Verificar a propriedade do endereço de e-mail

Ao coletar um endereço de e-mail durante a inscrição, o login, o pagamento ou a recuperação de conta, a verificação de propriedade geralmente exige o envio de uma senha de uso único (OTP) fora de banda ou de um link mágico. Isso força os usuários a sair da página, esperar o e-mail chegar e copiar códigos, o que gera atrito, desistência e risco de phishing, além de revelar ao provedor de e-mail qual parte confiável (RP) o usuário está acessando.

A API Email Verification (extensão HTML do lado do cliente) e o Protocolo de Verificação de E-mail (EVP) (protocolo criptográfico de back-end) permitem que uma parte confiável verifique se um usuário controla um endereço de e-mail sem enviar um e-mail de verificação. O navegador faz a intermediação entre a parte confiável e o provedor de e-mail do usuário (o emissor):

  1. A parte confiável inclui um <input> oculto com autocomplete="email-verification-token" e um nonce criptográfico de uso único no mesmo <form> que a entrada de e-mail.
  2. Quando o usuário seleciona ou insere um endereço de e-mail, o navegador procura o registro DNS _email-verification.<domain> do domínio TXT, verifica se o usuário tem uma sessão ativa com o emissor autorizado e solicita um token de verificação de e-mail (EVT, na sigla em inglês) sem divulgar a identidade da parte confiável ao emissor.
  3. O navegador vincula o EVT à origem e à sessão nonce da parte confiável em um JWT de vinculação de chave (KB-JWT) assinado com um par de chaves de navegador temporário (EVT + KB-JWT), preenche a entrada oculta e envia o formulário.
  4. O back-end da parte confiável verifica a cadeia criptográfica e, se for válida, pula a etapa de OTP por e-mail ou link mágico.

1. Ativar o teste de origem e renderizar o formulário de verificação

Para participar como um site de verificação (parte confiável):

  1. Forneça o token do teste de origem na página que hospeda o formulário usando o cabeçalho de resposta HTTP Origin-Trial ou um elemento <meta http-equiv="origin-trial">.
    • Testes de origem de terceiros: os SDKs de identidade incorporados podem injetar um token de teste de terceiros, desde que a origem do responsável pelo registro do teste de origem corresponda ao domínio do emissor (mesmo site do emissor, como https://issuer.example).
  2. Gere um valor de uso único criptograficamente aleatório por renderização de formulário com pelo menos 128 bits de entropia (por exemplo, randomBytes(32).toString('base64url') de node:crypto), armazene-o no estado da sessão do lado do servidor com chave de um cookie de ID de sessão HttpOnly, Secure, SameSite=Lax (nunca armazene o valor nonce esperado diretamente em um cookie do cliente em que um invasor possa repetir o cookie e o token) e renderize-o no atributo nonce da entrada do token oculto. Exiba a página com Cache-Control: no-store para que a navegação de avanço e retorno não reutilize um valor de uso único já consumido.
  3. Adicione a entrada de token oculto (autocomplete="email-verification-token") no mesmo <form> que a entrada de 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. Verificar o token enviado no servidor

Quando o formulário for enviado, trate o campo token como uma entrada não confiável. Leia e exclua de forma atômica o nonce esperado do armazenamento de sessões do lado do servidor após o recebimento. Se o campo token estiver vazio (por exemplo, em navegadores que não são compatíveis com a EVP ou quando o usuário não está conectado ao provedor de e-mail) ou se a verificação falhar, use imediatamente o fluxo padrão de OTP ou link mágico por e-mail:

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

Sempre use bibliotecas SD-JWT e JOSE/JWT estabelecidas para sua plataforma de back-end (como @sd-jwt/core e jose em Node.js, sd-jwt-python / jwcrypto em Python ou sd-jwt-java / Nimbus JOSE+JWT na JVM) para implementar verifyEmailVerificationToken em vez de criar um código de análise JWT ou SD-JWT personalizado.

Etapa 1: analisar as declarações do SD-JWT e validar o endereço de e-mail

Depois de consumir o expectedNonce de uso único do armazenamento de sessão do lado do servidor, decodifique a apresentação do SD-JWT (EVT + KB-JWT) e verifique se:

  • O JWT do emissor (jwt) e o JWT de vinculação de chave (kbJwt) estão presentes, junto com a declaração do emissor (iss).
  • evtPayload.email_verified === true.
  • evtPayload.email corresponde ao e-mail do formulário enviado usando uma comparação que não diferencia maiú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.');
}

Etapa 2: validar a delegação de TXT do DNS e carregar o JWKS do emissor

Antes de fazer qualquer solicitação de rede para iss, verifique se o domínio do e-mail enviado delega autoridade a esse emissor exato por meio de um registro TXT de DNS _email-verification.<domain> (iss=<issuer-host> -> https://<issuer-host> sem porta, caminho ou barra final). Quando a delegação de DNS corresponder a iss byte por byte, busque ${iss}/.well-known/email-verification por HTTPS, confirme se metadata.issuer === iss e carregue o conjunto de chaves da Web JSON (JWKS, na sigla em inglês) do emissor.

Observação sobre o processamento de kid: alguns dos principais provedores de e-mail (como o Gmail) omitem kid do cabeçalho EVT. Quando kid estiver ausente e o JWKS tiver várias chaves, jose vai gerar ERR_JWKS_MULTIPLE_MATCHING_KEYS (um AsyncIterable de CryptoKeys correspondentes). Itere for await (const publicKey of err) para verificar cada chave, conforme mostrado na Etapa 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));

