Destino Dev

Blog

Autenticação JWT segura no NestJS com refresh tokens rotativos e Redis

Por John Pires · 06 de março de 2026

Contexto

JWTs (JSON Web Tokens) são amplamente usados para autenticação em APIs. Em aplicações modernas, adota-se um par de tokens: access token de curta duração e refresh token de longa duração. Access tokens são usados para autorizar requests. Refresh tokens permitem obter novos access tokens sem que o usuário faça login novamente.

No entanto, refresh tokens de longa duração introduzem risco: se um refresh token vazar, um atacante pode trocar indefinidamente por access tokens. A solução prática é usar refresh tokens rotativos (rotation) e um armazenamento server-side (Redis) para controlar validade e prevenir replay.

Explicação técnica

Princípios

Resumo do mecanismo que vamos implementar:

  • Access token curto (por exemplo, 15 minutos) assinado com JWT.
  • Refresh token rotativo: a cada uso do refresh token, você emite um novo refresh token (com novo jti) e invalida o anterior.
  • Redis armazena o identificador do refresh token (jti) ligado a uma sessão ou usuário e tem TTL igual ao tempo de vida do refresh token.
  • Detecção de replay: se um refresh token já usado for reapresentado, isso indica comprometimento e você revoga toda a sessão e outros refresh tokens relacionados.
  • Armazenamento do refresh token no cliente em cookie httpOnly+secure+SameSite ou em armazenamento seguro no cliente, mas preferencialmente cookie.

Estrutura do payload JWT

Recomendações para claims:

  • sub: id do usuário
  • typ: 'access' ou 'refresh'
  • jti: uuidv4 único por token
  • sid: session id (opcional) para agrupar tokens da mesma autenticação
  • iat, exp

O jti do refresh token é a chave primária para controle em Redis.

Redis: chave, valor e TTL

Convenção de chave:

refresh:jti:{jti} -> { "uid": userId, "sid": sessionId }

TTL da chave = tempo de expiração do refresh token (ex: 30 dias). Ao rotacionar, você cria uma nova chave com novo jti e deleta a chave anterior. Para detectar replay, em vez de apagar imediatamente a chave antiga, marque-a como usada por um curto período adicional (ou mantenha um registro de eventos de revogação) para distinguir reuse versus primeiro uso legítimo.

Detecção de replay e resposta

Fluxo seguro:

  1. Cliente envia refresh token R_old (com jti J1).
  2. Server verifica assinatura JWT; extrai J1 e procura refresh:jti:J1 em Redis.
  3. Se encontrado e estado = valid: emitir novo access token e novo refresh token (J2). Persistir refresh:jti:J2 e deletar ou marcar J1 como used e criar um marker revogado:refresh:jti:J1 com TTL longo o suficiente para detectar reuse.
  4. Se não encontrado, mas existir marker revogado para J1: isso indica reuse -> revogar sessão inteira e forçar logout dos dispositivos.
  5. Se não encontrado e sem marker, pode ser token expirado ou manipulado -> negar e exigir login.

Exemplos

Abaixo exemplos práticos em NestJS usando ioredis. Dois blocos principais: AuthService e AuthController com endpoints /login, /refresh, /logout. Também mostro um guard para refresh tokens.

Exemplo 1: AuthService (emitir, rotacionar, validar)

import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import Redis from 'ioredis'
import { v4 as uuidv4 } from 'uuid'

@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService, private readonly redis: Redis) {}

  private ACCESS_TTL = 60 * 15 // 15 minutes
  private REFRESH_TTL = 60 * 60 * 24 * 30 // 30 days

  async issueTokens(userId: string, sessionId: string) {
    const accessPayload = { sub: userId, typ: 'access', sid: sessionId }
    const accessToken = this.jwtService.sign(accessPayload, { expiresIn: this.ACCESS_TTL })

    const jti = uuidv4()
    const refreshPayload = { sub: userId, typ: 'refresh', jti, sid: sessionId }
    const refreshToken = this.jwtService.sign(refreshPayload, { expiresIn: this.REFRESH_TTL })

    const key = `refresh:jti:${jti}`
    await this.redis.set(key, JSON.stringify({ uid: userId, sid: sessionId }), 'EX', this.REFRESH_TTL)

    return { accessToken, refreshToken }
  }

  async rotateRefreshToken(oldJti: string, tokenPayload: any) {
    const keyOld = `refresh:jti:${oldJti}`
    const record = await this.redis.get(keyOld)
    if (!record) {
      // could be replay
      const marker = await this.redis.get(`revoked:refresh:jti:${oldJti}`)
      if (marker) {
        throw new UnauthorizedException('Refresh token reuse detected')
      }
      throw new UnauthorizedException('Invalid refresh token')
    }

    const data = JSON.parse(record)
    if (data.uid !== tokenPayload.sub) throw new UnauthorizedException('Token user mismatch')

    // revoke old and record as revoked short-term to detect reuse
    await this.redis.del(keyOld)
    await this.redis.set(`revoked:refresh:jti:${oldJti}`, JSON.stringify({ uid: data.uid, sid: data.sid }), 'EX', 60 * 60 * 24) // keep revocation marker for 1 day

    // issue new
    const newJti = uuidv4()
    const refreshPayload = { sub: data.uid, typ: 'refresh', jti: newJti, sid: data.sid }
    const newRefresh = this.jwtService.sign(refreshPayload, { expiresIn: this.REFRESH_TTL })
    const keyNew = `refresh:jti:${newJti}`
    await this.redis.set(keyNew, JSON.stringify({ uid: data.uid, sid: data.sid }), 'EX', this.REFRESH_TTL)

    const accessPayload = { sub: data.uid, typ: 'access', sid: data.sid }
    const newAccess = this.jwtService.sign(accessPayload, { expiresIn: this.ACCESS_TTL })

    return { accessToken: newAccess, refreshToken: newRefresh }
  }
}

