Eigendom van e-mailadres verifiëren

Als je een e-mailadres verzamelt tijdens de aanmelding, het inloggen, het betalen of het accountherstel, moet je traditioneel een eenmalige toegangscode of magic link buiten de band sturen om het eigendom te verifiëren. Hierdoor moeten gebruikers je pagina verlaten, wachten op de e-mail en codes kopiëren. Dit leidt tot frictie, afhaken en phishingrisico's, terwijl de e-mailprovider ook te weten komt welke Relying Party (RP) de gebruiker bezoekt.

Met de e-mailverificatie API (HTML-extensie aan de clientzijde) en het Email Verification Protocol (EVP) (cryptografisch protocol aan de backend) kan een Relying Party verifiëren dat een gebruiker een e-mailadres beheert zonder een verificatiemail te sturen. De browser bemiddelt tussen de Relying Party en de e-mailprovider van de gebruiker (de uitgever):

  1. De relying party bevat een verborgen <input> met autocomplete="email-verification-token" en een cryptografische nonce voor eenmalig gebruik in dezelfde <form> als de e-mailinvoer.
  2. Als de gebruiker een e-mailadres selecteert of invoert, zoekt de browser de _email-verification.<domain> DNS TXT-record van het domein op, verifieert of de gebruiker een actieve sessie heeft met de gezaghebbende uitgever en vraagt een e-mailverificatietoken (EVT) aan zonder de identiteit van de vertrouwende partij bekend te maken aan de uitgever.
  3. De browser bindt de EVT aan de oorsprong en sessie van de Relying Party nonce in een Key Binding JWT (KB-JWT) die is ondertekend met een kortstondig browsersleutelpaar (EVT + KB-JWT), vult de verborgen invoer in en dient het formulier in.
  4. De backend van de Relying Party verifieert de cryptografische keten en slaat de stap voor het eenmalige wachtwoord of de magic link over als deze geldig is.

1. De Origin Trial aanzetten en het verificatieformulier weergeven

Zo neem je deel als verifiërende site (relying party):

  1. Verstrek de Origin Trial-token op de pagina die het formulier host met de HTTP-reactieheader Origin-Trial of een <meta http-equiv="origin-trial">-element.
    • Origin trials van derden: ingesloten identiteits-SDK's kunnen een proeftoken van derden injecteren, op voorwaarde dat de oorsprong van de registrant van de origin trial overeenkomt met het domein van de uitgever (dezelfde site als de uitgever, zoals https://issuer.example).
  2. Genereer een cryptografisch willekeurige nonce per formulierweergave met minstens 128 bits entropie (bijvoorbeeld randomBytes(32).toString('base64url') van node:crypto), sla deze op in de sessiestatus aan de serverzijde met een HttpOnly, Secure, SameSite=Lax sessie-ID-cookie (sla de verwachte nonce-waarde nooit rechtstreeks op in een clientcookie waar een aanvaller zowel de cookie als de token kan herhalen) en geef deze weer in het nonce-kenmerk van de verborgen tokeninvoer. Lever de pagina met Cache-Control: no-store zodat navigatie terug/vooruit geen al gebruikte nonce hergebruikt.
  3. Voeg de verborgen tokeninvoer (autocomplete="email-verification-token") toe aan dezelfde <form> als de e-mailinvoer (autocomplete="email").
<!-- Serve with:
     Origin-Trial: <YOUR_ORIGIN_TRIAL_TOKEN>
     Cache-Control: no-store -->
<form method="POST" action="/api/verify-email">
  <label for="email">Email address:</label>
  <input
    type="email"
    id="email"
    name="email"
    autocomplete="email"
    required
  />

  <!-- MANDATORY: Must be in the same <form> as the email input.
       The nonce value must be freshly generated on the server per form render. -->
  <input
    type="hidden"
    name="token"
    nonce="SERVER_GENERATED_PER_FORM_NONCE"
    autocomplete="email-verification-token"
  />

  <button type="submit">Continue</button>
</form>

2. De ingediende token verifiëren op de server

Als het formulier wordt ingediend, wordt het veld token behandeld als niet-vertrouwde invoer. Lees en verwijder de verwachte nonce atomisch uit je sessieopslag aan de serverzijde bij ontvangst. Als het veld token leeg is (bijvoorbeeld in browsers die EVP niet ondersteunen of als de gebruiker is uitgelogd bij de e-mailprovider) of als de verificatie mislukt, val je onmiddellijk terug op je standaard OTP- of magic link-proces voor e-mail:

