Voltar para Artigos
Back-end★ Destaque15 min de leitura

Arquitetando o Fluxo de Recuperação de Senhas

Fluxo completo de recuperação de senha com 4 etapas: código numérico com timing-safe comparison, JWT de propósito específico, rate limiting por IP e por email, hash do token no banco e invalidação de sessões após reset.

12 de agosto de 2026

Recuperação de senha é um dos fluxos mais sensíveis de qualquer sistema — é a porta de entrada para quem não tem a senha. Implementações ingênuas expõem tokens longos em URLs (logados em proxies e analytics), não limitam tentativas (brute force de código OTP), e não invalidam sessões ativas após o reset (um atacante que sequestrou a conta ainda fica logado).

Este artigo implementa o fluxo completo em 4 etapas com as medidas de segurança que um sistema de produção exige: enumeração de usuários prevenida, código de 6 dígitos com timing-safe comparison, JWT com purpose específico, rate limiting em múltiplas camadas e invalidação de todas as sessões após a troca de senha.

Modelo do Banco de Dados

A modelagem do banco é a primeira decisão de segurança: nunca armazenar o código OTP em texto plano. Se o banco vazar, um atacante não deve conseguir usar os códigos de reset. Por isso armazenamos o SHA-256(codigo) — um hash irreversível. A coluna attempts limita o brute force a nível de banco (independente do rate limiting da API): após 5 tentativas erradas com um mesmo token, ele é invalidado mesmo que ainda esteja no prazo de validade.

PasswordResetModel.ts
import { Entity, PrimaryColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { UserModel } from './UserModel';

@Entity('password_resets')
export class PasswordResetModel {
  @PrimaryColumn('uuid')
  id: string;

  @Column({ name: 'user_id' })
  userId: string;

  @ManyToOne(() => UserModel, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })
  user: UserModel;

  // Armazenamos o HASH do código, não o código em si
  // Se o banco vazar, o atacante não consegue usar os códigos
  @Column({ name: 'code_hash' })
  codeHash: string;

  // Número de tentativas de verificação com este código
  // Limita brute force mesmo sem rate limiting na API
  @Column({ name: 'attempts', default: 0 })
  attempts: number;

  @Column({ name: 'expires_at', type: 'timestamp with time zone' })
  expiresAt: Date;

  // Marca como usado — impede reutilização mesmo dentro do prazo
  @Column({ default: false })
  used: boolean;

  @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
  createdAt: Date;
}

Etapa 1: Solicitar Recuperação

RequestPasswordResetUseCase.ts
import { randomInt, createHash } from 'crypto';
import { addMinutes } from 'date-fns';

export class RequestPasswordResetUseCase {
  constructor(
    private usersRepo: IUsersRepository,
    private passwordResetsRepo: IPasswordResetsRepository,
    private mailQueue: IQueue
  ) {}

  async execute(email: string): Promise<void> {
    // SEMPRE retorna sem erro, mesmo se o e-mail não existir
    // Previne enumeração de usuários (saber quais e-mails estão cadastrados)
    const user = await this.usersRepo.findByEmail(email);
    if (!user) {
      // Aguarda o mesmo tempo que aguardaria se encontrasse o usuário
      // Previne timing attack por diferença de latência de resposta
      await new Promise((resolve) => setTimeout(resolve, 200));
      return;
    }

    // Invalida códigos anteriores do mesmo usuário (apenas um ativo por vez)
    await this.passwordResetsRepo.invalidateByUserId(user.id);

    // Código numérico de 6 dígitos — mais amigável que UUID para digitar
    const code = randomInt(100_000, 999_999);

    // Armazena o HASH do código no banco, não o código em si
    const codeHash = createHash('sha256').update(code.toString()).digest('hex');

    await this.passwordResetsRepo.create({
      id: crypto.randomUUID(),
      userId: user.id,
      codeHash,
      attempts: 0,
      expiresAt: addMinutes(new Date(), 15), // 15 minutos de validade
      used: false,
    });

    // E-mail enfileirado — não bloqueia a resposta HTTP
    await this.mailQueue.add('send-recovery-email', {
      to: user.email,
      name: user.name,
      code, // O código em texto puro vai APENAS no e-mail, nunca no banco
    });
  }
}

Etapa 2: Rate Limiting na Rota

Um código OTP de 6 dígitos tem apenas 900.000 combinações — o atacante poderia tentar todas em minutos sem rate limiting. Configure limites em duas camadas:

password-reset.routes.ts
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { getRedisClient } from '../infra/redis/RedisClient';

const router = Router();

// Rate limit por IP: máximo 5 solicitações de reset por 15 minutos
const requestResetLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Muitas tentativas. Aguarde 15 minutos.' },
  standardHeaders: true,
  store: new RedisStore({ sendCommand: (...args) => getRedisClient().call(...args) }),
  keyGenerator: (req) => `rate:reset:request:${req.ip}`,
});

// Rate limit por IP: máximo 10 tentativas de verificação de código por 15 minutos
const verifyCodeLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Muitas tentativas de verificação. Aguarde.' },
  store: new RedisStore({ sendCommand: (...args) => getRedisClient().call(...args) }),
  keyGenerator: (req) => `rate:reset:verify:${req.ip}`,
});

router.post('/request', requestResetLimiter, controller.request);
router.post('/verify', verifyCodeLimiter, controller.verify);
router.post('/reset', controller.reset); // O JWT já protege esta rota

export { router as passwordResetRoutes };

Etapa 3: Verificar Código e Emitir JWT de Propósito

