Ataques a JWT: Por Que Colocar o Refresh Token na Blacklist Não Desloga Ninguém

Ataques a JWT: Por Que Colocar o Refresh Token na Blacklist Não Desloga Ninguém

Django Security Series — Post 16 | Série III: Autenticação e Sessão
OWASP A07:2025 — Authentication Failures | Tempo de leitura: ~17 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um projeto com simplejwt cujo endpoint de logout coloca o refresh token na blacklist. Faça login, faça logout e mande o access token de novo: ele continua funcionando, até o exp. O laboratório mantém também duas views de exportação que um token forjado diferencia, como verificação de regressão.

Um JSON Web Token (JWT, definido na RFC 7519) é uma string compacta, segura para uso em URLs, que carrega um conjunto de afirmações sobre alguém, normalmente um usuário, junto com uma assinatura criptográfica sobre elas. Essas afirmações se chamam claims: quem é o usuário, quando o token foi emitido, quando ele expira. É a assinatura que faz as claims merecerem crédito. Ela é produzida com uma chave que só o emissor tem, e qualquer alteração nas claims a invalida.

Um JWT funciona como uma credencial. Depois que o usuário faz login, o servidor lhe entrega um token, e o cliente o envia de volta a cada requisição, normalmente num cabeçalho Authorization: Bearer. O servidor confere a assinatura e sabe quem está pedindo sem consultar nada: sem tabela de sessões, sem armazenamento compartilhado. Por isso o JWT virou a escolha habitual para APIs consumidas por aplicações de página única (SPAs, single-page applications) e aplicativos móveis, para serviços que precisam confiar num login feito por outro serviço e para login único (single sign-on), em que o OpenID Connect entrega a identidade do usuário num JWT chamado ID token. No mundo Django, ele chega quase sempre pelo Django REST Framework (DRF) e pelo djangorestframework-simplejwt.

Essa conveniência é também o motivo de ele importar tanto para a segurança. Um JWT válido é o usuário enquanto durar: quem tem um é tratado como seu dono, e quem consegue produzir um pode se passar por qualquer pessoa. O que é preciso para encerrá-lo antes da hora é a pergunta mais difícil.


O Ataque: O Que É e Como Funciona

Por dentro, um JWT é composto de três strings em base64url unidas por pontos: um cabeçalho, um payload e uma assinatura. O cabeçalho diz qual algoritmo assinou o token. O payload carrega as claims, as afirmações que o token faz: normalmente um id de usuário, um tipo de token, o momento de emissão (iat), a expiração (exp) e um identificador único (jti). A assinatura é calculada sobre as duas primeiras partes com uma chave que só o servidor deveria ter.

Todo o resto decorre de um fato: o servidor não precisa guardar registro dos tokens que emite; precisa guardar só a chave. Um cookie de sessão no Django, com o backend padrão em banco de dados, é uma chave aleatória que não significa nada até o servidor procurá-la numa tabela. Um JWT traz a própria prova: o servidor confere a assinatura sobre o cabeçalho e o payload com a sua chave e, se ela for válida, o payload é exatamente o que ele assinou. Ele confere o exp e acredita nas claims. Esse é todo o atrativo do design, já que não há consulta e qualquer serviço que tenha a chave consegue verificar. É também a origem do ataque abaixo: um token em que o servidor acredita à primeira vista é um token que o servidor não consegue tomar de volta.

O ataque que decorre disso nem passa pela assinatura. Um JWT é uma credencial ao portador: como dinheiro em espécie, pertence a quem o tem na mão, e o servidor não tem como perguntar se quem o apresenta é a pessoa para quem ele foi emitido. Roubar um token vale tanto quanto roubar a senha, enquanto o token durar. E tokens vazam por caminhos banais. As SPAs gostam de guardá-los no localStorage, o armazenamento do navegador que qualquer script da página consegue ler, então um único bug de XSS (Post 2) os entrega; ao contrário de um cookie de sessão marcado como HttpOnly, nada os esconde do JavaScript. Eles também acabam em logs do servidor, quando alguém registra os cabeçalhos das requisições, e no histórico do navegador e em logs de proxy, quando a aplicação os passa na URL.

É aí que o design sem estado cobra a conta. Um cookie de sessão é só um ponteiro para uma linha no servidor: apague a linha e o cookie roubado morre na próxima requisição. Um JWT não aponta para nada. Ele é conferido contra a chave e o exp, e nenhum dos dois muda quando o usuário faz logout, troca a senha ou tem a conta desativada. O token roubado continua funcionando até o exp, a menos que o servidor confira a cada requisição algo que ele guarda (uma lista de tokens revogados ou a própria linha do usuário), o que é voltar ao estado que se queria evitar ao escolher JWT.

