Destino Dev

Blog

Rotação segura de refresh tokens JWT: detectar reuse e revogar sessões

Por John Pires · 10 de março de 2026

Cenario pratico do dia a dia

Problema comum em apps web e mobile

Em aplicações modernas com autenticação baseada em JWT, é comum separar access tokens de curta duração e refresh tokens de duração maior para melhorar a usabilidade. Na prática, cada vez que o cliente usa um refresh token para emitir um novo access token, é possível rotacionar o refresh token (emitir um novo e invalidar o antigo). Essa técnica reduz a janela de ataque quando um refresh token vaza. Porém, se a rotação não for implementada corretamente, um atacante que copiou um refresh token antes da rotação pode usá-lo para obter sessões válidas. Detectar esse "reuse" (reutilização maliciosa) é essencial para revogar sessão e mitigar invasões.

O cenário típico envolve: clientes web/mobile, backend REST ou GraphQL, um banco para guardar estado de sessão e possivelmente Redis para performance. A rotação precisa ser determinística e atômica para evitar condições de corrida e falsos positivos que derrubam usuários legítimos.

Objetivos e critérios de sucesso

Ao final dessa leitura você deverá ser capaz de:

  • Entender o padrão de rotação segura de refresh tokens com JWT.
  • Implementar um fluxo que detecta reuse e revoga sessões comprometidas.
  • Conhecer trade-offs entre complexidade, consistência e performance.

Critérios de sucesso: um refresh token só pode ser usado uma vez; se reaparecer, o sistema detecta e revoga todas as sessions relacionadas ao usuário.

Arquitetura da solucao

Componentes principais

A arquitetura recomendada é simples e prática:

  1. JWT access token: curto prazo (ex: 5-15 minutos), assinado e stateless.
  2. JWT refresh token: mais longo (ex: 7-30 dias), contém um identificador único (jti) e um session id.
  3. Store de sessão (Redis ou banco relacional) que persiste o estado do refresh token: jti atual, validade, e flag usado.
  4. Endpoints: /login, /refresh, /logout, endpoints protegidos por access token.

O ponto crítico é que o refresh token deve representar um secret de uso único e atrelado a um registro de sessão no servidor. Ao receber um refresh, o servidor compara o jti do token com o jti armazenado na sessão. Se coincidir e não estiver marcado como usado, emite novos tokens e atualiza o registro. Se não coincidir, trata como reuse e revoga a sessão inteira.

Modelo de dados e invariantes

Estrutura de sessão sugerida no Redis (chave por sessionId):

  • userId: id do usuário
  • currentJti: jti do refresh token vigente
  • expiresAt: timestamp
  • revoked: boolean

Invariantes que garatem segurança:

  • Um sessionId corresponde a exatamente um refresh token vigente (currentJti).
  • Ao rotacionar, a atualização do currentJti e a emissão do novo token devem ser atômicas (transação Redis ou operação DB em transação).
  • Se um jti que não bate com currentJti for apresentado, considerar possível comprometimento e revogar sessão.

Codigo completo com explicacoes

Contexto: ambiente e libs

Exemplo em Node.js com Express, jsonwebtoken e ioredis. Assuma que existe um user store simples (DB) para autenticação. Usamos Redis como store de sessões para rapidez e TTL nativo.

Função para emitir tokens (o que faz)

O código abaixo gera um access token e um refresh token com jti e sessionId embutidos. Em seguida persiste a sessão no Redis com currentJti igual ao jti recém-gerado. Essa persistência deve ser atômica para evitar condições de corrida.

// Gera access e refresh tokens, persiste session no Redis (Node.js, ioredis, jsonwebtoken)
const jwt = require('jsonwebtoken')
const { v4: uuidv4 } = require('uuid')
const Redis = require('ioredis')
const redis = new Redis(process.env.REDIS_URL)

async function issueTokens(userId) {
  const sessionId = uuidv4() // id da sessão, imutavel para essa sessão
  const refreshJti = uuidv4() // identificador unico do refresh

  const access = jwt.sign({ sub: userId, sid: sessionId }, process.env.ACCESS_SECRET, { expiresIn: '15m' })
  const refresh = jwt.sign({ sub: userId, sid: sessionId, jti: refreshJti }, process.env.REFRESH_SECRET, { expiresIn: '7d' })

  // Persiste sessão com TTL e currentJti; usa SETEX para TTL em segundos
  const key = `sess:${sessionId}`
  const payload = JSON.stringify({ userId, currentJti: refreshJti, revoked: false })
  await redis.set(key, payload, 'EX', 7 * 24 * 60 * 60)

  return { access, refresh }
}

