Verifica la propiedad de la dirección de correo electrónico

Cuando se recopila una dirección de correo electrónico durante el registro, el acceso, la confirmación de la compra o la recuperación de la cuenta, la verificación de la propiedad tradicionalmente requiere el envío de una contraseña de un solo uso (OTP) fuera de banda o un vínculo mágico. Esto obliga a los usuarios a salir de tu página, esperar a que se envíe el correo electrónico y copiar los códigos, lo que genera fricción, abandono y riesgo de suplantación de identidad (phishing), además de revelar al proveedor de correo electrónico qué parte de confianza (RP) está visitando el usuario.

La API de verificación por correo electrónico (extensión HTML del cliente) y el protocolo de verificación por correo electrónico (EVP) (protocolo criptográfico de backend) permiten que una parte de confianza verifique que un usuario controla una dirección de correo electrónico sin enviar un mensaje de verificación. El navegador actúa como intermediario entre la parte de confianza y el proveedor de correo electrónico del usuario (la entidad emisora):

  1. La parte de confianza incluye un <input> oculto con autocomplete="email-verification-token" y un nonce criptográfico de un solo uso en el mismo <form> que la entrada de correo electrónico.
  2. Cuando el usuario selecciona o ingresa una dirección de correo electrónico, el navegador busca el registro DNS TXT _email-verification.<domain> del dominio, verifica que el usuario tenga una sesión activa con la entidad emisora autorizada y solicita un token de verificación por correo electrónico (EVT) sin divulgar la identidad de la parte de confianza a la entidad emisora.
  3. El navegador vincula el EVT al origen y la sesión nonce de la parte de confianza en un JWT de vinculación de claves (KB-JWT) firmado con un par de claves efímeras del navegador (EVT + KB-JWT), completa la entrada oculta y envía el formulario.
  4. El backend de la parte de confianza verifica la cadena criptográfica y, si es válida, omite el paso de la OTP por correo electrónico o el vínculo mágico.

1. Habilita la prueba de origen y renderiza el formulario de verificación

Para participar como sitio de verificación (tercero que confía en la información), sigue estos pasos:

  1. Proporciona el token de la prueba de origen en la página que aloja el formulario con el encabezado de respuesta HTTP Origin-Trial o un elemento <meta http-equiv="origin-trial">.
    • Pruebas de origen de terceros: Los SDKs de identidad incorporados pueden insertar un token de prueba de terceros, siempre que el origen del registrante de la prueba de origen coincida con el dominio de la entidad emisora (en el mismo sitio que la entidad emisora, como https://issuer.example).
  2. Genera un nonce criptográficamente aleatorio por renderización de formulario con al menos 128 bits de entropía (por ejemplo, randomBytes(32).toString('base64url') de node:crypto), almacénalo en el estado de sesión del servidor con una clave de cookie de ID de sesión HttpOnly, Secure, SameSite=Lax (nunca almacenes el valor nonce esperado directamente dentro de una cookie de cliente donde un atacante podría reproducir tanto la cookie como el token) y renderízalo en el atributo nonce de la entrada de token oculta. Publica la página con Cache-Control: no-store para que la navegación hacia atrás o adelante no reutilice un nonce ya consumido.
  3. Agrega la entrada de token oculta (autocomplete="email-verification-token") dentro del mismo <form> que la entrada de correo electrónico (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 el token enviado en el servidor

Cuando se envíe el formulario, trata el campo token como una entrada no confiable. Lee y borra de forma atómica el nonce esperado de tu almacén de sesiones del servidor cuando lo recibas. Si el campo token está vacío (por ejemplo, en navegadores que no admiten EVP o cuando el usuario no accedió a su proveedor de correo electrónico) o si falla la verificación, recurre de inmediato a tu flujo estándar de OTP o vínculo mágico por correo electrónico:

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

Usa siempre bibliotecas de SD-JWT y JOSE/JWT establecidas para tu plataforma de backend (como @sd-jwt/core y jose en Node.js, sd-jwt-python/jwcrypto en Python o sd-jwt-java/Nimbus JOSE+JWT en la JVM) para implementar verifyEmailVerificationToken en lugar de escribir código personalizado de análisis de JWT o SD-JWT.

Paso 1: Analiza las reclamaciones del SD-JWT y valida la dirección de correo electrónico

Después de consumir el expectedNonce de un solo uso del almacenamiento de sesiones del servidor, decodifica la presentación de SD-JWT (EVT + KB-JWT) y verifica lo siguiente:

  • Están presentes tanto el JWT de la entidad emisora (jwt) como el JWT de vinculación de claves (kbJwt), junto con la reclamación de la entidad emisora (iss).
  • evtPayload.email_verified === true.
  • evtPayload.email coincide con el correo electrónico del formulario enviado mediante una comparación que no distingue mayúsculas de minúsculas (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.');
}

Paso 2: Valida la delegación de TXT de DNS y carga el JWKS de la entidad emisora

Antes de realizar cualquier solicitud de red a iss, verifica que el dominio del correo electrónico enviado delegue la autoridad a ese emisor exacto a través de un registro TXT de DNS _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> sin puerto, ruta de acceso ni barra diagonal final). Una vez que la delegación de DNS coincida con iss byte por byte, recupera ${iss}/.well-known/email-verification a través de HTTPS, confirma que metadata.issuer === iss y carga el conjunto de claves web JSON (JWKS) de la entidad emisora.

Nota sobre el manejo de kid: Algunos proveedores de correo electrónico importantes (como Gmail) omiten kid del encabezado EVT. Cuando kid no está presente y el JWKS contiene varias claves, jose arroja ERR_JWKS_MULTIPLE_MATCHING_KEYS (un AsyncIterable de CryptoKey coincidentes). Itera for await (const publicKey of err) para verificar cada clave, como se muestra en el paso 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));

