Blog

JWT: os erros que aparecem em quase todo projeto.

JWT resolve um problema real: verificar identidade sem consultar o banco a cada requisição. É útil, é padrão e é usado errado com uma consistência impressionante.

A lista abaixo é a que eu encontro com mais frequência, em ordem aproximada de gravidade.

1. Aceitar o algoritmo que o token declara

O cabeçalho do JWT diz qual algoritmo foi usado. Se a sua biblioteca confia nesse campo, quem envia o token escolhe como ele é verificado.

O ataque clássico é trocar para alg: none e mandar um token sem assinatura. A variante mais interessante é trocar RS256 por HS256: o servidor passa a usar a chave pública, que é pública, como segredo de HMAC. Quem tem a chave pública forja qualquer token.

// perigoso: aceita o que vier
jwt.verify(token, key);

// correto: você decide, não o token
jwt.verify(token, key, { algorithms: ["RS256"] });

Biblioteca moderna já exige a lista. Se a sua não exige, ela é antiga o bastante pra ter outros problemas.

2. Não conseguir revogar

Essa é conceitual, não é bug de código, e é a que mais dói.

JWT é validado por assinatura, sem consulta ao banco. Isso é o ponto inteiro dele. E significa que um token emitido é válido até expirar, aconteça o que acontecer. Usuário demitido, senha trocada, permissão revogada, conta suspensa: o token continua funcionando.

Não tem solução limpa. Tem escolhas.

Access token curto, de cinco a quinze minutos, e refresh token que é consultado no banco. A revogação leva até quinze minutos pra valer, o que é aceitável na maioria dos produtos.

Lista de revogação em cache, checada a cada requisição. Funciona e devolve a consulta que o JWT existia pra evitar.

Ou sessão em banco, e JWT nenhum. Impopular e frequentemente a resposta certa. Se o seu sistema tem um banco a um milissegundo de distância e não precisa de autenticação distribuída entre serviços de times diferentes, JWT está resolvendo um problema que você não tem.

3. Expiração longa demais

Token de sete dias porque “senão o usuário reclama de deslogar”.

O problema é o de cima elevado ao cubo: uma semana de janela em que um token roubado funciona sem nenhum recurso pra você.

Access de quinze minutos, refresh de duas semanas com rotação. O usuário não percebe nada, porque a renovação é automática, e a janela de exposição do token que trafega em toda requisição cai de dias pra minutos.

4. Refresh token sem rotação nem detecção de reuso

Refresh token que pode ser usado várias vezes é uma senha de longa duração com outro nome.

O padrão correto: cada uso queima o refresh token e emite um novo. E se um token já queimado aparecer de novo, isso é evidência de roubo, porque o usuário legítimo e o atacante não podem ambos ter o mais recente.

async function refresh(presented: string) {
  const record = await tokens.findByHash(sha256(presented));
  if (!record) throw new UnauthorizedException();

  if (record.usedAt) {
    // alguém está usando um token antigo. derruba a família inteira.
    await tokens.revokeFamily(record.familyId);
    await alerts.suspiciousRefreshReuse(record.userId);
    throw new UnauthorizedException();
  }

  await tokens.markUsed(record.id);
  return issuePair(record.userId, record.familyId);
}

Guarde o hash do refresh token, não o valor. Se o banco vazar, os tokens não são utilizáveis.

5. Guardar em localStorage

localStorage é acessível por qualquer JavaScript da página. Um XSS, uma dependência comprometida, um script de terceiro, e o token vai embora.

Cookie httpOnly, secure, SameSite=Lax é a opção mais segura, ao custo de você ter que lidar com CSRF, que é um problema mais fácil e mais conhecido.

Se o localStorage for inevitável por causa da arquitetura do frontend, então o access token precisa ser curto de verdade, e você compensa com defesa em profundidade contra XSS. É um trade-off legítimo, desde que seja escolha e não desconhecimento.

6. Colocar dado demais no payload

JWT não é criptografado. É assinado e codificado em Base64. Qualquer pessoa lê o conteúdo colando num site.

{
  "sub": "...",
  "email": "cliente@empresa.com",
  "cpf": "12345678900",
  "salary_band": "C3",
  "internal_notes": "..."
}

Isso está visível pra quem tiver o token, incluindo qualquer proxy no caminho e qualquer log que grave o cabeçalho.

Payload mínimo: identificador do usuário, expiração, emissor, público, e no máximo os papéis. Detalhe você busca. Se buscar detalhe é caro, cache, não empurre pro token.

Payload gordo também tem custo prático: o token vai em toda requisição, e um JWT de 4KB é 4KB por chamada.

7. Não validar emissor e público

Se você aceita qualquer token assinado com a chave certa, um token emitido pra outro serviço do mesmo ecossistema serve no seu.

jwt.verify(token, key, {
  algorithms: ["RS256"],
  issuer: "https://auth.exemplo.com",
  audience: "api-principal",
  clockTolerance: 5,
});

clockTolerance pequeno cobre a diferença de relógio entre máquinas, que existe e causa falha intermitente muito chata de diagnosticar.

O resumo

JWT prova que o token foi emitido por quem tem a chave e que não foi alterado. Não prova que ele ainda deveria valer, e não esconde nada.

Se você precisa revogar rápido, precisa de estado em algum lugar. Aceitar isso desde o começo evita a descoberta desconfortável no meio de um incidente.

Sobre o que vem depois de autenticar, escrevi em autenticação e autorização.

← Voltar pro blog