For additional detail, you can walk through the mock email provider demo code and refer to the issuer steps in the Email Verification API and Email Verification Protocol proposals.
As an issuer, you don't need to sign up for the origin trial or provide a token because the relying party site triggers the browser behavior. Ensure that your endpoints are configured to respond to those requests.
Configure issuer discovery
To allow browsers to automatically discover your verification endpoints when an
email address belonging to your domain is selected, expose your configuration
using DNS and a .well-known HTTP endpoint.
Configure DNS delegate record
Configure a DNS TXT record on your email domain that delegates verification authority to your issuer identifier. These identifiers can use the same domain depending on your infrastructure.
Record Format: _email-verification.<email-domain>
Example Zone File:
_email-verification.example.com IN TXT "iss=accounts.issuer.example"
Host a .well-known/email-verification endpoint
Host a JSON metadata file on your issuer domain under the /.well-known/ path.
This file outlines your issuance capabilities and the cryptographic signing
algorithms your infrastructure supports.
Endpoint: https://<issuer-domain>/.well-known/email-verification
Example response:
{
"issuance_endpoint": "https://accounts.issuer.example/email-verification/issuance",
"jwks_uri": "https://accounts.issuer.example/.well-known/vc-public-jwks",
"signing_alg_values_supported": ["EdDSA", "ES256"]
}
Host a .well-known/web-identity endpoint
You might have already implemented an additional .well-known JSON resource as
part of the Federated Credentials (FedCM)
API. This resource provides
links to your accounts endpoint and login URL.
Endpoint: https://<domain>/.well-known/web-identity
Example response:
{
"accounts_endpoint": "https://accounts.issuer.example/accounts",
"login_url": "https://accounts.issuer.example/login"
}
Use an accounts endpoint
The accounts endpoint from the FedCM API provides a list of signed-in accounts at the moment. The following example shows a minimal response. For more details, refer to the identity provider implementation guide.
Endpoint: as specified in .well-known/web-identity
The following is an example response:
{
"accounts": [
{
"id": "demo-example",
"name": "Demo User",
"email": "demo@example.com",
"given_name": "Demo"
}
]
}
Integrate with the Login Status API
The user must have an active session with the provider and you must signal that to the browser using the Login Status API.
When a user successfully signs in or signs out, serve the matching HTTP response header:
Set-Login: logged-in
Set-Login: logged-out
Alternatively, update the status using JavaScript in your web application context:
navigator.login.setStatus("logged-in");
navigator.login.setStatus("logged-out");
Handle issuance requests
Your issuance_endpoint receives an application/json POST request that
contains the email key and HTTP Message
Signatures headers for Signature,
Signature-Input, and Signature-Key.
Use a library that supports structured headers and HTTP Message Signatures for
your environment. In Node.js you can use
structured-headers and
http-message-sig.
Full request format:
POST /email-verification/issuance HTTP/1.1
Host: provider.example
Accept: application/json
Content-Digest: sha-256=:aBc123aBc123aBc123aBc123aBc123=:
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature: sig=:+dEf567dEf567/dEf567dEf567dEf567/dEf567==:
Signature-Input: sig=("@method" "@authority" "@path" "content-digest" "signature-key");created=1786455840
Signature-Key: sig=hwk;crv="Ed25519";kty="OKP";x="gHi890_gHi890_gHi890"
{email: "demo@example.com"}
Parse and validate the request:
- Session authentication: Validate your first-party session cookies sent with the request. The user must be authenticated.
Sec-Fetch-Destheader: Set toemail-verification.- HTTP Message Signatures: Verify the request signature using the
ephemeral public key in
Signature-Keyand validate theContent-Digest. - Payload: The JSON body contains the
emailstring requested for verification.
Issuance response
Upon successful validation of the session and request token, generate a signed
Selective Disclosure JWT (SD-JWT) returned as JSON using appropriate libraries
for your platform. For example, for Node you can use
@sd-jwt/core and
jose.
The raw payload format should look similar to:
{
"iss": "https://accounts.issuer.example",
"iat": 1780272000,
"exp": 1780272300,
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "pUbLiCKeY123pUbLiCKeY123pUbLiCKeY123",
"y": "pUbLiCKeY456pUbLiCKeY456pUbLiCKeY456"
}
},
"email": "demo@example.com",
"email_verified": true
}
Create, sign, and return the token:
import { importJWK, CompactSign } from "jose";
import { SDJwtInstance } from "@sd-jwt/core";
import crypto from "node:crypto";
// Issuer private key from secure storage
const privateKey = await importJWK(PRIVATE_KEY_JWK, "EdDSA");
const origin = url.origin;
const currentTime = Math.floor(Date.now() / 1000);
const evtPayload = {
iss: origin,
iat: currentTime,
exp: currentTime + 300, // 5 minutes
cnf: {
jwk: browserJwk,
},
email: payload.email, // exactly as received in payload
email_verified: true,
};
const sdJwt = new SDJwtInstance({
signer: async (data) => {
const [headerB64, payloadB64] = data.split(".");
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
const payload = Buffer.from(payloadB64, "base64url");
const signed = await new CompactSign(payload).setProtectedHeader(header).sign(privateKey);
return signed.split(".").pop()!;
},
signAlg: "EdDSA",
hasher: async (data, alg) => {
const nodeAlg = alg.replace("-", "");
return new Uint8Array(crypto.createHash(nodeAlg).update(data).digest());
},
hashAlg: "sha-256",
saltGenerator: async () => crypto.randomBytes(16).toString("base64url"),
});
const issuanceToken = await sdJwt.issue(evtPayload, undefined, {
header: {
alg: "EdDSA",
kid: PRIVATE_KEY_JWK.kid,
typ: "evt+jwt",
},
});
return sendResponse({ issuance_token: issuanceToken, }, 200);
The resulting response body looks similar to:
{
"issuance_token": "tOkEn123tOkEn123tOkEn123...~"
}
After you create and sign the email verification token, the browser passes this to the verifier site for validation.
Troubleshooting
If the browser does not contact your endpoints or rejects issued tokens, check the following common issues:
Browser never calls accounts_endpoint or issuance_endpoint
- Login Status not set: Chrome only queries your endpoints if it knows the
user is signed in. Ensure your sign-in flow sets the
Set-Login: logged-inHTTP header or callsnavigator.login.setStatus("logged-in"). - Session cookies blocked (
SameSite=None): The browser fetches youraccounts_endpointandissuance_endpointfrom a relying party on a different site. Because these are cross-site requests, your session cookie must includeSameSite=None; Secure. ASameSite=Laxcookie works when testing on a same-site verifier, but is omitted on cross-site requests. - Discovery or account mismatch: Verify that
_email-verification.<email-domain>returns a single TXT record (iss=<issuer-domain>, withouthttps://), both.well-knownendpoints returnContent-Type: application/json, and theaccounts_endpointresponse includes an account whoseemailmatches the entered address.
Issuance request validation fails
Sec-Fetch-Destheader spelling: Chrome 154+ sendsSec-Fetch-Dest: email-verification(with a hyphen), while Chrome 153 sentemailverification(without a hyphen). Accept both values during the rollout.- HTTP Message Signature (
@authority) mismatch behind a proxy: When verifying the RFC 9421 signature, the@authoritycomponent reflects the public-facing host. If your server sits behind a reverse proxy or load balancer, reconstruct the verification URL usingX-Forwarded-Host(or your public origin) rather than the internal hostname, and computeContent-Digestover the raw request body bytes before JSON parsing.
Browser rejects the returned issuance_token
- Modified or canonicalized
emailclaim: From Chrome 156, the browser checks that the EVTemailclaim matches the requestedemailbyte-for-byte. If your backend normalizes the address to a canonical account format (such as returningFirst.Last@example.comwhenfirst.LAST@example.comwas requested), Chrome drops the token. Match the request against the user's account, but return the exactemailstring received in the request body. - Missing trailing tilde (
~): Even with zero disclosures, theissuance_tokenmust be a valid SD-JWT ending with a trailing tilde (<Issuer-signed-JWT>~) so the browser can append the<KB-JWT>. - Mismatched
issorcnf.jwk: Ensure the EVTissclaim is the exact HTTPS origin (https://<issuer-domain>, no trailing slash) matching your.well-known/email-verificationmetadata, andcnf.jwkembeds the browser's ephemeral public key from theSignature-Keyheader.