Email provider (issuer) implementation

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-Dest header: Set to email-verification.
  • HTTP Message Signatures: Verify the request signature using the ephemeral public key in Signature-Key and validate the Content-Digest.
  • Payload: The JSON body contains the email string 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-in HTTP header or calls navigator.login.setStatus("logged-in").
  • Session cookies blocked (SameSite=None): The browser fetches your accounts_endpoint and issuance_endpoint from a relying party on a different site. Because these are cross-site requests, your session cookie must include SameSite=None; Secure. A SameSite=Lax cookie 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>, without https://), both .well-known endpoints return Content-Type: application/json, and the accounts_endpoint response includes an account whose email matches the entered address.

Issuance request validation fails

  • Sec-Fetch-Dest header spelling: Chrome 154+ sends Sec-Fetch-Dest: email-verification (with a hyphen), while Chrome 153 sent emailverification (without a hyphen). Accept both values during the rollout.
  • HTTP Message Signature (@authority) mismatch behind a proxy: When verifying the RFC 9421 signature, the @authority component reflects the public-facing host. If your server sits behind a reverse proxy or load balancer, reconstruct the verification URL using X-Forwarded-Host (or your public origin) rather than the internal hostname, and compute Content-Digest over the raw request body bytes before JSON parsing.

Browser rejects the returned issuance_token

  • Modified or canonicalized email claim: From Chrome 156, the browser checks that the EVT email claim matches the requested email byte-for-byte. If your backend normalizes the address to a canonical account format (such as returning First.Last@example.com when first.LAST@example.com was requested), Chrome drops the token. Match the request against the user's account, but return the exact email string received in the request body.
  • Missing trailing tilde (~): Even with zero disclosures, the issuance_token must be a valid SD-JWT ending with a trailing tilde (<Issuer-signed-JWT>~) so the browser can append the <KB-JWT>.
  • Mismatched iss or cnf.jwk: Ensure the EVT iss claim is the exact HTTPS origin (https://<issuer-domain>, no trailing slash) matching your .well-known/email-verification metadata, and cnf.jwk embeds the browser's ephemeral public key from the Signature-Key header.