Search by

svss-labs / email-verification-api

Svss

Verify Email Verification Protocol (EVT+KB) tokens issued by email providers like Gmail

Package info

github.com/svss-labs/email-verification-api

pkg:composer/svss-labs/email-verification-api

Statistics

Installs: 103

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v0.1.1 2026-09-02 10:15 UTC

This package is auto-updated.

Last update: 2026-09-02 10:18:50 UTC


README

A PHP library to verify Email Verification Tokens (EVT) issued through the Email Verification Protocol — the proposed web standard that lets browsers verify a user's email address with their email provider directly, without one-time passwords (OTPs) or magic links.

The user picks their email address in a form, the browser obtains a cryptographically signed token from their email provider (e.g. Gmail), and your server verifies it with this package. No email is ever sent.

Requirements

  • PHP 8.3+
  • Composer
  • PHP extensions: curl, gmp, openssl, sodium (standard on most hosts; sodium and gmp are required for Ed25519 signature verification and safe IP arithmetic)

Installation

composer require svss-labs/email-verification-api

How the protocol works

  1. Your site renders a form with a hidden field carrying a per-render nonce.
  2. The user enters (or autocomplete-selects) their email address.
  3. The browser discovers the issuer via a DNS TXT record (_email-verification.<email-domain>), confirms the user's session with the provider, and obtains a signed token.
  4. On form submit, the browser attaches the token (an SD-JWT with a Key Binding JWT) to the hidden field.
  5. Your server verifies the full package: parsing → expected values (email/nonce/audience/age) → DNS delegation → issuer signature (metadata + JWKS) → key binding.

Full protocol details: Chrome developer article.

Usage

<?php

use SVSSLabs\EmailVerificationApi\EmailVerificationApi;
use SVSSLabs\EmailVerificationApi\VerifyEmailTokenInput;

$token   = $_POST['token'];            // hidden field, populated by the browser
$nonce   = $_SESSION['nonce'];         // the nonce you embedded in the form
$email   = $_POST['email'];
$audience = "https://your-domain.com"; // your site's origin

$input = new VerifyEmailTokenInput(
    token: $token,
    nonce: $nonce,
    email: $email,
    audience: $audience,
);

$result = EmailVerificationApi::verifyEmailToken($input);

if ($result->ok) {
    echo "{$result->value->email} is verified by {$result->value->issuer}";
} else {
    echo "Verification failed: {$result->error->message}";
}

Form markup

The browser only attaches a token when the form follows this exact contract:

<input type="email" name="email" autocomplete="email" required>
<input type="hidden" name="token" nonce="<?= htmlspecialchars($nonce) ?>"
       autocomplete="email-verification-token">

Laravel example

The token is bound to the nonce that was present when the user's email field changed, which can happen on a page load different from the submit. Store a small pool of recent nonces server-side (here in the session) so the submit handler can match the one the token was bound to.

routes/web.php

use App\Http\Controllers\EmailVerificationController;

Route::get('/signup', [EmailVerificationController::class, 'show']);
Route::post('/signup', [EmailVerificationController::class, 'verify']);

app/Http/Controllers/EmailVerificationController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use SVSSLabs\EmailVerificationApi\EmailVerificationApi;
use SVSSLabs\EmailVerificationApi\VerifyEmailTokenInput;

class EmailVerificationController extends Controller
{
    public function show(Request $request): View
    {
        // Keep a rolling pool of recent nonces — the token may be bound to a
        // nonce issued on an earlier page load.
        $nonces = $request->session()->get('evp_nonces', []);
        $nonce = bin2hex(random_bytes(32));
        $nonces[] = $nonce;
        $request->session()->put('evp_nonces', array_slice($nonces, -10));

        return view('signup', ['nonce' => $nonce]);
    }

    public function verify(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'email' => ['required', 'email'],
            'token' => ['required', 'string'],
        ]);

        $result = EmailVerificationApi::verifyEmailToken(new VerifyEmailTokenInput(
            token: $validated['token'],
            nonce: $request->session()->pull('evp_nonce'), // or match against the pool from `show()`
            email: $validated['email'],
            audience: $request->getSchemeAndHttpHost(),
        ));

        if (!$result->ok) {
            return back()->withErrors(['email' => "Verification failed: {$result->error->message}"]);
        }

        // $result->value->email is now verified by $result->value->issuer.
        // Create the user, log them in, etc.
        return redirect('/welcome');
    }
}

resources/views/signup.blade.php

<form method="post" action="{{ url('/signup') }}">
    @csrf
    <input type="email" name="email" autocomplete="email" required>
    <input type="hidden" name="token" nonce="{{ $nonce }}"
           autocomplete="email-verification-token">
    <button type="submit">Sign up</button>
</form>

@if ($errors->any())
    <ul>
        @foreach ($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
    </ul>
@endif

Nonce matching: pull('evp_nonce') works when the submit happens on the same page load. For the field-change-on-early-load case, keep the pool from show() (e.g. in the session) and match the nonce claim embedded in the token's KB-JWT, exactly like example/live-demo.php does.

VerifyEmailTokenInput

Parameter Type Default Description
token string Full EVT+KB presentation as submitted by the browser
nonce string The nonce your site generated for this form render
email string The email address being verified (compared case-insensitively)
audience string Your HTTP(S) origin, e.g. https://your-domain.com
maxTokenAgeSeconds int 300 Maximum accepted token age
clockToleranceSeconds int 60 Allowed clock skew
fetch ?callable null Override HTTP fetching (tests). null = real HTTPS via Guzzle
resolveTxt ?callable null Override DNS TXT resolution (tests). null = real dns_get_record
resolveHost ?callable null Override A/AAAA resolution (tests). null = real DNS
now ?callable null Clock override returning Unix milliseconds (tests)

Result

verifyEmailToken() returns a Result object:

  • Success: $result->ok === true, $result->value is a VerifiedEmail with email, issuer, audience, issuedAt (['evt' => ..., 'keyBinding' => ...]) and the full decoded claims.
  • Failure: $result->ok === false, $result->error is a VerificationError with stage (input, parse, expected-values, dns, issuer, key-binding), code, message and optional cause.

Common error codes:

Code Meaning
TOKEN_MALFORMED Token is not a parseable EVT+KB presentation
EMAIL_MISMATCH / NONCE_MISMATCH / AUDIENCE_MISMATCH Expected values don't match token claims
TOKEN_EXPIRED / TOKEN_NOT_YET_VALID iat outside the accepted window
DNS_DELEGATION_MISSING / DNS_DELEGATION_AMBIGUOUS No (or multiple) iss= TXT records at _email-verification.<domain>
ISSUER_MISMATCH DNS-delegated issuer differs from the token issuer
METADATA_FETCH_FAILED / JWKS_FETCH_FAILED Issuer endpoints unreachable
ALGORITHM_UNSUPPORTED EVT signed with an algorithm the issuer doesn't advertise
EVT_SIGNATURE_INVALID Signature doesn't verify against the issuer's JWKS
KB_SIGNATURE_INVALID Key Binding JWT doesn't verify against the cnf.jwk ephemeral key
SD_HASH_MISMATCH sd_hash doesn't match the presented token

Security notes

  • Fail-closed: any internal error results in a verification failure, never a false positive.
  • SSRF-safe network calls: issuer endpoints must be HTTPS, on publicly-routable hosts; resolved IPs are validated against global ranges and pinned via cURL CURLOPT_RESOLVE; redirects are disabled.
  • Constant-time comparisons (hash_equals) for signature-relevant hash checks; strict algorithm/key-type compatibility mapping prevents algorithm-confusion attacks.
  • Nonces should be single-use, random (e.g. bin2hex(random_bytes(32))) and bound to the user's session.

Testing your integration

1. Offline demo (no browser needed)

git clone https://github.com/svss-labs/email-verification-api.git
cd email-verification-api
composer install
php example/demo.php

Uses test fixtures and mocked network callbacks to walk through a successful verification.

2. Live browser demo (real Google/Gmail verification)

composer install
php -S localhost:8080 example/live-demo.php

Open http://localhost:8080, enter your Gmail address, and submit — the page verifies a real token against Google's production issuer (real DNS, real JWKS).

⚠️ Browser requirements — enable the Chrome flag

The browser side of this protocol is experimental and disabled by default. If no token is attached to your form:

  1. Open chrome://flags/#email-verification-protocol and set Email Verification Protocol to Enabled

Also required:

  • Desktop Chrome 150+ (desktop-only up to Chrome 152)
  • Signed in to your email provider in the same browser profile (for Gmail: signed into your Google Account)
  • The origin trial token active on your origin for production use — sign up at the Chrome origin trials dashboard and either serve it as a <meta http-equiv="origin-trial" content="..."> tag (the live demo auto-injects one from the EVP_OT_TOKEN env var or example/origin-trial-token.txt) or as a response header
  • Since Chrome 152, typing or pasting the email triggers verification on field change; earlier versions require selecting from the autocomplete dropdown
  • Since Chrome 152, while verification runs Chrome shows a progress indicator (spinner → checkmark) inside the email input field; the form may submit before it finishes, so don't rely on it completing before submit

You can sanity-check your browser setup independently with Google's verifier demo and mock email provider.

Troubleshooting

Symptom Fix
No token in the POST at all Enable the chrome://flags entry above; check you're signed in to the provider
NONCE_MISMATCH The token is bound to the nonce present when the email was entered. Keep one nonce per form render and store issued nonces server-side (the live demo keeps a small pool) until submit
METADATA_INVALID Older releases required the JWKS host to equal the issuer host — Gmail hosts its JWKS on a separate domain; update to a fixed version
DNS_DELEGATION_MISSING The email domain has no _email-verification TXT record — only participating providers (e.g. Gmail) can issue tokens
Verification works on rowan.fyi but not your origin Origin trial token missing/expired for your origin

Development

composer install
composer test     # PHPUnit
composer stan     # PHPStan level 5
composer check    # both

Compatibility

  • Gmail participates in the origin trial; any @gmail.com address works when signed in.
  • Breaking changes are expected while the protocol is experimental. The most notable one — Chrome 153 switching issuer requests to HTTP Message Signatures — only affects email providers that issue tokens. This library only verifies tokens, so no code changes are required on our side; you can keep this package updated to pick up any refinements to SD-JWT parsing, key binding, or expected-value checks that the protocol evolves.
  • Watch the WICG repo and the evp-announce mailing list for ongoing protocol changes.

License

The MIT License (MIT). Please see License File for more information.