Voltar para o blog
BackendSegurançaNestJSNode.js

JWT, refresh tokens e revogação: o que ninguém conta antes de você ir para produção

26 de junho de 202612 min de leiturapor Sávio Araújo

O problema que o tutorial não mostra

Você implementou autenticação com JWT. O token tem 15 minutos de expiração. O usuário clica em "sair" e o frontend deleta o token do localStorage. Parece funcionar.

Mas o token ainda é válido por até 15 minutos. Se alguém capturou esse token (XSS, interceptação, log acidental), ele pode continuar usando a API durante esse período. E se o usuário mudar a senha por suspeita de comprometimento, o token antigo continua funcionando.

Esse é o custo real do JWT stateless: você troca complexidade de infraestrutura (sessões no banco/Redis) por vulnerabilidades de revogação.

O que faz um JWT ser válido

Um JWT tem três partes: header, payload e assinatura. O servidor valida a assinatura com sua chave secreta e confia no conteúdo do payload sem consultar nenhum estado externo.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  ← header
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxNTAwMDAwMCwiZXhwIjoxNzE1MDAwOTAwfQ.  ← payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← assinatura

O campo exp define a expiração. Enquanto exp estiver no futuro e a assinatura for válida, o token é aceito — independentemente de qualquer outra coisa que tenha acontecido com o usuário.

Access token + refresh token

A solução padrão:

  • Access token: vida curta (5–15 minutos). Stateless. Enviado em cada request.
  • Refresh token: vida longa (dias/semanas). Stateful (armazenado no banco). Usado apenas para obter novos access tokens.
// auth.service.ts
import { JwtService } from '@nestjs/jwt';
import { randomBytes } from 'crypto';
 
@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
    private readonly refreshTokenRepository: RefreshTokenRepository,
  ) {}
 
  async generateTokens(userId: string) {
    const accessToken = this.jwtService.sign(
      { sub: userId },
      { expiresIn: '15m' },
    );
 
    const refreshToken = randomBytes(40).toString('hex');
    const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 dias
 
    await this.refreshTokenRepository.create({
      token: refreshToken, // ou o hash: await bcrypt.hash(refreshToken, 10)
      userId,
      expiresAt,
    });
 
    return { accessToken, refreshToken };
  }
}

O schema do refresh token:

CREATE TABLE refresh_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token TEXT NOT NULL UNIQUE,
  user_id UUID NOT NULL REFERENCES users(id),
  expires_at TIMESTAMPTZ NOT NULL,
  revoked_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token)
  WHERE revoked_at IS NULL;

Rotação de refresh tokens

Cada vez que o refresh token é usado para obter um novo access token, você deve:

  1. Invalidar o refresh token atual
  2. Emitir um novo refresh token junto com o novo access token

Isso é chamado de refresh token rotation. O benefício: se um refresh token for roubado e usado, você detecta o double-use — dois clients tentando usar o mesmo token — e pode revogar toda a família de sessão.

async refreshTokens(refreshToken: string) {
  const stored = await this.refreshTokenRepository.findValid(refreshToken);
 
  if (!stored) {
    // Token inválido, expirado ou já revogado
    // Se foi revogado anteriormente, pode ser reuse attack — revogar todas as sessões do usuário
    await this.handlePossibleTokenReuse(refreshToken);
    throw new UnauthorizedException('Refresh token inválido.');
  }
 
  // Revoga o token atual (rotação)
  await this.refreshTokenRepository.revoke(stored.id);
 
  // Emite novos tokens
  return this.generateTokens(stored.userId);
}
 
private async handlePossibleTokenReuse(token: string) {
  const revoked = await this.refreshTokenRepository.findRevoked(token);
  if (revoked) {
    // Alguém usou um token já revogado — possível comprometimento
    await this.refreshTokenRepository.revokeAllForUser(revoked.userId);
    // Opcional: notificar o usuário
  }
}

