Valider la propriété de l'adresse e-mail

Lorsque vous collectez une adresse e-mail lors de l'inscription, de la connexion, du paiement ou de la récupération de compte, la validation de la propriété nécessite généralement l'envoi d'un code secret à usage unique (OTP) ou d'un lien magique hors bande. Cela oblige les utilisateurs à quitter votre page, à attendre la réception de l'e-mail et à copier les codes, ce qui entraîne des frictions, des abandons et un risque d'hameçonnage, tout en révélant au fournisseur de messagerie quelle partie de confiance (RP) l'utilisateur visite.

L'API Email Verification (extension HTML côté client) et le protocole de validation de l'adresse e-mail (EVP) (protocole cryptographique de backend) permettent à une partie de confiance de vérifier qu'un utilisateur contrôle une adresse e-mail sans envoyer d'e-mail de validation. Le navigateur sert d'intermédiaire entre la partie de confiance et le fournisseur de messagerie de l'utilisateur (l'émetteur) :

  1. La partie de confiance inclut un <input> masqué avec autocomplete="email-verification-token" et un nonce cryptographique à usage unique dans le même <form> que l'entrée d'adresse e-mail.
  2. Lorsque l'utilisateur sélectionne ou saisit une adresse e-mail, le navigateur recherche l'enregistrement DNS _email-verification.<domain> TXT du domaine, vérifie que l'utilisateur dispose d'une session active avec l'émetteur faisant autorité et demande un jeton de validation de l'adresse e-mail (EVT) sans divulguer l'identité de la partie de confiance à l'émetteur.
  3. Le navigateur associe l'EVT à l'origine et à la session nonce de la partie de confiance dans un jeton JWT de liaison de clé (KB-JWT) signé avec une paire de clés de navigateur éphémère (EVT + KB-JWT), remplit l'entrée masquée et envoie le formulaire.
  4. Le backend de la partie de confiance vérifie la chaîne cryptographique et, si elle est valide, ignore l'étape du mot de passe à usage unique par e-mail ou du lien magique.

1. Activer l'essai Origin Trial et afficher le formulaire de validation

Pour participer en tant que site de validation (partie de confiance) :

  1. Fournissez le jeton d'essai Origin Trial sur la page hébergeant le formulaire à l'aide de l'en-tête de réponse HTTP Origin-Trial ou d'un élément <meta http-equiv="origin-trial">.
    • Essais Origin Trial tiers : les SDK d'identité intégrés peuvent injecter un jeton d'essai tiers, à condition que l'origine du titulaire de l'essai Origin Trial corresponde au domaine de l'émetteur (même site que l'émetteur, par exemple https://issuer.example).
  2. Générez un nonce aléatoire de manière cryptographique par affichage de formulaire avec au moins 128 bits d'entropie (par exemple, randomBytes(32).toString('base64url') de node:crypto), stockez-le dans l'état de session côté serveur associé à un cookie d'ID de session HttpOnly, Secure, SameSite=Lax (ne stockez jamais la valeur nonce attendue directement dans un cookie client où un pirate informatique pourrait rejouer à la fois le cookie et le jeton), puis affichez-le dans l'attribut nonce de l'entrée de jeton masquée. Diffusez la page avec Cache-Control: no-store afin que la navigation vers l'avant/l'arrière ne réutilise pas un nonce déjà utilisé.
  3. Ajoutez l'entrée de jeton masquée (autocomplete="email-verification-token") dans le même <form> que l'entrée d'adresse e-mail (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. Vérifier le jeton envoyé sur le serveur

Lorsque le formulaire est envoyé, traitez le champ token comme une entrée non fiable. Lisez et supprimez de manière atomique le nonce attendu de votre magasin de sessions côté serveur à la réception. Si le champ token est vide (par exemple, dans les navigateurs qui ne sont pas compatibles avec l'EVP ou lorsque l'utilisateur est déconnecté de son fournisseur de messagerie) ou si la validation échoue, revenez immédiatement à votre flux standard de mot de passe à usage unique par e-mail ou de lien magique :

// 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);
}

Utilisez toujours des bibliothèques SD-JWT et JOSE/JWT établies pour votre plate-forme de backend (comme @sd-jwt/core et jose dans Node.js, sd-jwt-python/jwcrypto dans Python ou sd-jwt-java/Nimbus JOSE+JWT sur la JVM) pour implémenter verifyEmailVerificationToken plutôt que d'écrire manuellement du code d'analyse JWT ou SD-JWT personnalisé.

Étape 1 : Analyser les revendications SD-JWT et valider l'adresse e-mail

Après avoir utilisé le expectedNonce à usage unique du stockage de session côté serveur, décodez la présentation SD-JWT (EVT + KB-JWT) et vérifiez que :

  • Le JWT de l'émetteur (jwt) et le JWT de liaison de clé (kbJwt) sont tous deux présents, ainsi que la revendication de l'émetteur (iss).
  • evtPayload.email_verified === true.
  • evtPayload.email correspond à l'adresse e-mail du formulaire envoyé à l'aide d'une comparaison non sensible à la casse (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.');
}

Étape 2 : Valider la délégation DNS TXT et charger le JWKS de l'émetteur

Avant d'envoyer une requête réseau à iss, vérifiez que le domaine de l'adresse e-mail envoyée délègue l'autorité à cet émetteur exact via un enregistrement TXT DNS _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> sans port, chemin d'accès ni barre oblique finale). Une fois que la délégation DNS correspond à iss octet par octet, récupérez ${iss}/.well-known/email-verification via HTTPS, confirmez que metadata.issuer === iss et chargez l'ensemble de clés Web JSON (JWKS) de l'émetteur.

