To implement email verification on your site, update your form markup to request the token and add server-side validation for incoming tokens.
Register for the origin trial
Verifying sites must have the origin trial configured on their site.
As of Chrome 154 third-party origin trials are supported with an important caveat: the registered origin for the trial must be same-site to the issuer. For example:
- Issuer domain:
issuer.example - OT registrant:
https://issuer.example - JavaScript origin:
https://issuer.example(orhttps://app.issuer.examplewith subdomain matching)
Configure form fields
Add a hidden token field to your email submission form:
<input
type="email"
name="email-address"
autocomplete="email">
<input
type="hidden"
name="token"
autocomplete="email-verification-token"
nonce="rAnD0m-VaLuE">
Field requirements:
- Email field: Set
type="email"andautocomplete="email"so Chrome can autofill and recognize the address. - Token field attributes:
- Set
autocomplete="email-verification-token": Chrome identifies this field to populate the token on submission. - Set
nonce="<VALUE>": The site must provide a unique session-bound nonce to verify the form submission.
- Set
Validate the email verification token (EVT)
When the user submits the form, your server receives the email address and the token from the hidden field. An empty token field indicates that the browser or provider does not support EVP, or the user skipped verification. If this occurs, fall back to your existing verification process, such as sending an OTP or magic link.
If a token is present, validate it as follows:
- Parse the token using an SD-JWT library.
- Validate expected values and session claims.
- Verify DNS delegation.
- Discover issuer metadata and fetch JWKS.
- Verify cryptographic signatures and key binding.
1. Parse the token
The token uses the RFC 9901: Selective Disclosure JWT
(SD-JWT+KB) format. Use the
appropriate libraries for your platform for parsing and validating the token.
For example, for Node you can use
@sd-jwt/core and
jose. In its raw form this looks like
this: an Issuer-signed JWT, followed by zero or more Disclosures, and ending
with a Key Binding JWT with each component separated by a tilde:
<Issuer-signed EVT>~<Disclosure 1>~...~<Disclosure N>~<Key Binding JWT>
In the current implementation, the token contains zero disclosures
(<Issuer-signed EVT>~<Key Binding JWT>). However this might change in the
future.
Decode the token with the library:
import { decodeSdJwtSync } from "@sd-jwt/core";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
createHash(alg === "sha-256" ? "sha256" : alg)
.update(data)
.digest();
const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const kbPayload = decoded.kbJwt?.payload;
If verifier.example verifies demo@provider.example, the decoded token looks
similar to the following:
{
"evtJwtDecodedHeader": {
"typ": "evt+jwt",
"alg": "EdDSA",
"kid": "issuer-key-id"
},
"evtJwtDecodedPayload": {
"iss": "https://provider.example",
"iat": 12345678901,
"exp": 12345679901,
"cnf": {
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "pUbLiCkEy123pUbLiCkEy123pUbLiCkEy123"
}
},
"email": "demo@provider.example",
"email_verified": true
},
"kbJwtDecodedHeader": {
"alg": "EdDSA",
"typ": "kb+jwt"
},
"kbJwtDecodedPayload": {
"aud": "https://verifier.example",
"iat": 12345678901,
"nonce": "rAnDoM123rAnDoM123rAnDoM123rAnDoM123",
"sd_hash": "hAsH456hAsH456hAsH456hAsH456hAsH456"
},
"disclosures": []
}
2. Validate expected values and session claims
Check that the basic values in the payload match your provided and expected values:
email_verified: Must betrue.email: Must match the email address submitted in the form.aud(audience): Must match your site's origin.nonce: Must match the nonce provided in your form.iat(issued at) andexp(expiry): Confirm the token is within its valid time window and has not expired.
3. Verify DNS delegation
Verify the _email-verification DNS record for the email address domain. For
example, for demo@gmail.com, query the _email-verification.gmail.com TXT
record. For this provider, the query returns the location of the account
provider, that is accounts.google.com.
$ dig +short TXT _email-verification.gmail.com
"iss=accounts.google.com"
Verify that the issuer scheme is https:// and that https://<domain> matches
the iss claim in the EVT.
4. Verify the EVT signature
Fetch the issuer's discovery metadata from
https://<issuer>/.well-known/email-verification:
{
"issuance_endpoint": "https://accounts.google.com/gsi/email-verification/issue",
"jwks_uri": "https://verifiablecredentials-pa.googleapis.com/.well-known/vc-public-jwks",
"signing_alg_values_supported": ["EdDSA"]
}
Fetch the JSON Web Key Set from jwks_uri.
Use your SD-JWT library to verify the token package. The library coordinates the validation:
- Validating the issuer signature on the EVT against the fetched JWKS.
- Validating the browser's signature on the KB-JWT using the ephemeral public
key in
cnf.jwk. - Verifying the key binding (
aud,nonce, and the digest hashsd_hash).
Example verification logic in Node.js:
import { SDJwtInstance } from "@sd-jwt/core";
import { importJWK, compactVerify } from "jose";
import { createHash } from "node:crypto";
const hasher = (data, alg) =>
createHash(alg === "sha-256" ? "sha256" : alg)
.update(data)
.digest();
const sdJwt = new SDJwtInstance({ hasher });
sdJwt.config({
hasher,
// Verifier for the Issuer-signed EVT
verifier: async (data, sig) => {
const token = `${data}.${sig}`;
const header = decoded.jwt.header;
const headerAlg = header.alg || "ES256";
// Match by kid if present, or iterate across matching algorithm keys
const keysToTry = header.kid
? jwksData.keys.filter(k => k.kid === header.kid)
: jwksData.keys;
for (const jwk of keysToTry) {
try {
const pubKey = await importJWK(jwk, jwk.alg || headerAlg);
await compactVerify(token, pubKey);
return true;
} catch {
// Try next candidate key
}
}
return false;
},
// Verifier for the Key Binding JWT (KB-JWT)
kbVerifier: async (data, sig) => {
try {
const browserJwkKey = evtPayload.cnf?.jwk;
if (!browserJwkKey) return false;
const pubKey = await importJWK(browserJwkKey, decoded.kbJwt.header.alg || "ES256");
await compactVerify(`${data}.${sig}`, pubKey);
return true;
} catch {
return false;
}
},
});
// The library automatically verifies EVT signature, KB-JWT signature, audience, nonce, and sd_hash
const result = await sdJwt.verify(rawToken, {
kb: {
expectedNonce: sessionNonce,
expectedAudience: "https://example.com",
required: true,
},
});
const verifiedPayload = result.payload;
If all steps succeed, you have verified the email address against the provider. If verification fails, fall back to sending a confirmation email to the user using your normal flow.
Troubleshooting
If verification fails or the browser does not provide a token, check the following common issues:
Token field is empty on submission
- Origin trial registration: Confirm the
Origin-Trialheader or<meta>tag is served on the page. For third-party origin trials (Chrome 154+), the registered trial origin must be same-site to the issuer (https://<issuer-domain>). You can inspect the origin trial configuration on a site in DevTools under Application > Frames > (select the relevant frame) > Origin trials. - Form markup: Both
<input type="email" autocomplete="email">and<input type="hidden" autocomplete="email-verification-token" nonce="...">must be in the same<form>element (not isolated across Shadow DOM boundaries), andnoncemust not be empty. - Early submission or reused page: The browser fetches the token in the background after the email is entered or autofilled. Submitting before the request completes leaves the token empty. This can happen if the user presses Return to submit the form after entering their email.
- Browser and provider prerequisites: The user must be signed in to a
participating provider in the same browser profile and have Verified
Email enabled in Chrome settings (
chrome://settings/contactInfo).
Issuer signature verification fails
- Missing
kidheader: Thekid(Key ID) claim in the EVT header and JWKS is optional (for example, Gmail omitskid). Ifkidis absent, iterate through all candidate keys in the issuer'sjwks_urirather than failing on a key ID lookup. - Algorithm identifiers (EdDSA and Ed25519): Issuers and libraries may
specify either
EdDSAorEd25519(alongsideES256). Ensure your JWK import and verification logic accepts both identifiers. - Issuer (
iss) origin format: The DNS TXT record (_email-verification.<domain>) contains a bare hostname (iss=accounts.issuer.example), whereas the EVTissclaim is a full HTTPS origin (https://accounts.issuer.example, with no trailing slash). Prefixhttps://to the DNS record value before comparing.
Key binding (KB-JWT) validation fails
- Mismatched or expired nonce: Ensure the
noncerendered in the<input>matches the active session nonce on your server and has not been overwritten by another tab or consumed by a prior request. - Audience (
aud) mismatch: Theaudclaim is the verifier's HTTPS origin (https://verifier.example, with no path or trailing slash).
Email claim (email) comparison fails
- Casing and canonicalization: Chrome 156+ returns the
emailclaim byte-for-byte as entered in the form, but earlier browser versions or providers may return a canonicalized address (for example,First.Last@example.comforfirst.last@example.com). Use a case-insensitive comparison when matching the token'semailclaim against the submitted form value.