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) :
- La partie de confiance inclut un
<input>masqué avecautocomplete="email-verification-token"et unnoncecryptographique à usage unique dans le même<form>que l'entrée d'adresse e-mail. - Lorsque l'utilisateur sélectionne ou saisit une adresse e-mail, le navigateur recherche l'enregistrement DNS
_email-verification.<domain>TXTdu 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. - Le navigateur associe l'EVT à l'origine et à la session
noncede 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. - 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) :
- 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-Trialou 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).
- 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
- 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')denode:crypto), stockez-le dans l'état de session côté serveur associé à un cookie d'ID de sessionHttpOnly,Secure,SameSite=Lax(ne stockez jamais la valeurnonceattendue directement dans un cookie client où un pirate informatique pourrait rejouer à la fois le cookie et le jeton), puis affichez-le dans l'attributnoncede l'entrée de jeton masquée. Diffusez la page avecCache-Control: no-storeafin que la navigation vers l'avant/l'arrière ne réutilise pas un nonce déjà utilisé. - 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.emailcorrespond à 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) omettentkidde l'en-tête EVT. Lorsquekidest absent et que le JWKS contient plusieurs clés,joserenvoieERR_JWKS_MULTIPLE_MATCHING_KEYS(unAsyncIterabledeCryptoKeycorrespondants). Itérezfor 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']) :
- Signature EVT (
verifier) : vérifiez par rapport àissuerJwks(en interceptantERR_JWKS_MULTIPLE_MATCHING_KEYSpour itérer les clés candidates lorsquekidest omis), en appliquantisset une fenêtre de fraîcheur stricte (maxTokenAge: '5m',clockTolerance: '1m'). - Liaison du titulaire (options
kbVerifieretsdJwt.verify) : extrayez la clé publique éphémère du navigateur deevtPayload.cnf.jwk, vérifiez quecnf.jwk.algetdecoded.kbJwt.header.algse trouvent dansALLOWED_ALGSet 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 signatureKB-JWTavectyp: 'kb+jwt'. TransmettezkeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceetkeyBindingMaxAgeSeconds: 300àsdJwt.verify().@sd-jwt/coren'appelle quekbVerifieret validenonce,aud,iatetsd_hashlorsquekeyBindingNonceest 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/coreetjosedans Node.js) pour analyser et valider la présentationEVT~KB-JWT, et transmettezkeyBindingNonceà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 ousd_hash. - À FAIRE : générez un nouveau
noncecryptographique (au moins 128 bits, commerandomBytes(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 avecCache-Control: no-store. - À FAIRE : lisez et supprimez de manière atomique le
nonceattendu du stockage de session côté serveur dès que la requêtePOSTarrive, afin qu'un jeton intercepté ne puisse pas être rejoué. - À FAIRE : Effectuez une comparaison non sensible à la casse entre
evtPayload.emailet 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
kidest absent de l'en-tête EVT (les fournisseurs tels que Gmail omettentkid). - À FAIRE : Vérifiez la délégation DNS (
_email-verification.<domain>) avant de faire confiance à une revendicationissdans un EVT, et confirmezmetadata.issuer === issdans 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
tokenest 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 valeurtokenvide. - 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 untokenvide. - Jetons expirés ou non valides : si le
noncea expiré, a déjà été utilisé ou échoue à une étape deverifyEmailVerificationTokendanshandleSignupSubmission, 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.