Verifica la proprietà dell'indirizzo email

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

  1. La relying party include un <input> nascosto con autocomplete="email-verification-token" e un nonce crittografico monouso nello stesso <form> dell'input dell'email.
  2. Quando l'utente seleziona o inserisce un indirizzo email, il browser cerca il record DNS _email-verification.<domain> TXT del 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.
  3. Il browser associa l'EVT all'origine e alla sessione nonce della 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.
  4. 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):

  1. Fornisci il token dell'origin trial nella pagina che ospita il modulo utilizzando l'intestazione della risposta HTTP Origin-Trial o 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).
  2. 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') da node:crypto), memorizzalo nello stato della sessione lato server con chiave da un cookie ID sessione HttpOnly, Secure, SameSite=Lax (non memorizzare mai il valore nonce previsto 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'attributo nonce dell'input del token nascosto. Pubblica la pagina con Cache-Control: no-store in modo che la navigazione avanti/indietro non riutilizzi un nonce già utilizzato.
  3. 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.email corrisponde 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) omettono kid dall'intestazione EVT. Quando kid è assente e il JWKS contiene più chiavi, jose genera ERR_JWKS_MULTIPLE_MATCHING_KEYS (un AsyncIterable di CryptoKey corrispondenti): esegui l'iterazione di for 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']):

  1. Firma EVT (verifier): verifica rispetto a issuerJwks (rilevando ERR_JWKS_MULTIPLE_MATCHING_KEYS per eseguire l'iterazione delle chiavi candidate quando kid viene omesso), applicando iss e una finestra di aggiornamento ristretta (maxTokenAge: '5m', clockTolerance: '1m').
  2. Associazione del titolare (opzioni kbVerifier e sdJwt.verify): estrai la chiave pubblica temporanea del browser da evtPayload.cnf.jwk, verifica che cnf.jwk.alg e decoded.kbJwt.header.alg siano presenti in ALLOWED_ALGS e corrispondano (considerando 'Ed25519' e 'EdDSA' come equivalenti durante la transizione delle implementazioni di origin trial a RFC 9864 'Ed25519') e verifica la firma KB-JWT con typ: 'kb+jwt'. Passa keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience e keyBindingMaxAgeSeconds: 300 a sdJwt.verify(): @sd-jwt/core richiama kbVerifier e convalida nonce, aud, iat e sd_hash solo quando viene fornito keyBindingNonce.
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/core and jose in Node.js) to parse and validate the EVT~KB-JWT presentation, and pass keyBindingNonce to sdJwt.verify() so Key Binding verification cannot be bypassed. NON creare manualmente routine personalizzate di analisi e verifica della firma di JWT, SD-JWT o sd_hash.
  • SÌ: genera un nonce crittografico nuovo (almeno 128 bit, ad esempio randomBytes(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 con Cache-Control: no-store.
  • SÌ: leggi ed elimina in modo atomico il valore nonce previsto dallo spazio di archiviazione della sessione lato server immediatamente quando arriva la richiesta POST, in modo che un token intercettato non possa essere riprodotto.
  • ESEGUI un confronto senza distinzione tra maiuscole e minuscole tra evtPayload.email e 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 kid non è presente nell'intestazione EVT (provider come Gmail omettono kid).
  • VERIFICA la delega DNS (_email-verification.<domain>) prima di considerare attendibile qualsiasi rivendicazione iss in un EVT e conferma metadata.issuer === iss nella 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 valore token vuoto.
  • Domini email non autenticati o non partecipanti: quando un utente inserisce un indirizzo email il cui dominio non pubblica un record DNS TXT _email-verification o quando l'utente non ha una sessione attiva con il proprio provider email nel browser, il browser invia il modulo con un token vuoto.
  • Token scaduti o non validi: se il nonce è scaduto, è già stato utilizzato o non supera un passaggio di verifyEmailVerificationToken in handleSignupSubmission, 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.