Etapa 3: verificar a assinatura do EVT e o JWT de vinculação de chave (KB-JWT)

Por fim, verifique as duas assinaturas e a vinculação criptográfica entre os dois tokens usando a lista de permissões explícita do algoritmo assimétrico (['Ed25519', 'EdDSA', 'ES256']):

  1. Assinatura do EVT (verifier): verifique em relação a issuerJwks (capturando ERR_JWKS_MULTIPLE_MATCHING_KEYS para iterar chaves candidatas quando kid for omitido), aplicando iss e uma janela de atualização restrita (maxTokenAge: '5m', clockTolerance: '1m').
  2. Vinculação do titular (opções kbVerifier e sdJwt.verify): extraia a chave pública temporária do navegador de evtPayload.cnf.jwk, confirme se cnf.jwk.alg e decoded.kbJwt.header.alg estão em ALLOWED_ALGS e correspondem (tratando 'Ed25519' e 'EdDSA' como equivalentes enquanto as implementações do teste de origem fazem a transição para o RFC 9864 'Ed25519') e verifique a assinatura KB-JWT com typ: 'kb+jwt'. Transmita keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience e keyBindingMaxAgeSeconds: 300 para sdJwt.verify(). O @sd-jwt/core só invoca kbVerifier e valida nonce, aud, iat e sd_hash quando keyBindingNonce é fornecido.
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áticas recomendadas

  • FAÇA toda a verificação de tokens estritamente no servidor. NÃO valide tokens no JavaScript do lado do cliente nem confie em nenhum estado de verificação do lado do cliente. As verificações do lado do cliente podem ser facilmente ignoradas ou falsificadas e não oferecem garantia de confiança à parte confiável.
  • USE bibliotecas SD-JWT e JOSE/JWT estabelecidas para sua plataforma de back-end (por exemplo, @sd-jwt/core e jose em Node.js) para analisar e validar a apresentação EVT~KB-JWT e transmitir keyBindingNonce para sdJwt.verify() para que a verificação de vinculação de chave não possa ser ignorada. NÃO crie rotinas personalizadas de análise e verificação de assinatura de JWT, SD-JWT ou sd_hash.
  • FAÇA a geração de um novo nonce criptográfico (pelo menos 128 bits, como randomBytes(32).toString('base64url')) no servidor para cada renderização de formulário, armazene-o no estado da sessão do lado do servidor (não diretamente em um cookie do cliente) e exiba a página do formulário com Cache-Control: no-store.
  • FAÇA a leitura e a exclusão atômicas do nonce esperado do armazenamento de sessão do lado do servidor imediatamente quando a solicitação POST chegar para que um token interceptado não possa ser reproduzido.
  • FAÇA uma comparação que não diferencia maiúsculas de minúsculas entre evtPayload.email e o endereço de e-mail do formulário enviado (email.toLowerCase()), preservando a string de e-mail enviada.
  • FAÇA uma verificação de teste de todas as chaves no JWKS do emissor quando kid estiver ausente do cabeçalho do EVT (provedores como o Gmail omitem kid).
  • VERIFIQUE a delegação de DNS (_email-verification.<domain>) antes de confiar em qualquer declaração iss em um EVT e confirme metadata.issuer === iss na resposta .well-known/email-verification.
  • NÃO trate a verificação de propriedade do e-mail como prova de entrega na caixa de entrada. A EVP comprova que o usuário controla a conta de e-mail com o provedor oficial. Ainda assim, envie e-mails de boas-vindas transacionais e lide com devoluções normalmente.
  • NÃO bloqueie a criação de contas ou o login quando token estiver vazio ou quando a verificação falhar. Sempre faça uma transição suave para o fluxo padrão de OTP por e-mail ou link mágico.

Estratégia de fallback

A verificação de e-mail (autocomplete="email-verification-token") foi projetada como um aprimoramento progressivo em relação à verificação de e-mail fora de banda tradicional (códigos OTP ou links mágicos):

  • Navegadores incompatíveis: os navegadores que não são compatíveis com autocomplete="email-verification-token" ignoram a entrada oculta e enviam o formulário com um valor token vazio.
  • Domínios de e-mail não autenticados ou não participantes: quando um usuário insere um endereço de e-mail cujo domínio não publica um registro TXT de DNS _email-verification ou quando o usuário não tem uma sessão ativa com o provedor de e-mail no navegador, o navegador envia o formulário com um token vazio.
  • Tokens expirados ou inválidos: se o nonce tiver expirado, já tiver sido consumido ou falhar em qualquer etapa de verifyEmailVerificationToken em handleSignupSubmission, o servidor vai capturar o erro e enviar um e-mail tradicional de OTP ou link mágico sem interromper o fluxo de inscrição ou login do usuário.