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):
- A parte confiável inclui um
<input>oculto comautocomplete="email-verification-token"e umnoncecriptográfico de uso único no mesmo<form>que a entrada de e-mail. - Quando o usuário seleciona ou insere um endereço de e-mail, o navegador procura o registro DNS
_email-verification.<domain>do domínioTXT, 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. - O navegador vincula o EVT à origem e à sessão
nonceda 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. - 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):
- Forneça o token do teste de origem na página que hospeda o formulário usando o cabeçalho de resposta HTTP
Origin-Trialou 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).
- 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
- 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')denode:crypto), armazene-o no estado da sessão do lado do servidor com chave de um cookie de ID de sessãoHttpOnly,Secure,SameSite=Lax(nunca armazene o valornonceesperado diretamente em um cookie do cliente em que um invasor possa repetir o cookie e o token) e renderize-o no atributononceda entrada do token oculto. Exiba a página comCache-Control: no-storepara que a navegação de avanço e retorno não reutilize um valor de uso único já consumido. - 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.emailcorresponde 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) omitemkiddo cabeçalho EVT. Quandokidestiver ausente e o JWKS tiver várias chaves,josevai gerarERR_JWKS_MULTIPLE_MATCHING_KEYS(umAsyncIterabledeCryptoKeys correspondentes). Iterefor 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']):
- Assinatura do EVT (
verifier): verifique em relação aissuerJwks(capturandoERR_JWKS_MULTIPLE_MATCHING_KEYSpara iterar chaves candidatas quandokidfor omitido), aplicandoisse uma janela de atualização restrita (maxTokenAge: '5m',clockTolerance: '1m'). - Vinculação do titular (opções
kbVerifieresdJwt.verify): extraia a chave pública temporária do navegador deevtPayload.cnf.jwk, confirme secnf.jwk.algedecoded.kbJwt.header.algestão emALLOWED_ALGSe 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 assinaturaKB-JWTcomtyp: 'kb+jwt'. TransmitakeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceekeyBindingMaxAgeSeconds: 300parasdJwt.verify(). O@sd-jwt/coresó invocakbVerifiere validanonce,aud,iatesd_hashquandokeyBindingNonceé 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/coreejoseem Node.js) para analisar e validar a apresentaçãoEVT~KB-JWTe transmitirkeyBindingNonceparasdJwt.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 ousd_hash. - FAÇA a geração de um novo
noncecriptográfico (pelo menos 128 bits, comorandomBytes(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 comCache-Control: no-store. - FAÇA a leitura e a exclusão atômicas do
nonceesperado do armazenamento de sessão do lado do servidor imediatamente quando a solicitaçãoPOSTchegar 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.emaile 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
kidestiver ausente do cabeçalho do EVT (provedores como o Gmail omitemkid). - VERIFIQUE a delegação de DNS (
_email-verification.<domain>) antes de confiar em qualquer declaraçãoissem um EVT e confirmemetadata.issuer === issna 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
tokenestiver 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 valortokenvazio. - 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
TXTde DNS_email-verificationou 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 umtokenvazio. - Tokens expirados ou inválidos: se o
noncetiver expirado, já tiver sido consumido ou falhar em qualquer etapa deverifyEmailVerificationTokenemhandleSignupSubmission, 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.