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:
- JWT access token: curto prazo (ex: 5-15 minutos), assinado e stateless.
- JWT refresh token: mais longo (ex: 7-30 dias), contém um identificador único (jti) e um session id.
- Store de sessão (Redis ou banco relacional) que persiste o estado do refresh token: jti atual, validade, e flag usado.
- 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 é:
- Gerar refresh token com jti e sessionId.
- Persistir sessionId -> currentJti no Redis com TTL.
- Ao receber refresh, validar token, comparar jti com currentJti, se coincidir rotacionar e atualizar currentJti atomically.
- 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
Webhooks idempotentes em Node/Express: deduplicação, verificação e retries
Guia prático para implementar webhooks idempotentes em Node/Express: validação HMAC sobre raw body, deduplicação com Redis, persistência atômica e retries com…
Worker Threads: sandbox seguro para executar código de usuário no Node.js
Implementar sandbox seguro para executar código de usuário em Node.js: use worker threads + vm, limite memória com execArgv, aplique timeout externo, evite…
Abortar pipelines de Streams em Node.js: AbortController, timeouts e limpeza de Workers
Guia prático para abortar pipelines de streams em Node.js: uso de AbortController, patterns de timeout, e limpeza segura de Worker Threads para evitar…
Autenticação com Passkeys (WebAuthn) em SPA e API: arquitetura e fallback JWT
Arquitetura prática para usar Passkeys/WebAuthn em SPAs com API: pattern attempt-first WebAuthn com fallback JWT seguro. Padrões, exemplos de código, testes…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…
Diagnóstico de latência em APIs Node.js com async_hooks, event-loop e GC
Diagnostique latência em APIs Node.js combinando async_hooks/AsyncLocalStorage, monitorEventLoopDelay e análise de GC. Passo a passo prático: medir…