Depois dessa função, o cliente recebe ambos os tokens. A sessão existe no Redis com currentJti. Se o cliente usar o refresh correto, vamos rotacionar.

Endpoint /refresh com rotação segura (o que faz)

A rota abaixo valida o refresh token, busca a sessão, compara o jti e decide: se combinar e não estiver revogada, rotaciona gerando novo refresh e atualizando currentJti; se não combinar, detecta reuse e revoga sessão.

// Express handler para refresh com detecção de reuse
const express = require('express')
const app = express()
app.use(express.json())

app.post('/refresh', async (req, res) => {
  try {
    const token = req.body.refresh
    if (!token) return res.status(400).send({ error: 'no token' })

    const payload = jwt.verify(token, process.env.REFRESH_SECRET)
    const { sub: userId, sid: sessionId, jti } = payload

    const key = `sess:${sessionId}`
    const raw = await redis.get(key)
    if (!raw) {
      // sessão não existe: possível reuse/offline logout
      return res.status(401).send({ error: 'invalid session' })
    }

    const session = JSON.parse(raw)
    if (session.revoked) {
      // sessão já revogada
      return res.status(401).send({ error: 'session revoked' })
    }

    if (session.currentJti !== jti) {
      // Detecção de reuse: jti apresentado não bate com o jti vigente
      // Revogar sessão: set revoked=true e remover dados sensíveis
      session.revoked = true
      await redis.set(key, JSON.stringify(session))
      // opção: também limpar todos os refresh tokens relacionados ou notificar user
      return res.status(401).send({ error: 'refresh token reuse detected; session revoked' })
    }

    // Rotacionar: gerar novo jti e atualizar session atomica
    const newJti = uuidv4()
    const newAccess = jwt.sign({ sub: userId, sid: sessionId }, process.env.ACCESS_SECRET, { expiresIn: '15m' })
    const newRefresh = jwt.sign({ sub: userId, sid: sessionId, jti: newJti }, process.env.REFRESH_SECRET, { expiresIn: '7d' })

    session.currentJti = newJti
    await redis.set(key, JSON.stringify(session), 'EX', 7 * 24 * 60 * 60)

    return res.send({ access: newAccess, refresh: newRefresh })
  } catch (err) {
    return res.status(401).send({ error: 'invalid token' })
  }
})

Se um atacante possuir um antigo refresh token (jti A) e o cliente já rotacionou para jti B, quando o atacante tentar usar A o servidor vai ler session.currentJti=B e detectar mismatch; então marca session.revoked e impede novos tokens. Isso revoga a sessão inteira daquele sessionId.

Logout e revogação explícita (o que faz)

Logout deve marcar session.revoked e limpar chave no Redis. Isso impede uso futuro do refresh token e força logout em outros dispositivos se você optar por revogar por user.

// Logout: revoga sessão ou limpa chave
app.post('/logout', async (req, res) => {
  const { sessionId } = req.body
  if (!sessionId) return res.status(400).send({ error: 'missing sessionId' })
  const key = `sess:${sessionId}`
  await redis.del(key)
  return res.send({ ok: true })
})

Esse código ilustra os blocos essenciais. Na prática adicione logging e auditoria quando detectar reuse.

Tratamento de erros e edge cases

Condições de corrida e atomicidade

Um erro comum é não atualizar a sessão de forma atômica: imagine dois pedidos de refresh quase simultâneos (cliente faz retry). Ambos validam o jti vigente, ambos geram novos jti e escrevem; o último vence e um dos refreshes pode aceitar sem detectar reuse. Para mitigar:

  • Use operações atômicas no Redis: WATCH/MULTI/EXEC ou Lua scripts que validem e atualizem em uma operação.
  • Outra opção é armazenar uma lista de JTIs usados e rejeitar replays com TTL curto.
Use scripts Lua no Redis para comparar currentJti e sobrescrever em uma única chamada, evitando condições de corrida.

Exemplo de script Lua atomic update

