For additional detail, you can walk through the [mock email provider demo
code](https://github.com/rowan-m/rowan-fyi/tree/main/src/pages/made/email-provider)
and refer to the issuer steps in the [Email Verification
API](https://wicg.github.io/email-verification/) and [Email Verification
Protocol](https://datatracker.ietf.org/doc/html/draft-hardt-email-verification)
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"

The `iss=` value must be a bare, canonical lowercase hostname (for example,
`iss=accounts.issuer.example`) with no scheme (`https://`), port, path, or
uppercase characters. Chrome rejects records that include non-hostname
components.

### 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:

    {
      "issuer": "https://accounts.issuer.example",
      "issuance_endpoint": "https://accounts.issuer.example/email-verification/issuance",
      "jwks_uri": "https://accounts.issuer.example/.well-known/vc-public-jwks",
      "signing_alg_values_supported": ["Ed25519", "EdDSA", "ES256"]
    }

Metadata properties:

- `issuer`: Your issuer origin (`https://<issuer-domain>`, with no trailing slash). Must match the origin the metadata file is served from byte-for-byte.
- `issuance_endpoint`: The HTTPS URL that receives token issuance `POST` requests.
- `jwks_uri`: The HTTPS URL serving your public JSON Web Key Set (`JWKS`).
- `signing_alg_values_supported`: Array of supported JWS signing algorithms.

> [!NOTE]
> **Note:** Starting in Chrome 156, Chrome supports the fully-specified `"Ed25519"` algorithm identifier (RFC 9864) in addition to `"EdDSA"`, while Chrome 154 and 155 only recognize `"EdDSA"`. Include both `"Ed25519"` and `"EdDSA"` in `signing_alg_values_supported` to support all active browser versions.

### 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](https://developer.chrome.com/docs/identity/fedcm/implement/identity-provider). 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](https://developer.chrome.com/docs/identity/fedcm/implement/identity-provider#accounts-list-endpoint).

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](https://developer.mozilla.org/docs/Web/API/FedCM_API/IDP_integration#update_login_status_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](https://datatracker.ietf.org/doc/rfc9421/) 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`](https://www.npmjs.com/package/structured-headers) and
[`http-message-sig`](https://www.npmjs.com/package/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;alg="Ed25519";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`. From Chrome 156, the `Signature-Key` header includes `alg="Ed25519"` in its `hwk` parameters; accept `"Ed25519"` and `"EdDSA"`, and allow `alg` to be omitted by earlier Chrome versions.
- **Payload** : The JSON body contains the `email` string requested for verification.

> [!CAUTION]
> **Caution:** In Chrome 153 and earlier, the `Sec-Fetch-Dest` header was `emailverification` (without the `-`).

> [!NOTE]
> **Note:** In Chrome 152 and earlier, issuance requests used `application/x-www-form-urlencoded` with a signed `request_token` parameter.

### 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`](https://www.npmjs.com/package/@sd-jwt/core) and
[`jose`](https://www.npmjs.com/package/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
    }

> [!IMPORTANT]
> **Important:** The issuer must return the email exactly as it was included in the request. For example, if the canonical email is `First.Last@example.com` but the form sent `first.LAST@example.com` and the issuer would deliver to that address successfully, then the issuer must return `first.LAST@example.com`. This avoids exposing more data than would be revealed by sending a confirmation email.

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 with a canonical lowercase hostname (`iss=<issuer-domain>`, without `https://`, a port, a path, or uppercase characters), both `.well-known` endpoints return `Content-Type: application/json`, `.well-known/email-verification` has an `issuer` matching `https://<issuer-domain>` byte-for-byte, and the `accounts_endpoint` response includes an account whose `email` matches the entered address.
- **Unsupported signing algorithm in `.well-known/email-verification`** : Chrome 154 and 155 require `"EdDSA"` in `signing_alg_values_supported`, whereas Chrome 156+ and the IETF specification support `"Ed25519"`. Advertise both `["Ed25519", "EdDSA"]` during the transition so Chrome Stable does not reject your discovery metadata.

### 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.