Código de verificação por e-mail: como implementar e quando usar
Quando o e-mail é o canal certo para verificação, como gerar, guardar e validar o código com segurança, e como montar o fallback por WhatsApp e SMS. Com exemplo em Node.js.
Um código de verificação por e-mail é a forma mais simples de provar que a pessoa do outro lado controla o endereço que digitou. Ele resolve três problemas de uma vez: barra cadastros com e-mails falsos, protege a recuperação de senha e serve como segundo fator em logins sensíveis, sem depender de SMS nem de aplicativo autenticador.
A implementação parece trivial, mas raramente é. Código gerado com Math.random(), guardado em texto puro, sem limite de tentativas e sem prazo de validade: é assim que uma verificação vira porta de entrada em vez de barreira.
O guia abaixo cobre as decisões que importam: canal, geração, armazenamento, entrega, validação e fallback, com um exemplo completo em Node.js no fim.
Quando o e-mail é o canal certo (e quando não é)
O e-mail tem vantagens claras: custa pouco, funciona em qualquer dispositivo e não exige que a pessoa informe o telefone. Para confirmar o endereço no cadastro, ele é a única opção que faz sentido, porque o que você quer provar é a posse daquele e-mail, não de outro canal.
Também é boa escolha para:
- Confirmação de cadastro em produtos B2B e SaaS, onde o e-mail corporativo é a identidade principal.
- Recuperação de senha, combinada com as outras proteções descritas abaixo.
- Segundo fator de baixo risco, como confirmar um login em dispositivo novo.
- Fallback quando o telefone não recebe SMS nem WhatsApp.
Por outro lado, o e-mail perde quando o tempo de chegada importa. A entrega pode levar de segundos a alguns minutos, a mensagem pode cair no spam e o usuário precisa sair do seu app para ler. Em checkout, cadastro em app mobile ou login do dia a dia, isso derruba a conversão. Para esses fluxos, a comparação entre SMS ou WhatsApp para código de verificação ajuda a definir o canal principal.
Há ainda um limite de segurança: se o invasor já controla a caixa de entrada da vítima, um código enviado para esse mesmo e-mail não protege nada. Para operações de alto risco, como trocar dados bancários, use um canal diferente da identidade principal.
Gerando e guardando o código com segurança
A regra é curta: o código precisa ser imprevisível, ter vida curta e nunca ficar legível no banco. Cada exigência fecha uma classe de ataque.
Geração com aleatoriedade criptográfica
Use o gerador seguro da sua linguagem, nunca funções pseudoaleatórias comuns. Em Node.js, crypto.randomInt(0, 1000000); em Python, secrets.randbelow(10**6); em PHP, random_int(0, 999999). Formate com zeros à esquerda para manter sempre 6 dígitos.
Seis dígitos é o padrão por um motivo: são 1.000.000 de combinações, o que, somado a um limite de poucas tentativas, torna a adivinhação inviável. Quatro dígitos só são aceitáveis com limites muito agressivos; oito dígitos aumentam o erro de digitação sem ganho real.
Armazenamento com hash, nunca em texto puro
Guarde apenas um hash do código, junto com o identificador do usuário, a finalidade (cadastro, recuperação, login), o momento de expiração e o contador de tentativas. Se o banco vazar ou alguém com acesso de leitura consultar a tabela, não existe código utilizável.
Como o espaço de 6 dígitos é pequeno, um hash simples seria quebrado offline em segundos. A saída é um HMAC com uma chave secreta do servidor, algo como HMAC-SHA256(segredo, usuario + codigo): sem o segredo, a tabela vazada não serve para nada. bcrypt ou argon2 também funcionam, mas são mais lentos e desnecessários aqui.
Um código ativo por vez, com prazo curto
Dez minutos é um bom ponto de partida para e-mail, já que a entrega pode demorar mais que no WhatsApp. Ao gerar um código novo, invalide o anterior: só deve existir um código ativo por usuário e por finalidade. Isso impede que um atacante acumule vários códigos válidos ao mesmo tempo.
Enviando o código de verificação por e-mail: template e remetente
Um código que cai no spam é um código que não existe. Três cuidados resolvem a maior parte dos problemas de entrega:
- Domínio autenticado. Configure SPF, DKIM e DMARC para o domínio do remetente. Sem isso, Gmail e Outlook tendem a filtrar ou rejeitar a mensagem.
- Remetente reconhecível. Use um endereço do seu domínio, como
[email protected], com nome de exibição claro. - Fila transacional separada. E-mails de verificação não podem dividir reputação com campanhas de marketing; se uma campanha for marcada como spam, o código vai junto.
No template, menos é mais:
- Coloque o código no assunto (
Seu código é 482913). A pessoa lê na notificação, sem abrir a mensagem. - Destaque o código em fonte grande, com espaçamento que facilite ler e copiar.
- Informe o prazo de validade e inclua a frase de segurança: "Se você não pediu este código, ignore este e-mail".
- Não inclua links clicáveis além do indispensável. E-mails de código cheios de links treinam o usuário a clicar, e isso facilita phishing.
Envie a mensagem por fila ou job e responda à requisição HTTP na hora: se o provedor demorar, o usuário não fica olhando um spinner.
Validando: tentativas, expiração e reenvio
A validação é onde a maioria dos erros de segurança aparece. O endpoint de verificação deve seguir uma ordem fixa:
- Localizar o código ativo do usuário para aquela finalidade. Se não existir, responder com erro genérico.
- Conferir a expiração antes de qualquer comparação. Código vencido é descartado.
- Conferir o contador de tentativas. Ao atingir o limite (cinco é um bom valor), invalidar o código e exigir um novo envio.
- Comparar o hash com uma função de tempo constante, como
crypto.timingSafeEqualem Node, para evitar ataques de timing. - Em caso de acerto, marcar o código como usado na mesma transação, para que não possa ser reaproveitado.
Na interface, "código errado" e "código expirado" podem ter mensagens diferentes. Já na recuperação de senha, a resposta ao pedido de envio deve ser a mesma exista ou não o e-mail na base, para evitar enumeração de usuários.
Reenvio com cooldown
Permita reenviar, mas com intervalo mínimo entre envios (60 segundos funciona bem) e um teto por hora por endereço. Sem esse freio, qualquer pessoa usa o seu formulário para inundar a caixa de alguém ou consumir a cota do seu provedor. Mostre o contador regressivo no botão, como detalhado em UX da tela de código de verificação.
Depois da validação: a sessão
Com o código confirmado, o usuário precisa de uma sessão. Se a sua API usa tokens, o padrão descrito em autenticação JWT em Node.js com access e refresh token encaixa bem: o access token curto é emitido só depois da validação, e o refresh token fica em cookie httpOnly. Nunca emita a sessão antes de o código conferir, nem com base apenas em "o e-mail foi enviado".
Fallback para WhatsApp e SMS
Mesmo com tudo configurado, parte dos e-mails vai atrasar ou cair no spam. Um fluxo maduro oferece outro caminho: um link "Não recebeu? Enviar por WhatsApp" que aparece após 30 a 60 segundos.
O fallback só faz sentido quando você já tem o telefone verificado, ou quando o cadastro pede telefone e e-mail e verifica os dois. Nunca envie o código para um telefone digitado naquele momento como forma de confirmar o e-mail: isso quebra a prova de posse.
Para SMS, o caminho mais curto é um provedor com API HTTP; o tutorial de como enviar SMS com PHP mostra o padrão básico de requisição e resposta, que se repete em qualquer linguagem. Para WhatsApp, a entrega costuma ser mais rápida, e a confirmação de leitura ajuda a saber se o código chegou.
Se não quiser manter três integrações, uma API de OTP como a aut2fa aceita no campo to tanto um telefone quanto um endereço de e-mail: a mesma chamada POST /v1/send serve para e-mail, WhatsApp e SMS, e a confirmação é sempre POST /v1/send/{id}/verify. Nesse modelo, geração, hash, expiração e limite de tentativas ficam por conta da plataforma, e o seu código só precisa guardar o id do envio e checar valid: true.
Código de exemplo em Node.js
O exemplo abaixo implementa tudo o que foi discutido com o módulo crypto nativo e o Nodemailer. Para simplificar, o estado fica em memória; em produção, troque o Map por uma tabela no banco ou por chaves no Redis com TTL.
// otp-email.js — geração, envio e validação de código por e-mail
import crypto from "node:crypto";
import nodemailer from "nodemailer";
const SEGREDO = process.env.OTP_SECRET; // chave do HMAC, nunca no código-fonte
const VALIDADE_MIN = 10;
const MAX_TENTATIVAS = 5;
const codigos = new Map(); // email -> { hash, expiraEm, tentativas }
const transporte = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
function hashDoCodigo(email, codigo) {
return crypto.createHmac("sha256", SEGREDO).update(`${email}:${codigo}`).digest("hex");
}
export async function enviarCodigo(email) {
const codigo = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
// sobrescreve o código anterior: só existe um ativo por e-mail
codigos.set(email, {
hash: hashDoCodigo(email, codigo),
expiraEm: Date.now() + VALIDADE_MIN * 60_000,
tentativas: 0,
});
await transporte.sendMail({
from: '"Minha Loja" <[email protected]>',
to: email,
subject: `Seu código é ${codigo}`,
text: `Use o código ${codigo} para confirmar seu e-mail. Ele vale por ${VALIDADE_MIN} minutos.\nSe você não pediu este código, ignore esta mensagem.`,
});
}
export function validarCodigo(email, codigoDigitado) {
const registro = codigos.get(email);
if (!registro) return { ok: false, motivo: "nao_encontrado" };
if (Date.now() > registro.expiraEm) {
codigos.delete(email);
return { ok: false, motivo: "expirado" };
}
if (registro.tentativas >= MAX_TENTATIVAS) {
codigos.delete(email);
return { ok: false, motivo: "bloqueado" };
}
registro.tentativas += 1;
const esperado = Buffer.from(registro.hash);
const recebido = Buffer.from(hashDoCodigo(email, String(codigoDigitado).trim()));
const confere = esperado.length === recebido.length && crypto.timingSafeEqual(esperado, recebido);
if (!confere) {
return { ok: false, motivo: "incorreto", restantes: MAX_TENTATIVAS - registro.tentativas };
}
codigos.delete(email); // um código vale uma única vez
return { ok: true };
}
As rotas HTTP ficam enxutas, com a lógica no módulo acima:
// rotas.js — uso com Express
app.post("/verificacao/enviar", async (req, res) => {
await enviarCodigo(req.body.email); // em produção: enfileire e aplique cooldown por e-mail
res.status(202).json({ ok: true }); // mesma resposta exista ou não o cadastro
});
app.post("/verificacao/confirmar", (req, res) => {
const resultado = validarCodigo(req.body.email, req.body.codigo);
if (!resultado.ok) return res.status(400).json(resultado);
// aqui: marcar o e-mail como verificado e emitir a sessão
res.json({ ok: true });
});
Repare em três detalhes: o HMAC inclui o e-mail, então o mesmo código para duas pessoas gera hashes diferentes; a comparação em tempo constante exige buffers do mesmo tamanho, daí a checagem de length; e a tentativa é contada antes da comparação, para que uma falha no meio do caminho não a devolva ao atacante.
Perguntas frequentes
Quanto tempo o código de verificação por e-mail deve valer?
Entre 5 e 15 minutos. Menos que isso gera reenvios por causa de atrasos na entrega; mais que isso amplia a janela para alguém com acesso temporário à caixa de entrada. Dez minutos é um meio-termo seguro para a maioria dos produtos.
Posso mandar um link em vez de um código?
Pode, e para login em desktop o link costuma converter melhor. O código tem a vantagem de funcionar quando a pessoa lê o e-mail no celular e está no desktop, e de não ser consumido por scanners de segurança de e-mail corporativo que abrem links automaticamente.
O que fazer quando o e-mail cai no spam?
Primeiro, verifique SPF, DKIM e DMARC e separe a fila transacional da de marketing. Depois, na interface, mostre a dica "confira a pasta de spam" e ofereça reenvio e fallback por WhatsApp ou SMS após alguns segundos.
Preciso usar hash mesmo com expiração de 10 minutos?
Sim. A expiração protege contra uso tardio; o hash protege contra leitura por quem tem acesso ao banco, a logs ou a backups durante esses 10 minutos. Os dois controles são independentes e baratos.
Posso usar o mesmo código para e-mail e telefone no mesmo cadastro?
Não. Cada canal prova a posse de uma identidade diferente, então cada um precisa do próprio código, com o próprio hash e o próprio contador de tentativas. Reaproveitar o código faz um canal comprometido validar o outro.
Próximos passos
Com as regras acima, o fluxo do código de verificação por e-mail fica previsível: 6 dígitos gerados com aleatoriedade segura, HMAC no banco, 10 minutos de validade, 5 tentativas, reenvio com cooldown e um fallback para quando o e-mail não chega.
Se preferir não operar provedor de e-mail, templates e contadores por conta própria, crie uma aplicação de teste na aut2fa e envie o primeiro código com a chave ak_test_, sem consumir saldo: e-mail, WhatsApp e SMS passam a ser a mesma chamada de API.