Exemplo 2: Controller e RefreshGuard

import { Controller, Post, Req, Res, Body, UseGuards, UnauthorizedException } from '@nestjs/common'
import { Response, Request } from 'express'
import { AuthService } from './auth.service'
import { JwtService } from '@nestjs/jwt'

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService, private jwtService: JwtService) {}

  @Post('login')
  async login(@Body() body: any, @Res() res: Response) {
    // autenticar credenciais (omitido)
    const userId = 'user:123'
    const sessionId = 'sess:' + Date.now()

    const { accessToken, refreshToken } = await this.authService.issueTokens(userId, sessionId)

    // set cookie httpOnly
    res.cookie('refresh_token', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 1000 * 60 * 60 * 24 * 30,
    })

    return res.json({ accessToken })
  }

  @Post('refresh')
  async refresh(@Req() req: Request, @Res() res: Response) {
    const token = req.cookies['refresh_token']
    if (!token) throw new UnauthorizedException()

    let payload: any
    try { payload = this.jwtService.verify(token) } catch (e) { throw new UnauthorizedException('Invalid token') }

    if (payload.typ !== 'refresh') throw new UnauthorizedException('Not a refresh token')

    const oldJti = payload.jti
    const tokens = await this.authService.rotateRefreshToken(oldJti, payload)

    // overwrite cookie with new refresh token
    res.cookie('refresh_token', tokens.refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 1000 * 60 * 60 * 24 * 30,
    })

    return res.json({ accessToken: tokens.accessToken })
  }

  @Post('logout')
  async logout(@Req() req: Request, @Res() res: Response) {
    const token = req.cookies['refresh_token']
    if (token) {
      try {
        const payload: any = this.jwtService.verify(token)
        const key = `refresh:jti:${payload.jti}`
        await this.authService.redis.del(key)
        await this.authService.redis.set(`revoked:refresh:jti:${payload.jti}`, 'logout', 'EX', 60 * 60 * 24)
      } catch (e) {
        // ignore invalid tokens
      }
    }

    res.clearCookie('refresh_token')
    return res.json({ ok: true })
  }
}

Notas sobre implementação

Use variáveis de ambiente para chaves JWT e TTLs. Proteja a rota /refresh com rate limiting para reduzir ataques de brute force sobre tokens assinados.

Erros comuns

  • Não usar jti: sem jti fica difícil invalidar tokens individuais.
  • Apagar a chave antiga em Redis sem gravar um marcador de revogação: assim, quando o token antigo for reapresentado você não consegue distinguir entre token expirado e replay.
  • Manter refresh tokens com TTL muito longo sem rotação: aumenta janela de comprometimento.
  • Guardar refresh token no localStorage: suscetível a XSS. Prefira cookie httpOnly com SameSite.
  • Não limitar o uso de refresh: permitir muitos refreshes em curto período sem proteção facilita abuso. Use rate limit por IP/e dispositivo.
  • Não invalidar sessão inteira ao detectar replay: se reuse detectado, revogue todos os refresh tokens associados ao sessionId e force logout.

Checklist final

  • Usar access token curto (ex: 15min) e refresh token rotativo.
  • Incluir jti único em cada refresh token e armazenar no Redis com TTL.
  • Ao trocar refresh token: criar novo jti, persistir novo registro e invalidar/registrar revogação do antigo.
  • Manter um marcador de revogação para detectar reuse e responder revogando sessão inteira.
  • Armazenar refresh token no cliente em cookie httpOnly+secure+SameSite (evitar localStorage).
  • Aplicar rate limiting e monitoramento em endpoint de refresh.
  • Logar eventos de refresh e de revogação para auditoria e detecção de anomalias.
  • Considerar binding do refresh token ao fingerprint do dispositivo (opcional) e limitar sessões por usuário/dispositivo.

Considerações finais

Refresh tokens rotativos combinados com um armazenamento server-side (Redis) oferecem um equilíbrio entre usabilidade e segurança. A chave é tornar os refresh tokens single-use e observar reuse como um indicador de comprometimento. Implementação cuidadosa em NestJS com Redis e verificação de jti permite mitigar replay attacks e limitar o impacto de vazamentos.

Assuntos: NestJS, jwt, NoSQL, authentication, refresh-tokens

Leia também