VerifyPasswordResetCodeUseCase.ts
import { createHash, timingSafeEqual } from 'crypto';
import { sign } from 'jsonwebtoken';
import { isBefore } from 'date-fns';

export class VerifyPasswordResetCodeUseCase {
  constructor(private passwordResetsRepo: IPasswordResetsRepository) {}

  async execute(email: string, code: string): Promise<{ resetToken: string }> {
    const reset = await this.passwordResetsRepo.findActiveByEmail(email);

    // Usa a mesma mensagem de erro para código inválido e código expirado
    // Não dá informação ao atacante sobre o estado do código
    const genericError = new AppError('Código inválido ou expirado.', 400);

    if (!reset || reset.used) throw genericError;

    // Verifica expiração
    if (isBefore(reset.expiresAt, new Date())) {
      await this.passwordResetsRepo.markAsUsed(reset.id);
      throw genericError;
    }

    // Verifica limite de tentativas (brute force protection no banco)
    if (reset.attempts >= 5) {
      throw new AppError('Código bloqueado por muitas tentativas.', 429);
    }

    // Incrementa tentativas ANTES de verificar o código
    // (mesmo em tentativas inválidas, o contador sobe)
    await this.passwordResetsRepo.incrementAttempts(reset.id);

    // Timing-safe comparison: evita timing attacks
    // compare() leva o mesmo tempo independente de onde os bytes diferem
    const inputHash = Buffer.from(
      createHash('sha256').update(code).digest('hex')
    );
    const storedHash = Buffer.from(reset.codeHash);

    const isValid = inputHash.length === storedHash.length &&
      timingSafeEqual(inputHash, storedHash);

    if (!isValid) throw genericError;

    // Código válido: marca como usado imediatamente (one-time use)
    await this.passwordResetsRepo.markAsUsed(reset.id);

    // Emite JWT com purpose específico — não pode ser usado para outras operações
    const resetToken = sign(
      { purpose: 'password_reset', email },
      process.env.JWT_SECRET as string,
      { subject: reset.userId, expiresIn: '15m' }
    );

    return { resetToken };
  }
}

Etapa 4: Alterar a Senha e Invalidar Sessões

ResetPasswordUseCase.ts
import { verify, type JwtPayload } from 'jsonwebtoken';
import { hash } from 'bcrypt';

interface ResetTokenPayload extends JwtPayload {
  purpose: string;
  email: string;
  sub: string;
}

export class ResetPasswordUseCase {
  constructor(
    private usersRepo: IUsersRepository,
    private refreshTokensRepo: IRefreshTokensRepository
  ) {}

  async execute(resetToken: string, newPassword: string): Promise<void> {
    let payload: ResetTokenPayload;

    try {
      payload = verify(resetToken, process.env.JWT_SECRET as string) as ResetTokenPayload;
    } catch {
      throw new AppError('Token inválido ou expirado.', 401);
    }

    // Verifica que este JWT foi emitido especificamente para reset de senha
    // Um JWT de autenticação normal não pode ser usado aqui
    if (payload.purpose !== 'password_reset') {
      throw new AppError('Token com propósito inválido.', 401);
    }

    const user = await this.usersRepo.findById(payload.sub);
    if (!user) throw new AppError('Usuário não encontrado.', 404);

    // Hash da nova senha (Argon2 ou bcrypt)
    user.passwordHash = await hash(newPassword, 12);
    await this.usersRepo.save(user);

    // CRÍTICO: invalida TODOS os refresh tokens do usuário
    // Um atacante que tinha sessão ativa perde o acesso imediatamente
    await this.refreshTokensRepo.revokeAllByUserId(user.id);

    // Notifica o usuário da troca de senha (e-mail de segurança)
    // NÃO enfileire — envie de forma síncrona ou com alta prioridade
    // O usuário precisa saber IMEDIATAMENTE se não foi ele quem trocou
    await this.mailProvider.send({
      to: user.email,
      subject: 'Sua senha foi alterada',
      html: `<p>Sua senha foi alterada em ${new Date().toLocaleString('pt-BR')}. Se não foi você, entre em contato imediatamente.</p>`,
    });
  }
}

Nunca armazene o código OTP em texto puro no banco. Se um atacante obtiver acesso de leitura ao banco, poderia usar os códigos ativos para resetar senhas de qualquer usuário. Armazene sempre o SHA-256 do código — como fizemos com codeHash. E use timingSafeEqual para comparação, não ===, que vaza informação por diferença de tempo.

Checklist de Segurança

  • Anti-enumeração — retorna sucesso mesmo quando o e-mail não existe
  • Timing attack — setTimeout artificial e timingSafeEqual na comparação
  • One-time use — código marcado como used imediatamente após verificação
  • Expiração — 15 minutos no banco + JWT de 15 minutos
  • Limite de tentativas — máximo 5 por código + rate limiting por IP no Redis
  • JWT de propósito — purpose: 'password_reset' impede uso do token em outras rotas
  • Invalidação de sessões — todos os refresh tokens revogados após o reset
  • Notificação — e-mail de segurança enviado após a troca com alta prioridade

Conclusão

Recuperação de senha é um fluxo onde cada detalhe importa. A separação em 4 etapas (solicitar → verificar código → obter JWT → trocar senha) permite aplicar controles de segurança específicos em cada fase. O hash do código OTP no banco, o timingSafeEqual na comparação, o JWT com purpose e a invalidação das sessões são as medidas que transformam um fluxo funcional em um fluxo seguro.