svss-labs / email-verification-api
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
Requires
- php: ^8.3 || ^8.4 || ^8.5
- ext-curl: *
- ext-gmp: *
- ext-openssl: *
- ext-sodium: *
- guzzlehttp/guzzle: ^7.0 || ^8.0
- k2gl/sd-jwt: ^1.0
- web-token/jwt-framework: ^4.1
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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;sodiumandgmpare required for Ed25519 signature verification and safe IP arithmetic)
Installation
composer require svss-labs/email-verification-api
How the protocol works
- Your site renders a form with a hidden field carrying a per-render nonce.
- The user enters (or autocomplete-selects) their email address.
- 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. - On form submit, the browser attaches the token (an SD-JWT with a Key Binding JWT) to the hidden field.
- 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 fromshow()(e.g. in the session) and match the nonce claim embedded in the token's KB-JWT, exactly likeexample/live-demo.phpdoes.
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->valueis aVerifiedEmailwithemail,issuer,audience,issuedAt(['evt' => ..., 'keyBinding' => ...]) and the full decodedclaims. - Failure:
$result->ok === false,$result->erroris aVerificationErrorwithstage(input,parse,expected-values,dns,issuer,key-binding),code,messageand optionalcause.
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:
- Open
chrome://flags/#email-verification-protocoland 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 theEVP_OT_TOKENenv var orexample/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.comaddress 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.