É no logout que isso vira ataque. O usuário clica em "sair"; o cliente chama o endpoint de logout, que coloca o refresh token na blacklist, e depois apaga os dois tokens do próprio armazenamento. Tudo o que o usuário consegue ver diz que a sessão acabou. Mas um atacante que copiou o access token antes, do localStorage por um bug de XSS ou de uma linha de log, tem uma cópia que o cliente nunca poderia apagar, e o servidor nunca fica sabendo que deveria recusá-la. A próxima requisição com essa cópia é atendida como o usuário, e todas as seguintes também, até o exp. O refresh token morreu; o usuário não saiu.

A mesma confiança na assinatura abre outras portas. O payload é codificado, não criptografado, então quem tem um token consegue ler o que ele diz. Um servidor pode ser levado a aceitar um token que nunca assinou: com alg: none, o algoritmo dos tokens sem assinatura, ou com a confusão de algoritmo, em que um token marcado como HS256 é conferido com a chave pública RSA do servidor usada como segredo HMAC; Tim McLean mostrou os dois contra bibliotecas populares em 2015, e o PyJWT reabriu o segundo como CVE-2022-29217. Uma view que decodifica o token com a verificação da assinatura desligada (options={"verify_signature": False}) aceita qualquer payload que qualquer pessoa digitar. E quem obtém a chave de assinatura, quebrando offline um segredo HMAC fraco ou pegando uma SECRET_KEY vazada, a chave com que o simplejwt assina se ninguém disser outra coisa, fabrica tokens para qualquer usuário.


Incidentes Reais

File Browser — CVE-2025-53826, "Insecure JWT Handling Allows Session Replay Attacks after Logout" (GHSA-7xwp-2cpp-p8r7, publicado em 15 de julho de 2025; todas as versões até a 2.39.0, sem versão corrigida listada)

O File Browser é um gerenciador de arquivos web de código aberto, escrito em Go: ele serve um diretório pelo navegador, atrás de um login. O endpoint de login, /api/login, devolve um JWT, e toda chamada à API o carrega num cabeçalho X-Auth. O alerta, reportado por maen08, constatou que o logout não fazia nada no servidor: "this action does not invalidate the issued JWT", a ação não invalida o JWT emitido, então "any captured token can be replayed post-logout until it expires naturally", qualquer token capturado pode ser reenviado depois do logout até expirar sozinho. A prova de conceito: fazer login e guardar o token, sair pelo painel e chamar /api/resources com o token antigo. Sair não enviava nada ao servidor; o painel só apagava o token do navegador. O servidor não guardava estado de sessão nem lista de bloqueio: em http/auth.go, um token com assinatura válida e exp ainda no futuro era aceito, e comparar a hora em que foi emitido com a última atualização do registro do usuário só pedia ao cliente que o renovasse. O alerta tem classificação alta (CVSS 4.0, 7,7) e não lista versão corrigida.

O alerta não relata exploração; o valor dele é registrar o próprio design como a vulnerabilidade, sob a CWE-613 (Insufficient Session Expiration), entre outras. Em termos de ATT&CK, reenviar um token capturado é T1550.001 — Use Alternate Authentication Material: Application Access Token: o atacante nunca descobre a senha, e cada requisição fica registrada em nome do usuário. Para quem desenvolve em Django, é a view de logout do laboratório com menos ainda por trás: o laboratório ao menos aposenta o refresh token, e o File Browser não aposentava nada. De um jeito ou de outro, o access token nunca foi registrado em lugar nenhum, então só o exp o encerra. O CHECK_REVOKE_TOKEN do simplejwt vai além de tudo o que o File Browser tinha, já que recusa um token emitido antes da troca de senha, mas é uma verificação da conta, não do token, então também não consegue separar o token cujo usuário acabou de sair.

Fontes: File Browser — GHSA-7xwp-2cpp-p8r7: Insecure JWT Handling Allows Session Replay Attacks after Logout (15 de julho de 2025) · GitHub Advisory Database — CVE-2025-53826


Proteções Padrão do Django