// Replace with your public relying-party origin
const RP_ORIGIN = 'https://rp.example.com';

export async function handleSignupSubmission(request) {
  const formData = await request.formData();
  const submittedEmail = String(formData.get('email') || '').trim();
  const rawToken = String(formData.get('token') || '').trim();
  // Atomically read and delete the single-use nonce from server-side session state
  const expectedNonce = await consumeSessionNonce(request);

  if (rawToken && expectedNonce) {
    try {
      await verifyEmailVerificationToken({
        rawToken,
        submittedEmail,
        expectedNonce,
        expectedAudience: RP_ORIGIN,
      });
      // Fast path: email ownership is cryptographically verified; skip OTP email.
      return completeAccountCreation(submittedEmail);
    } catch (err) {
      // Log token verification failures before falling back to standard email verification.
      console.error('Email verification token check failed:', err);
    }
  }

  // Fallback path: send standard OTP code or magic link email to submittedEmail.
  return sendTraditionalVerificationEmail(submittedEmail);
}

Gebruik altijd gevestigde SD-JWT- en JOSE/JWT-bibliotheken voor je backend-platform (zoals @sd-jwt/core en jose in Node.js, sd-jwt-python / jwcrypto in Python of sd-jwt-java / Nimbus JOSE+JWT op de JVM) om verifyEmailVerificationToken te implementeren in plaats van handmatig aangepaste JWT- of SD-JWT-parseercode te schrijven.

Stap 1: Parseer de SD-JWT-claims en valideer het e-mailadres

Nadat je de eenmalige expectedNonce uit de sessieopslag aan de serverzijde hebt gebruikt, decodeer je de SD-JWT-presentatie (EVT + KB-JWT) en verifieer je het volgende:

  • Zowel de JWT van de uitgever (jwt) als de JWT voor sleutelbinding (kbJwt) zijn aanwezig, samen met de claim van de uitgever (iss).
  • evtPayload.email_verified === true.
  • evtPayload.email komt overeen met het e-mailadres van het ingediende formulier via een niet-hoofdlettergevoelige vergelijking (toLowerCase()).
import { createHash } from 'node:crypto';
import { decodeSdJwtSync } from '@sd-jwt/core';

const hasher = (data, alg) =>
  createHash(alg === 'sha-256' ? 'sha256' : alg).update(data).digest();

const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const iss = evtPayload.iss;

if (!expectedNonce || !decoded.kbJwt || !iss || evtPayload.email_verified !== true) {
  throw new Error('Missing nonce, KB-JWT, issuer, or email_verified !== true.');
}
// MANDATORY: Compare email addresses case-insensitively
if (evtPayload.email?.toLowerCase() !== submittedEmail.toLowerCase()) {
  throw new Error('Submitted email does not match the EVT email claim.');
}

Stap 2: Valideer de DNS TXT-delegatie en laad de JWKS van de uitgever

