Armadilhas de segurança em JWT e sessões, e como evitá-las
Os erros de JWT e sessão que levam a tomada de conta: confusão de algoritmo, segredos fracos, claims não verificadas, armazenamento, rotação e logout.
· 7 min de leitura · Lina Source LLC
JSON Web Tokens são um formato razoável com uma longa lista de arestas afiadas. O token em si raramente é o problema. As falhas moram em como ele é verificado, onde é guardado, quanto tempo vive e o que acontece quando o usuário sai. Cada um desses erros tem o mesmo desfecho: alguém tem em mãos um token que não deveria ter, e o seu servidor o aceita.
As duas fraquezas que mais aparecem são a CWE-347, verificação inadequada de assinatura criptográfica, e a CWE-613, expiração de sessão insuficiente. Os exemplos abaixo usam a jose, uma biblioteca JavaScript amplamente usada para JWTs que roda em Node.js, runtimes de edge e navegadores.
Decodificar não é verificar
A forma mais direta da CWE-347 é ler claims de um token sem checar a assinatura. Toda biblioteca de JWT tem uma função de decode para depuração, e ela aparece em middleware de autenticação com mais frequência do que deveria. Um token decodificado é só base64 que qualquer um consegue escrever. Em bases JavaScript, o padrão muitas vezes se esconde num pequeno helper que divide o token pelos pontos e roda JSON.parse na parte do meio. Funciona em todos os testes, porque os tokens de teste são válidos, e aceita todo token forjado em produção.
import { decodeJwt, jwtVerify } from 'jose';
// Vulnerável: qualquer um pode emitir um token com sub apontando para outro usuário
const claims = decodeJwt(token);
const userId = claims.sub;
// Correto: a assinatura, o algoritmo e as claims são verificados primeiro
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none e confusão de algoritmo
O cabeçalho do JWT diz qual algoritmo assinou o token, e o atacante controla o cabeçalho. Dois ataques clássicos decorrem de confiar nele. O primeiro é o alg definido como none: um token sem assinatura que algumas bibliotecas antigas aceitavam como válido. O segundo é a confusão de algoritmo: um servidor que espera RS256 mas deixa o cabeçalho escolher o algoritmo pode receber um token HS256 assinado com a chave pública do servidor usada como segredo HMAC. A chave pública é pública, então o atacante pode assinar o que quiser.
Bibliotecas modernas se defendem dos dois, e a jose rejeita tokens sem assinatura em jwtVerify e confere se o tipo da chave casa com o algoritmo. Não confie apenas nos padrões. Fixe a lista de algoritmos explicitamente em toda chamada de verificação, para que uma refatoração futura ou uma troca de biblioteca não a alargue silenciosamente. Se você aceita mais de um algoritmo, por exemplo durante uma migração de chaves, liste ambos explicitamente e use uma chave separada para cada. Ao rotacionar chaves, coloque um kid no cabeçalho e selecione a chave por kid a partir da sua própria lista, nunca a partir de uma URL indicada no cabeçalho do token.
Segredos de assinatura fracos
Um token HS256 é assinado com um segredo compartilhado. Se o segredo é curto ou adivinhável, como “secret”, o nome da aplicação ou um valor copiado de um tutorial, um atacante que tenha um único token válido consegue quebrá-lo por força bruta offline com ferramentas comuns e então assinar tokens para qualquer usuário. Não existe rate limit em tentativas offline.
- Use pelo menos 32 bytes aleatórios para HS256, gerados com algo como openssl rand -base64 32.
- Carregue o segredo do ambiente ou de um gerenciador de segredos, e falhe na inicialização se ele estiver ausente ou curto.
- Nunca o commite, e rotacione-o se ele algum dia foi commitado.
- Se vários serviços precisam verificar tokens mas apenas um deve emiti-los, use um algoritmo assimétrico como RS256 ou EdDSA, para que os verificadores tenham só a chave pública.
Valide exp, aud e iss
Uma assinatura válida só prova quem emitiu o token. As claims decidem se ele é destinado a você e se ainda vale. Um token sem expiração vale para sempre. Um token emitido para a sua API mobile não deveria ser aceito pelo seu serviço administrativo só porque ambos confiam no mesmo provedor de identidade. É para isso que servem aud e iss.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}A jose verifica exp sempre que ela está presente, mas um token sem exp passaria assim mesmo. A opção requiredClaims fecha essa lacuna. Para tokens vindos de um provedor de identidade externo, verifique contra o conjunto de chaves publicado por ele com createRemoteJWKSet e ainda assim fixe algoritmos, issuer e audience.
localStorage ou cookies httpOnly
Guardar um token no localStorage o torna legível por qualquer script da página. Um bug de XSS, ou um script de terceiro comprometido, e o token pode ser enviado a um atacante e usado de qualquer lugar até expirar.
Um cookie httpOnly não pode ser lido pelo JavaScript. XSS continua sendo sério, porque o script injetado consegue fazer requisições como o usuário enquanto a página está aberta, mas não consegue roubar uma credencial de vida longa e reusá-la depois. Para aplicações de navegador que falam com o próprio backend, cookies são o melhor padrão. Para uma SPA chamando uma API separada, manter o token de acesso só em memória e o token de refresh num cookie httpOnly é um meio-termo razoável.
// Express: session cookie with safe attributes
res.cookie('__Host-session', accessToken, {
httpOnly: true, // não legível pelo JavaScript
secure: true, // só HTTPS; exigido pelo prefixo __Host-
sameSite: 'lax', // não enviado em POSTs cross-site
path: '/', // exigido pelo prefixo __Host-
maxAge: 10 * 60 * 1000, // milissegundos, igual à vida do token
});O prefixo __Host- diz ao navegador para rejeitar o cookie a menos que ele seja Secure, tenha path igual a / e não tenha atributo Domain, o que impede um subdomínio comprometido de sobrescrevê-lo.
SameSite e o que ele não cobre
O SameSite=Lax bloqueia o cookie em requisições POST cross-site e em carregamentos de sub-recursos, o que elimina a maior parte do CSRF clássico. Ele ainda envia o cookie em navegações GET de nível superior, então qualquer endpoint GET que altere estado fica exposto. O Strict bloqueia essas também, mas desloga as pessoas quando elas seguem um link para a sua aplicação a partir de um e-mail ou de outro site. O None desativa a proteção e exige Secure.
Lax somado a uma regra de que requisições GET nunca alteram estado é uma base sólida. Para mutações sensíveis, acrescente uma verificação do cabeçalho Origin ou um token CSRF. Lembre que o SameSite trata todos os subdomínios do seu domínio registrável como mesmo site, então um subdomínio vulnerável ainda consegue forjar requisições.
Rotação, revogação e logout de verdade
Um JWT sem estado não pode ser revogado antes de expirar; esse é o preço de não consultar o banco a cada requisição. A CWE-613 descreve o que acontece quando esse preço é ignorado: logouts, trocas de senha e suspensões de conta que não encerram o acesso de fato. Apagar uma conta, mudar um papel e remover alguém de um time também são eventos de revogação, e cada um deveria valer já na requisição seguinte, não na próxima expiração de token.
- Mantenha tokens de acesso de vida curta, na faixa de minutos, não de dias.
- Guarde os tokens de refresh no servidor, com hash, e rotacione-os a cada uso.
- Se um token de refresh que já foi rotacionado for usado de novo, trate como roubo e revogue toda a família de tokens.
- Adicione uma versão de token à linha do usuário e ao token; incremente-a no logout em todos os dispositivos, na troca de senha ou na suspensão.
- Regenere o identificador de sessão no login, para evitar fixação de sessão.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// Uma versão incrementada ou uma conta desativada encerram o acesso na hora
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}Essa busca traz de volta uma leitura de banco por requisição, que é o custo honesto da revogação. Muitas aplicações ficam mais simples e mais seguras com um ID de sessão opaco num cookie e uma tabela de sessões. O logout então vira apagar uma linha. Use JWTs onde as propriedades deles realmente ajudam, como tokens de vida curta entre serviços, e não porque são o padrão de um tutorial.
Seja qual for a escolha, o logout precisa acontecer no servidor. Limpar o cookie ou apagar o token no navegador só remove a cópia do usuário; uma cópia roubada continua funcionando até o servidor recusá-la. No logout, apague a sessão ou o token de refresh no servidor, limpe o cookie usando o mesmo nome, path e atributos com que ele foi criado, e incremente a versão do token se a pessoa escolheu sair de todos os dispositivos. Uma redefinição de senha também deve encerrar todas as outras sessões.
Um checklist curto de revisão
- Procure chamadas de decode nos caminhos de autenticação.
- Confirme que toda chamada de verificação fixa algoritmos, issuer e audience, e exige exp.
- Verifique como o segredo de assinatura é gerado, carregado e validado na inicialização.
- Descubra onde os tokens são guardados no navegador.
- Teste se logout, troca de senha e suspensão de conta encerram as sessões existentes.
Essas verificações consistem principalmente em ler caminhos de código de ponta a ponta, que é também como o CodeAuditAgent aborda uma auditoria: os achados vêm com a linha citada, a CWE, um cenário de exploração e uma correção, então uma verificação de audience ausente pode ser confirmada e corrigida em minutos.