Recuperação de senha segura com código de verificação: passo a passo
Passo a passo de uma recuperação de senha segura: código de verificação no contato cadastrado, token de uso único, invalidação de sessões e código em Node.js.
O fluxo de "esqueci minha senha" é a porta dos fundos de quase todo sistema. Ele existe para contornar a senha, então qualquer descuido ali vale mais para um atacante do que uma senha fraca. Uma recuperação de senha segura precisa provar que quem pede a troca controla um canal já cadastrado — telefone ou e-mail —, sem revelar nada a quem está só testando identificadores.
A combinação que funciona melhor hoje é simples de descrever: resposta genérica para qualquer pedido, código de verificação enviado ao contato já registrado, token de redefinição de uso único com validade curta e, depois da nova senha, invalidação de todas as sessões antigas com aviso ao usuário.
O passo a passo abaixo cobre cada etapa com as decisões de segurança explicadas e termina com um exemplo em Node.js que você pode adaptar a qualquer framework.
Falhas comuns em "esqueci minha senha"
A maior parte das invasões por recuperação de senha não explora bugs sofisticados. Explora decisões de produto que pareciam inofensivas:
- Revelar se a conta existe. "E-mail não cadastrado" transforma o formulário em uma ferramenta de enumeração: o atacante descobre quais e-mails ou telefones têm conta e parte para o phishing.
- Enviar uma senha temporária. Ela viaja em texto puro, fica na caixa de entrada por tempo indeterminado e frequentemente nunca é trocada.
- Link que vale por dias e pode ser usado mais de uma vez. Um e-mail encaminhado por engano ou um histórico de navegador compartilhado vira acesso à conta.
- Token previsível. IDs sequenciais, timestamps ou hashes do e-mail são adivinháveis em minutos.
- Perguntas de segurança. Nome do primeiro animal de estimação está nas redes sociais do usuário.
- Não derrubar as sessões antigas. O usuário troca a senha depois de uma invasão, e o invasor continua logado no celular dele.
- Bloquear a conta a cada pedido. Transforma o fluxo em arma de negação de serviço contra qualquer usuário.
Contas de criadores sentem esses erros com mais força: um canal invadido vira golpe transmitido ao vivo para a audiência. O guia de segurança para streamers do Live Copiloto mostra o lado do criador; aqui, o foco é o que a plataforma deve fazer para que a recuperação não seja o elo fraco.
Fluxo de recuperação de senha segura com código por WhatsApp ou e-mail
O desenho recomendado tem seis etapas, e cada uma responde a uma das falhas acima:
- Pedido. O usuário informa e-mail ou telefone. O sistema responde sempre a mesma coisa: "Se o cadastro existir, você receberá um código."
- Envio. Se a conta existe, um código de uso único vai para o contato já cadastrado — nunca para um contato digitado agora. WhatsApp ou SMS para o telefone, e-mail para o e-mail.
- Verificação. O usuário digita o código. Cinco erros bloqueiam o código; dez minutos sem resposta expiram-no.
- Token de redefinição. Com o código válido, o servidor emite um token aleatório de uso único, também com validade curta.
- Nova senha. O usuário define a senha, que passa por checagem de força antes de ser aceita.
- Encerramento. O sistema invalida todas as sessões e tokens antigos e avisa o usuário por todos os canais cadastrados.
Código em vez de link tem duas vantagens práticas: funciona igual no celular e no desktop, sem depender de qual app abre o e-mail, e permite usar o telefone como canal. Se o e-mail do usuário foi comprometido, um código no WhatsApp ainda protege a conta; se o telefone foi clonado, o aviso por e-mail denuncia a tentativa. A lógica é a mesma do login sem senha com código pelo WhatsApp, só que aplicada à troca de credencial.
Com uma API de verificação, o envio e a conferência do código ficam fora do seu código. A aut2fa aceita telefone ou e-mail no mesmo campo to, escolhe o canal permitido na aplicação e devolve só o identificador do envio — o código nunca passa pelo seu backend.
Não revele se o usuário existe
A resposta genérica é a parte mais fácil de descrever e a mais fácil de quebrar por acidente. Para que ela funcione, quatro coisas precisam ser idênticas nos dois casos (conta existe ou não):
- A mensagem e o status HTTP. Mesmo texto, mesmo
200, mesmo formato de JSON. - O tempo de resposta. Se o envio do código acontece dentro da requisição, a conta existente responde mais devagar. Coloque o envio em uma fila e responda imediatamente.
- A tela seguinte. Mostre o campo de código para todo mundo. Quem não tem conta digita um código que nunca vai validar.
- O erro de verificação. "Código inválido ou expirado" serve para código errado, conta inexistente e código vencido. Não diferencie.
Some a isso limites por identificador e por IP: alguém que pede a recuperação do mesmo e-mail dez vezes em um minuto não é o dono da conta. Uma API de verificação com limite por destinatário ajuda aqui — na aut2fa, o limite por destinatário por hora é configurável na aplicação, e o excesso volta como HTTP 429 com Retry-After, que você pode exibir no botão de reenvio.
Token de redefinição de uso único
Por que não deixar o próprio código OTP autorizar a troca de senha? Porque o código tem seis dígitos e vida de minutos, e a tela de nova senha pode levar mais tempo que isso. O token de redefinição faz a ponte entre "provou que controla o contato" e "definiu a senha", e precisa de quatro propriedades:
- Aleatório de verdade: 32 bytes de um gerador criptográfico (
crypto.randomBytes), nunca um UUID derivado de tempo ou o hash do e-mail. - Guardado como hash: no banco fica apenas o SHA-256 do token. Se o banco vazar, os tokens não servem.
- Uso único e atômico: a consulta que consome o token marca
usado_emna mesma operação que o lê, para que duas requisições simultâneas não passem. - Validade curta: dez minutos é suficiente para digitar uma senha. Alinhe com o
expires_atdo código para a experiência fazer sentido.
Se o token precisar ir em um link (fluxo por e-mail sem código), use um formulário com POST na página de destino, para o token não vazar no cabeçalho Referer nem ficar em logs de acesso.
Um detalhe que passa despercebido: o identificador do envio (snd_...) deve ficar associado ao usuário no servidor. O cliente envia apenas identificador e código; é o backend que localiza o desafio aberto e chama a verificação. Aceitar um id de envio vindo do cliente permitiria verificar o código de um usuário e trocar a senha de outro.
Invalide as sessões antigas
Não existe recuperação de senha segura que deixe as sessões antigas vivas: é o erro que mais prolonga invasões. O invasor que já estava logado continua logado, com a nova senha ou sem ela.
A forma de invalidar depende de como a sessão é mantida:
| Tipo de sessão | Como invalidar |
|---|---|
| Sessão no servidor (cookie + store) | Apagar todas as sessões do usuário, exceto, se quiser, a atual |
| JWT com refresh token | Revogar todos os refresh tokens e incrementar uma versaoSessao no usuário |
| JWT sem refresh token | Incrementar a versão e rejeitar tokens com versão antiga a cada requisição |
| Chaves de API e integrações | Decisão de produto: avisar e oferecer rotação |
Se a sua API usa JWT em Node.js com access e refresh token, a versão de sessão vai dentro do payload; o middleware compara com a versão atual do usuário e derruba o que ficou para trás. É uma consulta a mais por requisição, mas é o que torna a troca de senha efetiva.
Feche o ciclo com um aviso: "Sua senha foi alterada em 14/10 às 15:02. Não foi você? Fale com o suporte." por e-mail e por WhatsApp. Esse aviso é o que faz a vítima de um SIM swap descobrir o golpe enquanto ainda dá tempo de agir.
Código de exemplo em Node.js
O exemplo usa Express, uma fila para o envio e a API da aut2fa para enviar e verificar o código. A chave fica em variável de ambiente e, em modo de teste, o código é sempre 000000, o que permite cobrir o fluxo inteiro com testes automatizados.
import crypto from "node:crypto";
import argon2 from "argon2";
const BASE = "https://api.aut2fa.com";
const headers = {
Authorization: `Bearer ${process.env.AUT2FA_API_KEY}`,
"Content-Type": "application/json",
};
const GENERICA = { ok: true, mensagem: "Se o cadastro existir, você receberá um código." };
const ERRO_CODIGO = { erro: "Código inválido ou expirado." };
// 1) Pedido: resposta idêntica exista ou não a conta; envio fora da requisição
app.post("/recuperar", limitador, async (req, res) => {
const usuario = await db.usuarios.porIdentificador(req.body.identificador);
if (usuario) await fila.add("enviar-codigo", { usuarioId: usuario.id });
res.json(GENERICA);
});
// Job da fila: envia o código para o contato JÁ cadastrado
export async function enviarCodigo({ usuarioId }) {
const usuario = await db.usuarios.porId(usuarioId);
const envio = await fetch(`${BASE}/v1/send`, {
method: "POST",
headers,
body: JSON.stringify({ to: usuario.telefone ?? usuario.email, locale: "pt-BR" }),
}).then((r) => r.json()); // 202 -> { id: "snd_...", expires_at: "..." }
await db.desafios.criar({ usuarioId, envioId: envio.id, expiraEm: envio.expires_at });
}
A segunda parte confere o código, emite o token de uso único e, por fim, troca a senha derrubando as sessões:
// 2) Confere o código e emite o token de redefinição (10 min, uso único)
app.post("/recuperar/codigo", limitador, async (req, res) => {
const { identificador, codigo } = req.body;
const usuario = await db.usuarios.porIdentificador(identificador);
const desafio = usuario && (await db.desafios.abertoPara(usuario.id));
if (!desafio) return res.status(400).json(ERRO_CODIGO);
const r = await fetch(`${BASE}/v1/send/${desafio.envioId}/verify`, {
method: "POST",
headers,
body: JSON.stringify({ code: String(codigo) }), // a API exige string
}).then((x) => x.json()); // { valid, verification, attempts_left }
if (!r.valid) return res.status(400).json(ERRO_CODIGO);
const token = crypto.randomBytes(32).toString("base64url");
await db.tokensReset.criar({
hash: crypto.createHash("sha256").update(token).digest("hex"),
usuarioId: usuario.id,
expiraEm: new Date(Date.now() + 10 * 60 * 1000),
});
await db.desafios.fechar(desafio.id);
res.json({ resetToken: token });
});
// 3) Nova senha: consome o token, troca a senha e invalida sessões antigas
app.post("/recuperar/senha", async (req, res) => {
const { resetToken, novaSenha } = req.body;
const hash = crypto.createHash("sha256").update(String(resetToken)).digest("hex");
const registro = await db.tokensReset.consumir(hash); // atômico: só retorna se não usado e não expirado
if (!registro) return res.status(400).json({ erro: "Token inválido." });
if (!senhaForte(novaSenha)) return res.status(422).json({ erro: "Senha fraca." });
await db.usuarios.atualizar(registro.usuarioId, {
senhaHash: await argon2.hash(novaSenha),
versaoSessao: { incrementar: 1 }, // derruba JWTs antigos
});
await db.refreshTokens.revogarTodos(registro.usuarioId);
await notificar(registro.usuarioId, "Sua senha foi alterada. Não foi você? Fale com o suporte.");
res.json({ ok: true });
});
Repare que a resposta de erro da etapa 2 não inclui as tentativas restantes: mostrar attempts_left só quando a conta existe revelaria a existência dela. Se quiser exibir o contador, faça isso de forma idêntica nos dois caminhos. Para o desenho da tela de digitação, vale o artigo sobre UX da tela de código de verificação.
Perguntas frequentes
Código ou link: qual é mais seguro para redefinir a senha?
Os dois funcionam quando o token é aleatório, de uso único e com validade curta. O código tem a vantagem de permitir o telefone como canal e de não vazar por Referer ou histórico; o link tem menos digitação. Muitos produtos combinam os dois: código pelo WhatsApp e aviso por e-mail.
Quanto tempo o código e o token devem valer?
Minutos, não horas. Dez minutos para o código (o padrão da aut2fa, informado em expires_at) e outros dez para o token de redefinição cobrem qualquer usuário real. Validades longas só servem a quem encontrou a mensagem depois.
Devo bloquear a conta depois de várias tentativas de recuperação?
Não bloqueie a conta: isso permite que qualquer pessoa tranque qualquer usuário. Limite o pedido por identificador e por IP, bloqueie o código após cinco erros e exija um novo envio. A conta continua funcionando para quem tem a senha.
E se o usuário perdeu o acesso ao telefone e ao e-mail?
Esse é o caso para um processo manual de suporte com verificação mais rigorosa: documento, dados da conta que só o dono conhece e um período de espera com aviso nos canais antigos. Nunca resolva isso com uma resposta automática, por mais insistente que seja o pedido.
Posso enviar uma senha temporária em vez de um código?
Não. A senha temporária é uma credencial completa em texto puro, sem prazo e sem garantia de troca. O código só prova a posse do canal; a senha nova é criada pelo usuário, com checagem de força.
Próximos passos
Revise o seu fluxo atual contra a lista de falhas da primeira seção: a maioria dos sistemas deixa justamente a invalidação de sessões e a resposta genérica para depois.
Para montar a parte do código sem gastar nada, crie uma aplicação em modo de teste no painel da aut2fa e siga a documentação de verificação: o código 000000 cobre os testes automatizados, e a troca para produção é só a chave.