Voordat je een netwerkverzoek indient bij iss, controleer je of het domein van het ingediende e-mailadres de autoriteit delegeert aan die exacte uitgever via een _email-verification.<domain> DNS TXT-record (iss=<issuer-host> -> https://<issuer-host> zonder poort, pad of afsluitende slash). Zodra de DNS-delegering byte voor byte overeenkomt met iss, haal je ${iss}/.well-known/email-verification op via HTTPS, bevestig je dat metadata.issuer === iss en laad je de JSON Web Key Set (JWKS) van de uitgever.

Opmerking over de verwerking van kid: Sommige grote e-mailproviders (zoals Gmail) laten kid weg uit de EVT-header. Als kid ontbreekt en de JWKS meerdere sleutels bevat, genereert jose ERR_JWKS_MULTIPLE_MATCHING_KEYS (een AsyncIterable van overeenkomende CryptoKey's). Herhaal for await (const publicKey of err) om elke sleutel te verifiëren, zoals getoond in stap 3.

import dns from 'node:dns/promises';
import { createRemoteJWKSet } from 'jose';

const domain = submittedEmail.split('@')[1];
const txtRecords = (await dns.resolveTxt(`_email-verification.${domain}`)).map((r) => r.join(''));
if (!txtRecords.some((txt) => txt.startsWith('iss=') && `https://${txt.slice(4).trim()}` === iss)) {
  throw new Error(`Issuer "${iss}" is not delegated via DNS for "${domain}".`);
}

const discoveryUrl = new URL('/.well-known/email-verification', iss);
if (discoveryUrl.protocol !== 'https:') throw new Error('Issuer must use HTTPS.');
const metadata = await (await fetch(discoveryUrl)).json();
if (metadata.issuer !== iss || !metadata.jwks_uri?.startsWith('https://')) {
  throw new Error('Invalid issuer metadata or non-HTTPS jwks_uri.');
}
const issuerJwks = createRemoteJWKSet(new URL(metadata.jwks_uri));

Stap 3: Verifieer de EVT-handtekening en de JWT voor sleutelbinding (KB-JWT)

Verifieer tot slot beide handtekeningen en de cryptografische binding tussen de 2 tokens met de expliciete toelatingslijst voor asymmetrische algoritmen (['Ed25519', 'EdDSA', 'ES256']):

  1. EVT-handtekening (verifier): Verifieer aan de hand van issuerJwks (vang ERR_JWKS_MULTIPLE_MATCHING_KEYS op om kandidaatsleutels te herhalen als kid wordt weggelaten), dwing iss af en een strakke versheidsperiode (maxTokenAge: '5m', clockTolerance: '1m').
  2. Houderbinding (opties kbVerifier en sdJwt.verify): Extraheer de tijdelijke openbare sleutel van de browser uit evtPayload.cnf.jwk, bevestig dat zowel cnf.jwk.alg als decoded.kbJwt.header.alg in ALLOWED_ALGS staan en overeenkomen (behandel 'Ed25519' en 'EdDSA' als equivalent terwijl Origin Trial-implementaties overstappen naar RFC 9864 'Ed25519') en verifieer de handtekening van KB-JWT met typ: 'kb+jwt'. Geef keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience en keyBindingMaxAgeSeconds: 300 door aan sdJwt.verify(). @sd-jwt/core roept alleen kbVerifier aan en valideert nonce, aud, iat en sd_hash als keyBindingNonce wordt geleverd.
import { SDJwtInstance } from '@sd-jwt/core';
import { importJWK, jwtVerify } from 'jose';

// IETF draft-hardt-email-verification-02 (§3.3, §5.1.1) specifies RFC 9864 'Ed25519' and 'ES256'.
// TODO: Remove 'EdDSA' once live issuers (e.g. Gmail) and Chrome Origin Trial builds finish
// transitioning from 'EdDSA' (with crv: 'Ed25519') to 'Ed25519'.
const ALLOWED_ALGS = ['Ed25519', 'EdDSA', 'ES256'];
const normalizeAlg = (alg) => (alg === 'Ed25519' ? 'EdDSA' : alg);
const evtOptions = {
  typ: 'ev+sd-jwt',
  issuer: iss,
  algorithms: ALLOWED_ALGS,
  maxTokenAge: '5m',
  clockTolerance: '1m',
};

const sdJwt = new SDJwtInstance({
  hasher,
  verifier: async (data, sig) => {
    const compactEvt = `${data}.${sig}`;
    try {
      await jwtVerify(compactEvt, issuerJwks, evtOptions);
    } catch (err) {
      // When `kid` is omitted (e.g. Gmail) and multiple keys match in the JWKS,
      // jose throws JWKSMultipleMatchingKeys, which yields each matching CryptoKey.
      if (err?.code !== 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') throw err;
      let matched = false;
      for await (const publicKey of err) {
        try {
          await jwtVerify(compactEvt, publicKey, evtOptions);
          matched = true;
          break;
        } catch {}
      }
      if (!matched) throw err;
    }
    return true;
  },
  kbVerifier: async (data, sig) => {
    const holderJwk = evtPayload.cnf?.jwk;
    const holderAlg = holderJwk?.alg;
    const kbAlg = decoded.kbJwt.header.alg;
    if (
      !holderAlg ||
      !kbAlg ||
      !ALLOWED_ALGS.includes(holderAlg) ||
      !ALLOWED_ALGS.includes(kbAlg) ||
      normalizeAlg(kbAlg) !== normalizeAlg(holderAlg)
    ) {
      throw new Error('Missing, disallowed, or mismatched KB-JWT algorithm.');
    }
    const holderKey = await importJWK(holderJwk, kbAlg);
    await jwtVerify(`${data}.${sig}`, holderKey, {
      typ: 'kb+jwt',
      audience: expectedAudience,
      algorithms: [kbAlg],
      maxTokenAge: '5m',
      clockTolerance: '1m',
    });
    return true;
  },
});

const verified = await sdJwt.verify(rawToken, {
  requiredClaimKeys: ['email', 'email_verified'],
  keyBindingNonce: expectedNonce,
  expectedKeyBindingAudience: expectedAudience,
  keyBindingMaxAgeSeconds: 300,
  skewSeconds: 60,
});

Praktische tips

  • Voer alle tokenverificatie strikt uit op de server. Valideer tokens NIET in JavaScript aan de clientzijde en vertrouw geen verificatiestatus aan de clientzijde. Controles aan de clientzijde kunnen makkelijk worden omzeild of vervalst en bieden geen enkele garantie aan de vertrouwende partij.
  • WEL: Gebruik gevestigde SD-JWT- en JOSE/JWT-bibliotheken voor je backend-platform (bijvoorbeeld @sd-jwt/core en jose in Node.js) om de EVT~KB-JWT-presentatie te parseren en te valideren, en geef keyBindingNonce door aan sdJwt.verify() zodat de verificatie van sleutelbinding niet kan worden omzeild. Maak GEEN eigen routines voor het parseren en verifiëren van handtekeningen voor aangepaste JWT's, SD-JWT's of sd_hash.
  • WEL: Genereer een nieuwe cryptografische nonce (minimaal 128 bits, zoals randomBytes(32).toString('base64url')) op de server voor elke formulierweergave, sla deze op in de sessiestatus aan de serverzijde (niet rechtstreeks in een clientcookie) en lever de formulierpagina met Cache-Control: no-store.
  • WEL: Lees en verwijder de verwachte nonce atomisch uit de sessieopslag aan de serverzijde zodra het POST-verzoek binnenkomt, zodat een onderschept token niet opnieuw kan worden afgespeeld.
  • Voer WEL een niet-hoofdlettergevoelige vergelijking uit tussen evtPayload.email en het e-mailadres van het ingediende formulier (email.toLowerCase()), terwijl je de ingediende e-mailtekenreeks behoudt.
  • WEL alle sleutels in de JWKS van de uitgever proefverifiëren als kid ontbreekt in de EVT-header (providers zoals Gmail laten kid weg).
  • WEL: Verifieer de DNS-overdracht (_email-verification.<domain>) voordat je een iss-claim in een EVT vertrouwt en bevestig metadata.issuer === iss in de .well-known/email-verification-reactie.
  • Beschouw de verificatie van e-maileigendom NIET als bewijs van de bezorgbaarheid van de inbox. EVP bewijst dat de gebruiker het e-mailaccount beheert bij de gezaghebbende provider. Stuur nog steeds transactionele welkomstmails en verwerk bounces op de normale manier.
  • Blokkeer het maken van accounts of inloggen NIET als token leeg is of als de verificatie mislukt. Schakel altijd probleemloos over naar je standaard eenmalige wachtwoord via e-mail of magic link.

Reservestrategie

E-mailverificatie (autocomplete="email-verification-token") is ontworpen als een progressive enhancement ten opzichte van traditionele out-of-band e-mailverificatie (eenmalige wachtwoorden of magic links):

  • Niet-ondersteunde browsers: Browsers die autocomplete="email-verification-token" niet ondersteunen, negeren de verborgen invoer en dienen het formulier in met een lege token-waarde.
  • Niet-geverifieerde of niet-deelnemende e-maildomeinen: Als een gebruiker een e-mailadres invoert waarvan het domein geen _email-verification DNS TXT-record publiceert of als de gebruiker geen actieve sessie heeft met de e-mailprovider in de browser, dient de browser het formulier in met een lege token.
  • Verlopen of ongeldige tokens: Als de nonce is verlopen, al is gebruikt of een stap van verifyEmailVerificationToken in handleSignupSubmission niet doorloopt, vangt de server de fout op en stuurt deze een traditionele OTP of e-mail met een magic link zonder het aanmeldings- of inlogproces van de gebruiker te onderbreken.