O Django não traz suporte a JWT. O django.contrib.auth oferece sessões e para por aí. No mundo do Django REST Framework, a escolha consagrada é o djangorestframework-simplejwt, então "os padrões do Django" aqui significam, na verdade, os padrões do simplejwt, rodando sobre o PyJWT. Tudo o que vem abaixo foi conferido contra o simplejwt 5.5.1, o PyJWT 2.15.1 e o Django 5.2.

Os ataques famosos falham aqui. O simplejwt nunca deixa o token escolher como será conferido: toda decodificação passa algorithms=[self.algorithm], uma lista de um só, tirada das suas configurações, e none não está entre os algoritmos que ele permite. Por baixo, o PyJWT recusa por conta própria: com a lista de um só do simplejwt, um token alg: none não passa do cabeçalho (InvalidAlgorithmError), e mesmo com todos os algoritmos que suporta permitidos o PyJWT recusa uma chave pública RSA entregue como segredo HMAC (InvalidKeyError).

O simplejwt também verifica o exp com tolerância zero, vem com um access token de cinco minutos e um refresh token de um dia e recusa tokens de usuários com is_active falso. Como lista de padrões, é cuidadosa.

O que ele deixa por sua conta é mais curto e pesa mais. Abra rest_framework_simplejwt/settings.py e os padrões estão ali:

"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"ALGORITHM": "HS256",
"SIGNING_KEY": settings.SECRET_KEY,
"CHECK_REVOKE_TOKEN": False,

A SIGNING_KEY é a sua SECRET_KEY. Se você não a definir, a chave que assina todo token da API é a mesma que protege suas sessões e seus links de redefinição de senha (Post 14), então um vazamento dela permite ao atacante fabricar tokens da API para qualquer usuário. A gestão de segredos ganha um post próprio mais adiante na série.

A revogação é a última lacuna, e a menos óbvia. O simplejwt vem com um app de blacklist, e o nome promete mais do que entrega. Olhe as definições das classes: RefreshToken e SlidingToken herdam de BlacklistMixin, e AccessToken não. A blacklist consegue impedir que um refresh token gere novos access tokens. Não consegue barrar um access token que já foi emitido, e nada no simplejwt revoga um token isolado. O que o simplejwt consegue é recusar todos os tokens de um usuário: o JWTAuthentication carrega a linha do usuário a cada requisição e barra uma conta inativa (User is inactive), e o primeiro padrão abaixo acrescenta a troca de senha a essa lista. Para um único token roubado de uma conta que continua ativa, o único fim é o exp.


O Padrão Vulnerável e a Correção

O primeiro padrão é um bloco de configuração, e cada linha dele é um padrão ou uma conveniência que parece razoável. As linhas com - são o que um projeto assim publica; as linhas com + são a correção:

 SIMPLE_JWT = {
-    "ACCESS_TOKEN_LIFETIME": timedelta(days=7),    # um token roubado vale por uma semana
-    "REFRESH_TOKEN_LIFETIME": timedelta(days=90),  # um refresh roubado gera acesso por 90 dias
-    "ROTATE_REFRESH_TOKENS": False,            # padrão; o refresh é reutilizável até expirar
-    "CHECK_REVOKE_TOKEN": False,               # padrão; trocar a senha não revoga nenhum token
+    "ALGORITHM": "HS256",
+    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
+    "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
+    "ROTATE_REFRESH_TOKENS": True,
+    "BLACKLIST_AFTER_ROTATION": True,
+    "CHECK_REVOKE_TOKEN": True,
 }
+INSTALLED_APPS += ["rest_framework_simplejwt.token_blacklist"]   # depois rode o migrate

Cada linha removida é uma falha diferente, e cada uma decide quanto vale um token roubado. O access token de sete dias transforma um token roubado em uma semana de acesso. Sem rotação, um refresh token roubado continua gerando access tokens por noventa dias. E com o CHECK_REVOKE_TOKEN desligado, o usuário que troca a senha por achar que foi comprometido não mudou nada, até onde a API sabe. Testei esse também: um access token emitido antes da troca de senha continuou autenticando normalmente depois dela.

Rotação mais blacklist significa que cada refresh token funciona uma única vez. A rigor, uma vez de cada vez: a consulta à blacklist e a inclusão nela são consultas separadas, então dois refreshes que chegam juntos podem passar os dois pela verificação e receber, cada um, um par novo. Isso encurta um roubo sem encerrá-lo: se o atacante fizer o refresh primeiro, recebe um par novo, e quem vê o próximo refresh falhar é o usuário legítimo. O simplejwt não percebe o replay nem revoga a cadeia, então esse refresh que falhou é, na melhor das hipóteses, um sinal que alguém precisa estar monitorando. O CHECK_REVOKE_TOKEN grava em cada token um valor derivado do hash da senha do usuário e o compara a cada requisição, de modo que uma troca de senha derruba na hora todos os access tokens em circulação.