Remarque sur la gestion de kid : Certains fournisseurs de messagerie majeurs (comme Gmail) omettent kid de l'en-tête EVT. Lorsque kid est absent et que le JWKS contient plusieurs clés, jose renvoie ERR_JWKS_MULTIPLE_MATCHING_KEYS (un AsyncIterable de CryptoKey correspondants). Itérez for await (const publicKey of err) pour valider chaque clé, comme indiqué à l'étape 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));

Étape 3 : Vérifiez la signature EVT et le jeton JWT de liaison de clé (KB-JWT)

Enfin, vérifiez les deux signatures et la liaison cryptographique entre les deux jetons à l'aide de la liste d'autorisation explicite de l'algorithme asymétrique (['Ed25519', 'EdDSA', 'ES256']) :

  1. Signature EVT (verifier) : vérifiez par rapport à issuerJwks (en interceptant ERR_JWKS_MULTIPLE_MATCHING_KEYS pour itérer les clés candidates lorsque kid est omis), en appliquant iss et une fenêtre de fraîcheur stricte (maxTokenAge: '5m', clockTolerance: '1m').
  2. Liaison du titulaire (options kbVerifier et sdJwt.verify) : extrayez la clé publique éphémère du navigateur de evtPayload.cnf.jwk, vérifiez que cnf.jwk.alg et decoded.kbJwt.header.alg se trouvent dans ALLOWED_ALGS et correspondent (en traitant 'Ed25519' et 'EdDSA' comme équivalents pendant la transition des implémentations d'essai Origin Trial vers la norme RFC 9864 'Ed25519'), puis vérifiez la signature KB-JWT avec typ: 'kb+jwt'. Transmettez keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience et keyBindingMaxAgeSeconds: 300 à sdJwt.verify(). @sd-jwt/core n'appelle que kbVerifier et valide nonce, aud, iat et sd_hash lorsque keyBindingNonce est fourni.
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,
});

Bonnes pratiques

  • À FAIRE : effectuez toutes les vérifications de jetons strictement sur le serveur. NE VALIDEZ PAS les jetons dans le code JavaScript côté client et ne faites pas confiance à l'état de validation côté client. Les vérifications côté client peuvent être facilement contournées ou falsifiées, et n'offrent aucune garantie de confiance à la partie de confiance.
  • À FAIRE : Utilisez des bibliothèques SD-JWT et JOSE/JWT établies pour votre plate-forme de backend (par exemple, @sd-jwt/core et jose dans Node.js) pour analyser et valider la présentation EVT~KB-JWT, et transmettez keyBindingNonce à sdJwt.verify() afin que la validation de la liaison de clé ne puisse pas être contournée. NE CRÉEZ PAS de routines personnalisées d'analyse et de validation de signature pour les jetons JWT, SD-JWT ou sd_hash.
  • À FAIRE : générez un nouveau nonce cryptographique (au moins 128 bits, comme randomBytes(32).toString('base64url')) sur le serveur pour chaque affichage de formulaire, stockez-le dans l'état de session côté serveur (pas directement dans un cookie client) et diffusez la page du formulaire avec Cache-Control: no-store.
  • À FAIRE : lisez et supprimez de manière atomique le nonce attendu du stockage de session côté serveur dès que la requête POST arrive, afin qu'un jeton intercepté ne puisse pas être rejoué.
  • À FAIRE : Effectuez une comparaison non sensible à la casse entre evtPayload.email et l'adresse e-mail du formulaire envoyé (email.toLowerCase()), tout en conservant la chaîne de l'adresse e-mail envoyée.
  • À FAIRE : Validez toutes les clés du JWKS de l'émetteur lorsque kid est absent de l'en-tête EVT (les fournisseurs tels que Gmail omettent kid).
  • À FAIRE : Vérifiez la délégation DNS (_email-verification.<domain>) avant de faire confiance à une revendication iss dans un EVT, et confirmez metadata.issuer === iss dans la réponse .well-known/email-verification.
  • NE CONSIDÉREZ PAS la validation de la propriété de l'adresse e-mail comme une preuve de la délivrabilité de la boîte de réception. La validation de l'adresse e-mail prouve que l'utilisateur contrôle le compte de messagerie auprès du fournisseur de référence. Continuez à envoyer des e-mails de bienvenue transactionnels et à gérer les rebonds normalement.
  • NE BLOQUEZ PAS la création de compte ou la connexion lorsque token est vide ou que la validation échoue. Revenez toujours à votre flux standard de mot de passe à usage unique par e-mail ou de lien magique.

Stratégie de remplacement

La validation de l'adresse e-mail (autocomplete="email-verification-token") est conçue comme une amélioration progressive par rapport à la validation traditionnelle des adresses e-mail hors bande (codes OTP ou liens magiques) :

  • Navigateurs non compatibles : les navigateurs qui ne sont pas compatibles avec autocomplete="email-verification-token" ignorent l'entrée masquée et envoient le formulaire avec une valeur token vide.
  • Domaines de messagerie non authentifiés ou non participants : lorsqu'un utilisateur saisit une adresse e-mail dont le domaine ne publie pas d'enregistrement DNS TXT _email-verification, ou lorsqu'il n'a pas de session active avec son fournisseur de messagerie dans le navigateur, le navigateur envoie le formulaire avec un token vide.
  • Jetons expirés ou non valides : si le nonce a expiré, a déjà été utilisé ou échoue à une étape de verifyEmailVerificationToken dans handleSignupSubmission, le serveur intercepte l'erreur et envoie un e-mail contenant un OTP traditionnel ou un lien magique sans interrompre le parcours d'inscription ou de connexion de l'utilisateur.