Paso 3: Verifica la firma del EVT y el JWT de vinculación de claves (KB-JWT)

Por último, verifica ambas firmas y la vinculación criptográfica entre los dos tokens con la lista de entidades permitidas de algoritmos asimétricos explícitos (['Ed25519', 'EdDSA', 'ES256']):

  1. Firma de EVT (verifier): Se verifica con issuerJwks (se detecta ERR_JWKS_MULTIPLE_MATCHING_KEYS para iterar claves candidatas cuando se omite kid), lo que aplica iss y un período de actualización estricto (maxTokenAge: '5m', clockTolerance: '1m').
  2. Vinculación del titular (opciones kbVerifier y sdJwt.verify): Extrae la clave pública efímera del navegador de evtPayload.cnf.jwk, confirma que cnf.jwk.alg y decoded.kbJwt.header.alg estén en ALLOWED_ALGS y coincidan (tratando 'Ed25519' y 'EdDSA' como equivalentes mientras las implementaciones de la prueba de origen realizan la transición a RFC 9864 'Ed25519') y verifica la firma de KB-JWT con typ: 'kb+jwt'. Pasa keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience y keyBindingMaxAgeSeconds: 300 a sdJwt.verify(): @sd-jwt/core solo invoca kbVerifier y valida nonce, aud, iat y sd_hash cuando se proporciona 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,
});

Prácticas recomendadas

  • SÍ realiza toda la verificación de tokens estrictamente en el servidor. NO valides tokens en JavaScript del cliente ni confíes en ningún estado de verificación del cliente, ya que las verificaciones del cliente se pueden omitir o falsificar con facilidad y no proporcionan ninguna garantía de confianza a la parte que confía.
  • SÍ usa bibliotecas SD-JWT y JOSE/JWT establecidas para tu plataforma de backend (por ejemplo, @sd-jwt/core y jose en Node.js) para analizar y validar la presentación de EVT~KB-JWT, y pasa keyBindingNonce a sdJwt.verify() para que no se pueda omitir la verificación de vinculación de claves. NO crees rutinas personalizadas de análisis y verificación de firmas de JWT, SD-JWT o sd_hash.
  • SÍ genera un nonce criptográfico nuevo (de al menos 128 bits, como randomBytes(32).toString('base64url')) en el servidor para cada renderización de formulario, almacénalo en el estado de la sesión del servidor (no directamente en una cookie del cliente) y entrega la página del formulario con Cache-Control: no-store.
  • SÍ lee y borra de forma atómica el nonce esperado del almacenamiento de sesiones del servidor inmediatamente cuando llega la solicitud POST para que no se pueda reproducir un token interceptado.
  • SÍ debes realizar una comparación que no distinga mayúsculas de minúsculas entre evtPayload.email y la dirección de correo electrónico del formulario enviado (email.toLowerCase()), conservando la cadena de correo electrónico enviada.
  • SÍ verifiques de forma preliminar todas las claves en el JWKS de la entidad emisora cuando kid no esté presente en el encabezado del EVT (los proveedores como Gmail omiten kid).
  • SÍ verifiques la delegación de DNS (_email-verification.<domain>) antes de confiar en cualquier reclamación de iss en un EVT y confirma metadata.issuer === iss en la respuesta de .well-known/email-verification.
  • NO trates la verificación de la propiedad del correo electrónico como prueba de la capacidad de entrega de la bandeja de entrada. La EVP demuestra que el usuario controla la cuenta de correo electrónico con el proveedor autorizado. De todos modos, envía correos electrónicos transaccionales de bienvenida y administra los rebotes con normalidad.
  • NO bloquees la creación de cuentas ni el acceso cuando token esté vacío o cuando falle la verificación. Siempre degrada de forma correcta al flujo estándar de OTP por correo electrónico o vínculo mágico.

Estrategia de resguardo

La verificación por correo electrónico (autocomplete="email-verification-token") se diseñó como una mejora progresiva de la verificación por correo electrónico fuera de banda tradicional (códigos OTP o vínculos mágicos):

  • Navegadores no compatibles: Los navegadores que no admiten autocomplete="email-verification-token" ignoran la entrada oculta y envían el formulario con un valor token vacío.
  • Dominios de correo electrónico no autenticados o que no participan: Cuando un usuario ingresa una dirección de correo electrónico cuyo dominio no publica un registro TXT de DNS de _email-verification, o cuando el usuario no tiene una sesión activa con su proveedor de correo electrónico en el navegador, este envía el formulario con un token vacío.
  • Tokens vencidos o no válidos: Si el nonce venció, ya se consumió o falla en algún paso de verifyEmailVerificationToken en handleSignupSubmission, el servidor detecta el error y envía una OTP tradicional o un correo electrónico con un vínculo mágico sin interrumpir el flujo de registro o acceso del usuario.