Duas coisas sobre essa última configuração para as quais a documentação não me preparou. Ligá-la num sistema já em produção recusa todo token emitido antes da mudança, já que eles não têm a claim, e cada usuário recebe a mensagem "The user's password has been changed.", que é falsa e vai gerar os chamados de suporte que você imagina. Planeje como um logout forçado. E o valor gravado é um MD5 do hash da senha armazenado, com salt e tudo, num payload que qualquer pessoa com o token consegue decodificar em base64. Eu me convenci de que isso não ajuda um atacante a quebrar nada: é o hash de um hash longo e com salt, e o salt nunca sai do servidor. Mas não fico confortável colocando um valor derivado da senha num token legível, e não encontrei nenhum texto que resolva a questão para um lado ou para o outro.

O segundo padrão é o que eu mesmo teria escrito: o logout da seção sobre o ataque, em código. Ele aposenta o refresh token, e só o cliente joga fora o access token:

# INSEGURO — desloga o refresh token, não o usuário
class LogoutView(APIView):
    authentication_classes = [JWTAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request):
        RefreshToken(request.data["refresh"]).blacklist()
        return Response(status=205)

A resposta é um 205 Reset Content bem-comportado. O mesmo access token que autenticou a requisição de logout autentica a próxima também, e segue assim até o seu exp. Com o padrão de cinco minutos do simplejwt, essa janela é pequena. Com os sete dias do primeiro padrão, o logout é puro teatro.

Este não tem correção para mostrar, e preciso ser franco: não existe configuração para ele. Um access token de cinco minutos é uma janela de cinco minutos, e zerá-la de verdade exige conferir o jti de cada access token contra uma lista de bloqueio a cada requisição. Isso é uma consulta ao banco ou ao cache por requisição, ou seja, uma tabela de sessões. (O JWTAuthentication já faz uma, a da linha do usuário, então a ausência de estado já tinha ido embora em parte antes de você começar.) Ainda não me decidi sobre o que isso implica. Se a sua API é consumida por um navegador no seu próprio domínio, a sessão comum do Django, guardada no banco, atrás de um cookie HttpOnly por padrão e fora do alcance dos scripts que roubariam um token do localStorage, já faz tudo o que esta seção está se esforçando para fazer, e desconfio que muitos projetos Django usam JWT porque um tutorial usava, não porque um segundo serviço precisava verificar um token. Para os projetos que precisam mesmo de JWT, a posição defensável é um access token de vida curta, com rotação e blacklist, mais uma aceitação por escrito da janela que sobra.


A Visão do Analista

Suponha que o achado chegue assim: "o access token de um usuário foi colado num chamado de suporte", ou um usuário informa que teve roubado um notebook com o aplicativo ainda logado. O ciclo de resposta a incidentes do CS0-003 pede contenção em seguida, e com JWT o movimento habitual de contenção, encerrar a sessão, não existe para o access token. Fazer logout do usuário aposenta o refresh token e mais nada. O que encerra todos os tokens do usuário é uma mudança que o simplejwt confere a cada requisição: desativar a conta ou, com o CHECK_REVOKE_TOKEN ligado, redefinir a senha. É uma ação mais pesada do que um único token vazado merece, e é a menor disponível; a alternativa é esperar o exp do access token. É por isso que o tempo de vida do access token é uma decisão de contenção, tomada com antecedência, no settings.py.

A detecção é a metade mais difícil. Um token reenviado é aceito como o usuário a quem pertence, então os logs registram esse usuário fazendo requisições comuns, e o simplejwt não guarda registro nenhum de access tokens: só os refresh tokens (e os sliding tokens, que esta configuração não usa) chegam à tabela OutstandingToken. Os sinais que sobram têm de vir do seu próprio log de requisições, indexado pelo jti do token: o mesmo jti chegando de dois endereços ou user agents ao mesmo tempo, ou uma requisição com um access token emitido antes do último logout daquele usuário. Nenhum dos dois prova roubo, já que um usuário com dois dispositivos e um proxy que troca de endereço têm a mesma cara, mas os dois são baratos de registrar e impossíveis de reconstruir depois.


Detectando Automaticamente

Testando Sua Defesa