Abaixo, script que verifica currentJti e atualiza se bater; retorna erro caso contrário. Contexto: evita duas rotas refresh aceitarem o mesmo jti simultaneamente.

// Lua script executado por redis.eval: verifica e atualiza currentJti atomico
const lua = `
  local key = KEYS[1]
  local expectedJti = ARGV[1]
  local newJti = ARGV[2]
  local raw = redis.call('GET', key)
  if not raw then return {err='no_session'} end
  local session = cjson.decode(raw)
  if session.revoked then return {err='revoked'} end
  if session.currentJti ~= expectedJti then return {err='mismatch'} end
  session.currentJti = newJti
  redis.call('SET', key, cjson.encode(session))
  return {ok='updated'}
`

// Uso: redis.eval(lua, 1, key, expectedJti, newJti)

Esse script evita a janela de tempo entre GET e SET que causa race conditions.

Erros comuns e armadilhas

Listagem de problemas frequentes:

  • Guardar refresh tokens no cliente de maneira insegura (ex.: localStorage sem proteção em XSS). Use HttpOnly cookies quando possível.
  • Não embutir sessionId no refresh token: dificulta a localização da sessão no servidor.
  • Não tratar reuse: aceitar tokens antigos sem checar currentJti deixa sistema vulnerável.
  • TTL mal calculado: refresh tokens com TTL infinito aumentam risco. Use rotacionamento e expirations razoáveis.
  • Não logar eventos de reuse/revogação: perde-se contexto para investigação forense.

Dica prática: sempre registre userId, sessionId e IP/agent quando detectar reuse e dispare alertas para operações sensíveis (invalidação, email de segurança).

Performance e otimizacao

Impacto do estado no Redis

Guardar uma chave por sessão no Redis tem custo: armazenamento e operações por refresh. Em sistemas com milhões de sessões, o footprint cresce. Recomendações:

  • Use TTLs curtos para chaves e estratégias de expiração automática.
  • Comprimir payloads (JSON minificado) e armazenar só o essencial: currentJti, revoked, userId.
  • Shard Redis ou usar clusters quando necessário.

Minimizando chamadas ao Redis

Algumas otimizações:

  • Cache local de leituras recentes (com invalidation baseada em pub/sub do Redis) para reduzir leituras em endpoints de alta taxa.
  • Combinar operações em scripts Lua para reduzir round trips.
  • Usar TTL no token e renovar apenas quando necessário para reduzir frequência de refreshes.

Também avalie a carga: se refreshes são raros e acessos são cobertos por access tokens, o custo do Redis será pequeno comparado aos benefícios de segurança.

Métricas e observabilidade

Monitore:

  • Taxa de refresh por sessão e por usuário
  • Incidentes de reuse detectados
  • Latência média das operações no Redis
  • Quantidade de sessions ativas por usuário
Medir é essencial: sem métricas você não saberá se as otimizações afetam segurança ou experiência.

Conclusao pratica

Resumo operativo

Implementar rotação segura de refresh tokens exige um pequeno estado por sessão e algumas garantias de atomicidade. O fluxo básico é:

  1. Gerar refresh token com jti e sessionId.
  2. Persistir sessionId -> currentJti no Redis com TTL.
  3. Ao receber refresh, validar token, comparar jti com currentJti, se coincidir rotacionar e atualizar currentJti atomically.
  4. Se não coincidir, detectar reuse e revogar sessão; notificar e auditar.

Boas praticas finais

Use HttpOnly cookies quando possível, minimize duração dos tokens, proteja endpoints com rate limits, registre eventos de segurança e implemente mecanismos atômicos no Redis (Lua scripts). Planeje como revogar sessões em massa se um breach afetar múltiplos usuários.

Seguindo esse padrão você reduz drasticamente a janela de risco quando refresh tokens são comprometidos e ganha mecanismos claros para detecção e resposta a incidentes.

Implementar corretamente exige disciplina: testes de integração que simulam retries, testes de carga para validar latência do Redis e auditoria de logs para detectar padrões anômalos. Faça isso antes de colocar em produção.

Se quiser, tenho exemplos de testes automatizados para esse fluxo e uma versão completa com scripts Lua preparados para produção.

Assuntos: jwt, refresh-tokens, Segurança, NoSQL, Node.js, arquitetura

Leia também