Para detectar o reuso, o servidor precisa lembrar qual token já foi trocado. Cada login abre uma família de tokens, cada renovação troca o identificador do token dentro dela, e quando um identificador já aposentado aparece de novo a família inteira é derrubada. Só validar a assinatura e uma versão global não basta, porque uma cópia do token continua valendo até vencer. O difícil não é a ideia, é fazer isso sem deslogar quem não fez nada, e foi aí que eu errei em produção. Conferi as fontes em 10 de outubro de 2026.
O que o padrão diz
Primeiro o vocabulário. O refresh token é a credencial de longa duração que o aplicativo apresenta para receber um token de acesso novo, sem pedir senha de novo. Se alguém copia esse token, consegue gerar acessos por semanas.
O OAuth 2.0 Security Best Current Practice (RFC 9700) manda que refresh tokens de clientes públicos sejam amarrados ao remetente (sender-constrained) ou usem rotação. Na rotação, o servidor emite um refresh token novo a cada renovação e invalida o anterior, mas guarda a relação entre eles. Se o atacante e o cliente legítimo usarem o mesmo token roubado, um dos dois apresentará um token já invalidado. O texto diz que o servidor não consegue saber qual dos dois é o atacante e por isso revoga o refresh token ativo. O custo é que o cliente legítimo precisa obter uma nova autorização.
A RFC 6749 (OAuth 2.0 base), na seção 6, diz que o servidor pode emitir um refresh token novo e que, nesse caso, o cliente deve descartar o antigo.
O que vi em produção
Num projeto em que trabalhei, o token era "rotativo" só no nome. A validação checava a assinatura e um número de versão do usuário. Emitir um par novo não derrubava o anterior, então uma cópia (de backup, de aparelho comprometido, de log) seguia servindo por 30 dias, o prazo de validade, mesmo depois de a vítima já ter renovado o dela. O número de versão resolvia só no atacado, derrubando todos os aparelhos.
O desenho que funcionou:
- cada login cria uma família, identificada por um
sid; - cada renovação troca o
jti, o identificador do token dentro da família; - um
jtijá aposentado sendo reapresentado derruba aquela família, e só ela, para não afetar os outros aparelhos do mesmo usuário.
Implementar isso deslogou gente de verdade. Foram cinco modos de falha, todos atingindo quem não fez nada. Os três primeiros, os testes pegaram. Os dois últimos escaparam e apareceram em produção, entre 1 e 2 de agosto de 2026.
- Gravar a família sem esperar a gravação terminar. O login respondia antes de a família existir, a requisição seguinte lia "família ausente" e a detecção falhava, de forma intermitente.
- Tolerar a ausência da família. Tratar "família não encontrada" como token antigo, aceitar e adotar, anula o logout e a detecção, que funcionam justamente apagando a família. Ausência só é benigna quando o token nem tem
sid. - A corrida honesta que parece ataque. Duas renovações quase simultâneas: a segunda chega com o
jtique acabou de ser aposentado. - Consertar o item 2 armou o item 4. A gravação era feita em dois passos (remover o
jtiantigo e depois inserir o novo), e entre eles a família não existia. Isso era inofensivo enquanto ausência fosse tolerada. Quando ausência passou a valer 401, virou logout involuntário. O comentário que justificava a janela continuou no código, descrevendo o mundo antigo, e ninguém o releu. O conserto foi uma operação atômica, sem instante de ausência. - Uma janela de tolerância de um slot só cobre duas renovações. Guardar só o
jtianterior derruba o terceiro refresh simultâneo: a família já rotacionou duas vezes quando ele chega. Como reuso encerra a família, todas as abas caem juntas. O front chamando a renovação ao abrir a página tornou três ou mais chamadas simultâneas rotina. O conserto foi guardar uma lista dejtiaposentados com horário, e decidir por tempo, não por posição.
Um detalhe que quase passou: em uma das bibliotecas de banco que usei, o update em forma de lista de operações exige uma opção explícita, senão a chamada lança erro. Como essa gravação ficava dentro de um try/catch que protege o login, o erro virava só um aviso no log, e toda sessão nascia sem família. Na primeira versão do conserto, 12 de 12 renovações voltaram 401. Só não subiu porque o teste da rajada existia.
Por que acontece
A defesa mexe no estado compartilhado entre várias requisições que chegam ao mesmo tempo. Qualquer lacuna de tempo entre "apaguei" e "gravei", ou qualquer tolerância pequena demais, é lida pelo servidor como ataque. Quem paga é o usuário legítimo, e o sintoma é "o site me desloga sozinho", que ninguém liga a um commit de segurança.
O que fazer
- Escreva primeiro os testes dos inocentes: vários aparelhos do mesmo usuário, token emitido antes da mudança e uma rajada de três ou mais renovações simultâneas no mesmo cookie. Só depois escreva o teste do ataque.
- Aceite e adote os tokens emitidos antes do deploy, que não têm
sidnemjti. Sem isso, subir a versão desloga a base inteira. - Grave a família em uma operação atômica e espere ela terminar antes de responder o login.
- Ao encerrar sessão pelo token, verifique a assinatura. Ler o
sidde um token apenas decodificado permite a qualquer pessoa forjar um token e encerrar a sessão de outra, e o logout vira ataque de negação de serviço. - Ao mudar um caminho do código, releia os comentários que justificavam os vizinhos.
Para detalhes de como eu monto autenticação e sessão em sistemas web, veja sistemas e SaaS.