Login que diz "e-mail não cadastrado" entrega sua lista de clientes
Feche a enumeração de contas no login, no cadastro e na recuperação de senha: mesma mensagem, mesmo tempo de resposta e o aviso indo por e-mail.
Uma clínica recebe um e-mail de uma paciente: alguém sabia que ela fazia tratamento lá. Não houve invasão de conta. O atacante pegou uma lista vazada de e-mails, passou cada um no formulário de login da clínica e anotou quais respondiam "senha incorreta" em vez de "e-mail não cadastrado". A resposta curta: senha errada e conta inexistente precisam produzir a mesma mensagem, o mesmo status HTTP e um tempo de resposta parecido. E isso vale também para o cadastro e para o "esqueci a senha", que costumam vazar a mesma informação por outra porta.
Saber que alguém tem conta num serviço já é informação. Em clínica, escola, app de namoro ou sistema de dívida, a existência da conta é o dado sensível. E mesmo num e-commerce comum, a lista confirmada de clientes é o primeiro passo para phishing direcionado e para testar senhas vazadas só nas contas que existem.
Por onde a existência da conta vaza
A enumeração de contas acontece quando o sistema responde de forma diferente para quem existe e para quem não existe. A diferença pode estar em quatro lugares:
- Na mensagem. "Usuário não encontrado" contra "senha incorreta". É o caso óbvio.
- No status ou no corpo da resposta. A tela mostra a mesma frase, mas a API devolve 404 para um caso e 401 para outro, ou um campo
"code": "USER_NOT_FOUND"que o front ignora e o atacante lê. - No tempo. Conta que existe passa pela verificação do hash da senha, que leva dezenas ou centenas de milissegundos por ser lenta de propósito. Conta que não existe retorna na hora. Com algumas centenas de medições, a diferença aparece.
- Nos fluxos vizinhos. O cadastro diz "este e-mail já está em uso". A recuperação diz "enviamos o link" só quando a conta existe.
Como deixar o login sem pista
- Uma frase para toda falha de credencial. "E-mail ou senha incorretos." Mesmo status (401) e mesmo corpo JSON nos dois casos.
- Verifique um hash mesmo quando a conta não existe. Compare a senha recebida com um hash fictício gerado na inicialização, com o mesmo custo dos hashes reais. Assim os dois caminhos gastam tempo parecido.
- Conta bloqueada ou não confirmada não ganha mensagem própria antes da senha. Se a pessoa acertou a senha, aí sim você pode dizer que a conta precisa de confirmação, porque ela já provou que é dona.
- Limite tentativas no servidor, por conta e por IP, e explique a pausa sem dizer se a conta existe. O post limite de tentativa que explica a espera detalha a mensagem.
- Registre o motivo real no log, do lado de cá. O suporte precisa saber que era conta inexistente; o navegador, não.
Exemplo em Node.js com MySQL e bcrypt
const bcrypt = require('bcrypt');
const pool = require('./db'); // mysql2/promise
const CUSTO = 12;
const HASH_FICTICIO = bcrypt.hashSync('senha-que-nao-pertence-a-ninguem', CUSTO);
const FALHA = { erro: 'E-mail ou senha incorretos.' };
async function login(req, res, next) {
try {
const email = String(req.body.email || '').trim().toLowerCase();
const senha = String(req.body.senha || '');
const [linhas] = await pool.execute(
'SELECT id, senha_hash, confirmado FROM usuarios WHERE email = ?',
[email]
);
const usuario = linhas[0];
const ok = await bcrypt.compare(senha, usuario ? usuario.senha_hash : HASH_FICTICIO);
if (!usuario || !ok) {
req.log.info({ email, motivo: usuario ? 'senha' : 'sem_conta' }, 'login recusado');
return res.status(401).json(FALHA);
}
if (!usuario.confirmado) {
return res.status(403).json({ erro: 'Confirme seu e-mail para entrar. Reenviamos o link.' });
}
await iniciarSessao(req, res, usuario.id);
return res.json({ ok: true });
} catch (err) {
return next(err);
}
}
O ponto central é a linha do bcrypt.compare: ela roda sempre, com um hash real ou com o fictício. O hash fictício precisa usar o mesmo custo dos hashes do banco; se os seus usuários têm hashes com custo 10 e o fictício tem 12, o tempo volta a denunciar. O log registra o e-mail e o motivo, sem a senha, como manda log útil não carrega senha.
A checagem de confirmado vem depois da senha correta. Quem chega até ela já provou ser dono da conta, então a mensagem específica não ensina nada a um estranho.
Cadastro e recuperação de senha: as portas laterais
Fechar o login e deixar o cadastro dizendo "este e-mail já está cadastrado" é trancar a porta da frente e abrir a janela. A saída para o cadastro é responder sempre da mesma forma, "enviamos um e-mail para continuar", e deixar o e-mail fazer a diferença. Se a conta não existe, a mensagem traz o link de confirmação. Se já existe, a mensagem diz "alguém tentou criar uma conta com este e-mail; se foi você, entre ou redefina a senha".
Na recuperação de senha, mesma ideia: a tela diz "se houver uma conta com este e-mail, enviamos as instruções". Só o dono da caixa de entrada descobre se a conta existe. Para o link em si, veja link de redefinição que expira e serve uma vez.
Há um custo de experiência: quem esqueceu com qual e-mail se cadastrou não recebe a dica na tela. Em sistemas onde a existência da conta não é sensível, algumas equipes aceitam esse vazamento conscientemente. A decisão é válida se for escrita e tomada por alguém que entende o risco, não herdada do primeiro tutorial.
Como testar se ainda vaza
Faça duas chamadas: uma com um e-mail que existe e senha errada, outra com um e-mail inventado. Compare status, corpo e tempo.
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email":"existe@exemplo.com","senha":"errada"}'
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email":"inventado@exemplo.com","senha":"errada"}'
Rode cada uma umas vinte vezes. Se uma fica em 250 ms e a outra em 5 ms, o hash fictício não está sendo usado. Se os status diferem, há um caminho no código que você não unificou. Repita o teste no cadastro e na recuperação.
Erros comuns
- Mensagem igual na tela e diferente na API. O atacante não usa a tela; ele lê o JSON e o status.
- Retornar antes de verificar o hash quando a conta não existe. A diferença de tempo vira um oráculo tão bom quanto a mensagem.
- Dizer "conta bloqueada" antes de conferir a senha. Só contas que existem podem estar bloqueadas, então a frase confirma o cadastro.
- Esquecer o cadastro e a recuperação. Eles respondem a mesma pergunta que você escondeu no login.
- Esconder o motivo também do log. O suporte fica cego e passa a pedir para "tentar de novo" sem saber que a conta não existe.
Checklist
- Senha errada e e-mail inexistente devolvem o mesmo status e o mesmo corpo.
- O código sempre executa uma verificação de hash, com custo igual ao dos hashes reais.
- Mensagens de conta bloqueada ou não confirmada só aparecem depois da senha correta.
- O cadastro responde igual para e-mail novo e e-mail existente, e o e-mail enviado é que muda.
- A recuperação de senha mostra uma frase neutra na tela.
- O log registra o motivo real da recusa, sem a senha.
- Existe limite de tentativas no servidor, por conta e por IP.
Teste agora no seu sistema: um e-mail que não existe e uma senha errada de uma conta real devolvem exatamente a mesma resposta, no mesmo tempo?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html