Como fazer o token de "esqueci minha senha" com segurança

O link de "esqueci minha senha" dá acesso à conta de quem o tiver, então o token precisa ser imprevisível, valer por pouco tempo e servir uma única vez. Este guia mostra como gerar esse token, por que guardar só o hash dele no banco, como conferir validade e uso, e os detalhes que costumam vazar o link, com exemplos em JavaScript, Python e PHP.

Por Leonardo Gaertner · Publicado em

O que o token precisa ter

  • Imprevisível: gerado pelo gerador aleatório criptográfico, com pelo menos 128 bits. Os exemplos usam 256 bits.
  • Validade curta: de 15 minutos a 1 hora.
  • Uso único: depois de trocar a senha, o token não vale mais.
  • No banco, só o hash: se o banco vazar, os tokens guardados não servem para nada.

Por que aqui o SHA-256 basta

Para senhas, um hash rápido como o SHA-256 é uma má ideia, porque senhas são fáceis de adivinhar e testar em massa. O token é diferente: 256 bits aleatórios não se adivinham, então um hash rápido já impede que quem leia o banco use os tokens. O banco guarda o hash, e o link do e-mail leva o token.

Para guardar senhas, veja como guardar senhas com hash.

Em JavaScript (Node.js)

Na conferência, calcule o hash do token recebido e busque o registro por ele (WHERE hash = ?). Se não existir, estiver expirado ou já tiver sido usado, recuse. Ao trocar a senha, marque o registro como usado na mesma transação.

JavaScript
import { createHash, randomBytes } from 'node:crypto';

const VALIDADE_MS = 30 * 60 * 1000; // 30 minutos

const hashDoToken = (token) => createHash('sha256').update(token).digest('hex');

// O token vai no link do e-mail; no banco fica só o hash dele
function criarTokenDeRedefinicao(usuarioId, agora = Date.now()) {
  const token = randomBytes(32).toString('base64url'); // 256 bits aleatórios
  const registro = {
    usuarioId,
    hash: hashDoToken(token),
    expiraEm: agora + VALIDADE_MS,
    usadoEm: null,
  };
  return { token, registro };
}

// Busca pelo hash (WHERE hash = ?) e confere validade e uso antes de trocar a senha
function registroValido(registro, agora = Date.now()) {
  return Boolean(registro) && registro.usadoEm === null && agora <= registro.expiraEm;
}

Em Python e PHP

No Python, secrets.token_urlsafe(32) já gera 256 bits em um texto seguro para URL. No PHP, random_bytes é a fonte criptográfica.

Python
import hashlib
import secrets
import time

VALIDADE_S = 30 * 60  # 30 minutos


def hash_do_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()


def criar_token_de_redefinicao(usuario_id: int, agora: float | None = None):
    agora = time.time() if agora is None else agora
    token = secrets.token_urlsafe(32)  # vai no link do e-mail
    registro = {
        "usuario_id": usuario_id,
        "hash": hash_do_token(token),
        "expira_em": agora + VALIDADE_S,
        "usado_em": None,
    }
    return token, registro  # no banco, só o registro (com o hash)


def registro_valido(registro, agora: float | None = None) -> bool:
    agora = time.time() if agora is None else agora
    return bool(registro) and registro["usado_em"] is None and agora <= registro["expira_em"]
PHP
<?php
const VALIDADE_S = 30 * 60; // 30 minutos

function hashDoToken(string $token): string
{
    return hash('sha256', $token);
}

function criarTokenDeRedefinicao(int $usuarioId, ?int $agora = null): array
{
    $agora ??= time();
    $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); // vai no e-mail
    $registro = [
        'usuarioId' => $usuarioId,
        'hash' => hashDoToken($token),
        'expiraEm' => $agora + VALIDADE_S,
        'usadoEm' => null,
    ];
    return [$token, $registro]; // no banco, só o registro
}

function registroValido(?array $registro, ?int $agora = null): bool
{
    return $registro !== null
        && $registro['usadoEm'] === null
        && ($agora ?? time()) <= $registro['expiraEm'];
}

Os detalhes que vazam o link

  • Resposta igual para e-mail cadastrado ou não ("se o e-mail existir, enviamos um link"). Senão, o formulário revela quem tem conta.
  • Na página de redefinição, use Referrer-Policy: no-referrer. Sem isso, o endereço com o token pode ir no cabeçalho Referer para scripts e links de terceiros.
  • Não registre a URL completa em logs, nem em ferramentas de análise.
  • Limite os pedidos por e-mail e por IP, para ninguém lotar a caixa de alguém.
  • Ao trocar a senha, invalide os outros tokens do usuário e encerre as sessões abertas.

Achou um erro neste guia? Avise por e-mail.

Perguntas frequentes

Quanto tempo deve valer o link de redefinição de senha?

Pouco: de 15 minutos a 1 hora é o mais comum. Quanto mais tempo o link vale, mais tempo ele fica útil para quem conseguir acesso ao e-mail.

Preciso guardar o token de redefinição no banco?

Guarde só o hash dele, com a validade e se já foi usado. Na conferência, calcule o hash do token recebido e busque por ele. Assim, um vazamento do banco não entrega links válidos.

Posso usar um UUID como token de redefinição?

Um UUID v4 tem 122 bits aleatórios e, se gerado com um gerador criptográfico, é difícil de adivinhar. Mesmo assim, prefira um token próprio com 256 bits, como os deste guia, e nunca use UUID v1 ou v7, que têm partes previsíveis.

Por que a mensagem não diz se o e-mail está cadastrado?

Para não revelar quem tem conta no site. Com mensagens diferentes, qualquer pessoa descobre se um e-mail está cadastrado só usando o formulário.