Testes que mandam um token alg: none, um token com a chave errada ou um token expirado passam numa instalação padrão do simplejwt, mesmo com as configurações inseguras acima: eles testam a biblioteca, que nunca foi o problema. O laboratório os mantém como verificações de regressão, apontados para as suas duas views de exportação. Os testes que distinguem são os que verificam a sua configuração, e este é o primeiro a escrever:

def test_password_change_revokes_access_tokens(self):
    access = AccessToken.for_user(self.alice)
    self.alice.set_password("a new passphrase, long enough")
    self.alice.save()
    response = self.client.get(SECURE, HTTP_AUTHORIZATION=f"Bearer {access}")
    self.assertEqual(response.status_code, 401)

Ele falha numa instalação padrão e passa assim que o CHECK_REVOKE_TOKEN é ligado (SECURE é a URL da view de exportação segura do laboratório, e self.alice vem do seed dele). Uma asserção de uma linha cabe ao lado dele, e o tests.py do laboratório a traz: o api_settings.ACCESS_TOKEN_LIFETIME é de no máximo cinco minutos, que o padrão cumpre e os sete dias do primeiro padrão não. O mesmo arquivo fixa os padrões do simplejwt citados neste post, para que uma versão que os mude falhe ali primeiro.

O último teste que escrevi falha em qualquer configuração do simplejwt: fazer logout, mandar de novo o access token que fez a requisição de logout e esperar um 401. Com as configurações seguras acima, ele é o único que continua falhando, e vai continuar assim até você construir uma lista de bloqueio para access tokens. Quase o apaguei. Mantive, marcado com expectedFailure, porque um teste que falha, com um comentário honesto, é a forma mais precisa que encontrei de dizer o que este projeto aceita, e a próxima pessoa que mexer na view de logout deveria ser obrigada a lê-lo. O laboratório traz esse teste, com o decorator e o comentário.

Fazendo a Varredura

Os scanners não têm nada a dizer sobre a view de logout. Ela coloca um refresh token na blacklist, que é o que um logout correto também faz; a lacuna é uma chamada que não tem como existir. (Eles pegam o outro par do laboratório: os pacotes curados do Semgrep sinalizam a view que decodifica com o verify_signature desligado e ficam em silêncio na correção, e o CI do repositório verifica essa separação.) O que decide quanto vale um token roubado é o bloco SIMPLE_JWT, e ali os scanners se esgotam. O Semgrep, com todos os pacotes de uma vez:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten \
             --config r/python.django --config r/python config/settings.py
# Ran 377 rules on 1 file: 0 findings.

Zero, e não porque o bloco esteja correto: nenhum desses cinco pacotes tem uma definição de regra que mencione SIMPLE_JWT, simplejwt ou CHECK_REVOKE_TOKEN, e as notas de varredura do laboratório trazem o laço que faz essa busca. O Bandit lê o arquivo, e o lê errado:

bandit config/settings.py
# >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'django-security-lab-not-a-secret-never-deploy-this'
#    Location: config/settings.py:20:13
# >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'True'
#    Location: config/settings.py:115:4

A primeira é a SECRET_KEY commitada do laboratório, um achado justo e sem relação com o logout. A segunda é a linha 115, "CHECK_REVOKE_TOKEN": True, a configuração do primeiro padrão que encerra todo access token numa troca de senha, reportada como senha vazada. O B105 compara a chave do dicionário com uma lista de palavras parecidas com "password", e token está nela, desde que nada venha depois: ROTATE_REFRESH_TOKENS escapa pelo S final. Contra as views do laboratório o Bandit não diz nada; a versão 1.9.4 não tem nenhuma verificação que mencione JWT.

Os scanners deixam este ataque para os testes. As configurações que decidem quanto vale um token roubado (o tempo de vida, se a rotação o aposenta, se uma troca de senha o encerra) são invisíveis para uma ferramenta e mal lidas pela outra, e a lacuna do logout não tem linha para apontar.


O hábito a levar deste post é parar de perguntar se os seus tokens podem ser forjados e começar a perguntar quanto eles valem depois que alguém tem um: quanto tempo vivem e o que pode encerrá-los antes da hora. Essas respostas estão no seu arquivo de configurações e na sua view de logout, não na biblioteca. O Post 17 fecha a Série III com OAuth2, em que o ID token que a sua aplicação Django recebe pelo OpenID Connect é um JWT assinado por terceiros, e cada pergunta deste post precisa ser feita sobre uma chave que não está com você.

Leitura Complementar

← Voltar para a série