Enumeração de usuários é a capacidade de um atacante descobrir quais e-mails ou nomes de usuário existem em uma aplicação, observando como ela responde. Sozinha, ela raramente é “crítica” — mas é o combustível de tudo o que vem depois: credential stuffing direcionado, password spraying, phishing personalizado e ataques de força bruta com metade do trabalho já feito. Saber quem tem conta reduz drasticamente o espaço de busca do atacante.
O problema quase sempre nasce de uma boa intenção de UX: mensagens de erro “úteis” que, sem querer, viram um oráculo.
O oráculo no formulário de login
Considere duas respostas de login:
# Usuário não existe
"Não encontramos uma conta com esse e-mail."
# Senha incorreta
"Senha incorreta. Tente novamente."
Para o usuário legítimo, parece atencioso. Para o atacante, é um oráculo perfeito: basta enviar uma senha qualquer e ler a mensagem. “Conta não encontrada” = e-mail inexistente; “Senha incorreta” = e-mail válido. Com uma lista de e-mails vazados, ele filtra em minutos exatamente quais têm conta no seu sistema.
A correção é uma mensagem genérica e idêntica para os dois casos:
"E-mail ou senha inválidos."
O oráculo escondido no reset de senha
O fluxo de “esqueci minha senha” é o mais negligenciado. É comum ver:
# E-mail cadastrado
"Enviamos um link de redefinição para o seu e-mail."
# E-mail não cadastrado
"Esse e-mail não está cadastrado."
De novo, a segunda mensagem confirma a existência da conta. A resposta correta é sempre a mesma, exista ou não a conta:
"Se houver uma conta com esse e-mail, enviaremos um link de redefinição."
E, fundamentalmente: o e-mail só é realmente disparado quando a conta existe — mas a resposta HTTP é idêntica nos dois casos.
O oráculo no cadastro
O registro tem uma tensão real: você precisa avisar “esse e-mail já está em uso”, mas isso é enumeração. Estratégias para resolver sem vazar:
- Confirmação por e-mail antes de revelar qualquer coisa: o formulário sempre responde “enviamos um e-mail para concluir o cadastro”. Se o endereço já tem conta, o e-mail recebido diz “você já tem uma conta” (e oferece reset). Quem não controla a caixa do alvo não aprende nada.
- Tratar a verificação de disponibilidade com os mesmos cuidados de rate limiting e CAPTCHA, ciente do trade-off de UX.
Canais que vazam além do texto
Mesmo com mensagens uniformes, a aplicação pode vazar por canais laterais:
Diferença de tempo de resposta
Se a aplicação só executa o cálculo caro de verificação de hash de senha quando o usuário existe, a resposta para um usuário válido demora mensuravelmente mais que para um inexistente. O atacante mede a latência e enumera sem ler uma única mensagem.
# VULNERÁVEL: só faz o trabalho caro quando o usuário existe
def login(email, senha):
user = db.find_user(email)
if not user:
return "E-mail ou senha inválidos." # responde rápido
if not verify_password(senha, user.hash): # custoso (bcrypt/argon2)
return "E-mail ou senha inválidos." # responde devagar
return start_session(user)
A defesa é gastar o mesmo tempo nos dois caminhos — verificando o hash contra um valor dummy quando o usuário não existe:
DUMMY_HASH = hash_password("senha-impossivel-de-acertar")
def login(email, senha):
user = db.find_user(email)
hash_alvo = user.hash if user else DUMMY_HASH
senha_ok = verify_password(senha, hash_alvo) # mesmo custo sempre
if user and senha_ok:
return start_session(user)
return "E-mail ou senha inválidos."
Diferença de comportamento de bloqueio
Se a conta é bloqueada após N tentativas somente quando existe, a presença (ou ausência) do bloqueio revela contas válidas. O comportamento de lockout e os contadores precisam ser consistentes.
Outros sinais
Códigos de status HTTP distintos, redirecionamentos diferentes, presença/ausência de um cookie, tamanho do corpo da resposta — qualquer diferença observável entre “existe” e “não existe” é um vetor. O princípio é único: a resposta deve ser indistinguível.
A tensão com a usabilidade
Mensagens genéricas frustram um pouco o usuário legítimo, e times de produto resistem. O equilíbrio maduro:
- Mensagem genérica na resposta imediata do formulário (o canal que o atacante observa em escala).
- Orientação detalhada por um canal autenticado — o e-mail enviado para o endereço informado, que só o dono da caixa lê.
- Rate limiting e CAPTCHA para que, mesmo com algum resíduo de diferença, a enumeração em massa seja inviável.
Como testamos enumeração de usuários
- Comparar respostas de login para um e-mail claramente inexistente e um e-mail conhecido — texto, status, tempo, cookies, redirects.
- Repetir no reset de senha e no cadastro.
- Medir a latência em lote para detectar oráculo por timing mesmo quando o texto é uniforme.
- Testar os limites de rate limiting/CAPTCHA: dá para automatizar milhares de checagens?
- Avaliar o impacto encadeado com listas de e-mails vazadas (credential stuffing direcionado).
Checklist de mitigação
- Mensagem idêntica e genérica em login, reset e cadastro (“e-mail ou senha inválidos” / “se houver conta, enviaremos o link”).
- Tempo de resposta constante: verificar hash contra dummy quando o usuário não existe.
- Mesmos status HTTP, redirects, cookies e tamanho de corpo nos dois casos.
- Comportamento de lockout/contadores consistente, independente de a conta existir.
- Rate limiting por IP/identificador e CAPTCHA adaptativo nos fluxos de auth.
- Revelar “e-mail já cadastrado” apenas por canal autenticado (o próprio e-mail).
- Monitorar e alertar picos de falhas de login/reset (sinal de enumeração em curso).
Enumeração de usuários é uma falha de design, não de código: ela mora na diferença entre duas respostas. Eliminá-la é, no fundo, um exercício de disciplina — garantir que a aplicação diga exatamente a mesma coisa, no mesmo tempo, exista a conta ou não.