Quando si raccoglie un indirizzo email durante la registrazione, l'accesso, il pagamento o il recupero dell'account, la verifica della proprietà richiede tradizionalmente l'invio di un passcode monouso (OTP) out-of-band o di un link magico. Questo costringe gli utenti a lasciare la pagina, attendere la consegna dell'email e copiare i codici, introducendo attrito, abbandono e rischio di phishing, oltre a rivelare al provider email quale relying party (RP) l'utente sta visitando.
L'API Email Verification (estensione HTML lato client) e l'Email Verification Protocol (EVP) (protocollo di crittografia di backend) consentono a un'entità attendibile di verificare che un utente controlli un indirizzo email senza inviare un'email di verifica. Il browser funge da intermediario tra la relying party e il provider email dell'utente (l'emittente):
- La relying party include un
<input>nascosto conautocomplete="email-verification-token"e unnoncecrittografico monouso nello stesso<form>dell'input dell'email. - Quando l'utente seleziona o inserisce un indirizzo email, il browser cerca il record DNS
_email-verification.<domain>TXTdel dominio, verifica che l'utente abbia una sessione attiva con l'emittente autoritativo e richiede un token di verifica email (EVT) senza divulgare l'identità della relying party all'emittente. - Il browser associa l'EVT all'origine e alla sessione
noncedella relying party in un JWT di associazione delle chiavi (KB-JWT) firmato con una coppia di chiavi del browser temporanea (EVT + KB-JWT), compila l'input nascosto e invia il modulo. - Il backend del componente attendibile verifica la catena crittografica e, se valida, salta il passaggio dell'OTP via email o del link magico.
1. Attiva l'origin trial ed esegui il rendering del modulo di verifica
Per partecipare come sito di verifica (relying party):
- Fornisci il token dell'origin trial nella pagina che ospita il modulo utilizzando l'intestazione della risposta HTTP
Origin-Trialo un elemento<meta http-equiv="origin-trial">.- Origin trial di terze parti: gli SDK di identità incorporati possono inserire un token di prova di terze parti, a condizione che l'origine del registrante dell'origin trial corrisponda al dominio dell'emittente (stesso sito dell'emittente, ad esempio
https://issuer.example).
- Origin trial di terze parti: gli SDK di identità incorporati possono inserire un token di prova di terze parti, a condizione che l'origine del registrante dell'origin trial corrisponda al dominio dell'emittente (stesso sito dell'emittente, ad esempio
- Genera un nonce casuale dal punto di vista crittografico per ogni rendering del modulo con almeno 128 bit di entropia (ad esempio,
randomBytes(32).toString('base64url')danode:crypto), memorizzalo nello stato della sessione lato server con chiave da un cookie ID sessioneHttpOnly,Secure,SameSite=Lax(non memorizzare mai il valorenonceprevisto direttamente all'interno di un cookie client in cui un utente malintenzionato potrebbe riprodurre sia il cookie che il token) ed eseguine il rendering nell'attributononcedell'input del token nascosto. Pubblica la pagina conCache-Control: no-storein modo che la navigazione avanti/indietro non riutilizzi un nonce già utilizzato. - Aggiungi l'input del token nascosto (
autocomplete="email-verification-token") all'interno dello stesso<form>dell'input dell'email (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. Verifica il token inviato sul server
Quando il modulo viene inviato, tratta il campo token come input non attendibile. Leggi ed elimina in modo atomico il valore nonce previsto dallo spazio di archiviazione della sessione lato server al momento della ricezione. Se il campo token è vuoto (ad esempio, nei browser che non supportano EVP o quando l'utente non ha eseguito l'accesso al proprio provider email) o se la verifica non va a buon fine, torna immediatamente al flusso standard di OTP via email o link magico:
// 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);
}
Utilizza sempre librerie SD-JWT e JOSE/JWT consolidate per la tua piattaforma di backend (come @sd-jwt/core e jose in Node.js, sd-jwt-python/jwcrypto in Python o sd-jwt-java/Nimbus JOSE+JWT sulla JVM) per implementare verifyEmailVerificationToken anziché scrivere manualmente codice di analisi JWT o SD-JWT personalizzato.
Passaggio 1: analizza le rivendicazioni SD-JWT e convalida l'indirizzo email
Dopo aver utilizzato il expectedNonce monouso dallo spazio di archiviazione della sessione lato server, decodifica la presentazione SD-JWT (EVT + KB-JWT) e verifica che:
- Sono presenti sia il JWT dell'emittente (
jwt) sia il JWT di associazione della chiave (kbJwt), insieme all'attestazione dell'emittente (iss). evtPayload.email_verified === true.evtPayload.emailcorrisponde all'indirizzo email del modulo inviato utilizzando un confronto senza distinzione tra maiuscole e minuscole (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.');
}
Passaggio 2: convalida la delega DNS TXT e carica il JWKS dell'emittente
Prima di effettuare qualsiasi richiesta di rete a iss, verifica che il dominio dell'indirizzo email inviato deleghi l'autorità esattamente a quell'emittente tramite un record TXT DNS _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> senza porta, percorso o barra finale). Una volta che la delega DNS corrisponde a iss byte per byte, recupera ${iss}/.well-known/email-verification tramite HTTPS, conferma che metadata.issuer === iss e carica il set di chiavi web JSON (JWKS) dell'emittente.
Nota sulla gestione di
kid: alcuni dei principali provider email (come Gmail) omettonokiddall'intestazione EVT. Quandokidè assente e il JWKS contiene più chiavi,josegeneraERR_JWKS_MULTIPLE_MATCHING_KEYS(unAsyncIterablediCryptoKeycorrispondenti): esegui l'iterazione difor await (const publicKey of err)per verificare ogni chiave, come mostrato nel passaggio 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));
Passaggio 3: verifica la firma EVT e il JWT di associazione della chiave (KB-JWT)
Infine, verifica entrambe le firme e l'associazione crittografica tra i due token utilizzando la lista consentita esplicita dell'algoritmo asimmetrico (['Ed25519', 'EdDSA', 'ES256']):
- Firma EVT (
verifier): verifica rispetto aissuerJwks(rilevandoERR_JWKS_MULTIPLE_MATCHING_KEYSper eseguire l'iterazione delle chiavi candidate quandokidviene omesso), applicandoisse una finestra di aggiornamento ristretta (maxTokenAge: '5m',clockTolerance: '1m'). - Associazione del titolare (opzioni
kbVerifieresdJwt.verify): estrai la chiave pubblica temporanea del browser daevtPayload.cnf.jwk, verifica checnf.jwk.algedecoded.kbJwt.header.algsiano presenti inALLOWED_ALGSe corrispondano (considerando'Ed25519'e'EdDSA'come equivalenti durante la transizione delle implementazioni di origin trial a RFC 9864'Ed25519') e verifica la firmaKB-JWTcontyp: 'kb+jwt'. PassakeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceekeyBindingMaxAgeSeconds: 300asdJwt.verify():@sd-jwt/corerichiamakbVerifiere convalidanonce,aud,iatesd_hashsolo quando viene fornitokeyBindingNonce.
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,
});
Best practice
- SÌ: esegui tutta la verifica dei token rigorosamente sul server. NON convalidare i token in JavaScript lato client o considerare attendibile qualsiasi stato di verifica lato client: i controlli lato client possono essere facilmente aggirati o falsificati e non forniscono alcuna garanzia di attendibilità alla parte di supporto.
- DO use established SD-JWT and JOSE/JWT libraries for your backend platform (for example,
@sd-jwt/coreandjosein Node.js) to parse and validate theEVT~KB-JWTpresentation, and passkeyBindingNoncetosdJwt.verify()so Key Binding verification cannot be bypassed. NON creare manualmente routine personalizzate di analisi e verifica della firma di JWT, SD-JWT osd_hash. - SÌ: genera un
noncecrittografico nuovo (almeno 128 bit, ad esempiorandomBytes(32).toString('base64url')) sul server per ogni rendering del modulo, memorizzalo nello stato della sessione lato server (non direttamente in un cookie client) e pubblica la pagina del modulo conCache-Control: no-store. - SÌ: leggi ed elimina in modo atomico il valore
nonceprevisto dallo spazio di archiviazione della sessione lato server immediatamente quando arriva la richiestaPOST, in modo che un token intercettato non possa essere riprodotto. - ESEGUI un confronto senza distinzione tra maiuscole e minuscole tra
evtPayload.emaile l'indirizzo email del modulo inviato (email.toLowerCase()), conservando la stringa dell'email inviata. - ESEGUI una verifica di prova di tutte le chiavi nel JWKS dell'emittente quando
kidnon è presente nell'intestazione EVT (provider come Gmail omettonokid). - VERIFICA la delega DNS (
_email-verification.<domain>) prima di considerare attendibile qualsiasi rivendicazioneissin un EVT e confermametadata.issuer === issnella risposta.well-known/email-verification. - NON considerare la verifica della proprietà dell'indirizzo email come prova della recapitabilità nella posta in arrivo. EVP dimostra che l'utente controlla l'account email con il provider autorevole; continua a inviare email di benvenuto transazionali e a gestire i mancati recapiti normalmente.
- NON bloccare la creazione dell'account o l'accesso quando
tokenè vuoto o quando la verifica non va a buon fine. Esegui sempre il downgrade al flusso standard di OTP via email o link magico.
Strategia di riserva
La verifica email (autocomplete="email-verification-token") è progettata come un potenziamento progressivo rispetto alla verifica email out-of-band tradizionale (codici OTP o link magici):
- Browser non supportati: i browser che non supportano
autocomplete="email-verification-token"ignorano l'input nascosto e inviano il modulo con un valoretokenvuoto. - Domini email non autenticati o non partecipanti: quando un utente inserisce un indirizzo email il cui dominio non pubblica un record DNS
TXT_email-verificationo quando l'utente non ha una sessione attiva con il proprio provider email nel browser, il browser invia il modulo con untokenvuoto. - Token scaduti o non validi: se il
nonceè scaduto, è già stato utilizzato o non supera un passaggio diverifyEmailVerificationTokeninhandleSignupSubmission, il server rileva l'errore e invia un'email con OTP tradizionale o link magico senza interrompere il flusso di registrazione o accesso dell'utente.