Revogação de access tokens

O problema fundamental: access tokens de vida curta não são revogáveis por natureza stateless.

Para revogar antes da expiração, a opção mais comum é uma blocklist no Redis:

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly redisService: RedisService,
    private readonly configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }
 
  async validate(payload: { sub: string; iat: number; exp: number }) {
    const isRevoked = await this.redisService.exists(`revoked:${payload.sub}:${payload.iat}`);
    if (isRevoked) throw new UnauthorizedException('Token revogado.');
 
    return { id: payload.sub };
  }
}

Ao fazer logout ou revogar sessão:

async logout(userId: string, tokenIat: number, tokenExp: number) {
  const ttl = tokenExp - Math.floor(Date.now() / 1000); // segundos restantes
  if (ttl > 0) {
    await this.redisService.setex(`revoked:${userId}:${tokenIat}`, ttl, '1');
  }
 
  await this.refreshTokenRepository.revokeAllForUser(userId);
}

O iat (issued at) é o identificador do token individual. A chave no Redis expira junto com o token, então a blocklist não cresce indefinidamente.

Armazenamento seguro no cliente

Onde guardar o refresh token no frontend importa muito:

| Opção | XSS | CSRF | Recomendação | |---|---|---|---| | localStorage | Vulnerável | Imune | Não usar para refresh token | | Cookie HttpOnly + Secure | Imune | Vulnerável | Usar com proteção CSRF | | Cookie HttpOnly + SameSite=Strict | Imune | Imune (mesma origem) | Melhor opção | | Memória (variável JS) | Imune | Imune | Perde no refresh da página |

A configuração recomendada para o cookie de refresh token:

// auth.controller.ts
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
  const refreshToken = req.cookies['refresh_token'];
  const tokens = await this.authService.refreshTokens(refreshToken);
 
  res.cookie('refresh_token', tokens.refreshToken, {
    httpOnly: true,
    secure: true,           // só HTTPS
    sameSite: 'strict',     // só mesma origem
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
    path: '/auth/refresh',  // só disponível para esse endpoint
  });
 
  return { accessToken: tokens.accessToken };
}

O access token pode ir no corpo da resposta e ser guardado em memória no frontend. Ele expira em 15 minutos de qualquer forma — não vale o risco de colocar no localStorage.

Mudança de senha invalida tokens

Quando o usuário muda a senha, todos os refresh tokens devem ser revogados:

async changePassword(userId: string, oldPassword: string, newPassword: string) {
  // Valida senha atual, faz o hash da nova...
  await this.userRepository.updatePassword(userId, hashedPassword);
 
  // Invalida todas as sessões ativas
  await this.refreshTokenRepository.revokeAllForUser(userId);
 
  // Para invalidar access tokens em andamento, você pode incluir
  // um campo "password_changed_at" no JWT e verificar no validate()
}

Uma técnica eficiente sem blocklist: incluir o hash da senha (ou um version que incrementa a cada mudança de senha) no JWT, e no validate() comparar com o valor atual no banco. Se divergir, o token é inválido. Isso adiciona uma query por request, mas elimina a necessidade de blocklist para esse caso específico.

Resumo das decisões

| Decisão | Recomendação | |---|---| | Expiração do access token | 5–15 minutos | | Expiração do refresh token | 7–30 dias | | Rotação de refresh token | Sempre | | Revogação de access token | Blocklist Redis com TTL | | Armazenamento do refresh token | Cookie HttpOnly + Secure + SameSite | | Armazenamento do access token | Memória (ou cookie separado) | | Mudança de senha | Revogar todos os refresh tokens |

JWT stateless é uma troca, não uma solução mágica. Se você precisa de revogação instantânea em todos os cenários, sessões com estado (Redis) são mais simples. Se você aceita a janela de expiração do access token (5–15 min), JWT com refresh token rotacionado é uma escolha sólida.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech