JWT em Node.js: como funciona, 5 erros que comprometem sua API e refresh token com rotação
← Voltar para Codeshort

JWT em Node.js: como funciona, 5 erros que comprometem sua API e refresh token com rotação

JWT é fácil de implementar e mais fácil ainda de implementar errado. Estrutura, erros reais, armazenamento seguro e refresh token com rotação — do jeito que funciona em produção.

DC
Dev Code Software
15 de maio de 2026·10 min de leitura

Um dev mandou para review um PR com CPF e hash de senha no payload do JWT. Ele achava que Base64 era criptografia. O revisor rejeitou, abriu um card de urgência e passou a tarde explicando o problema para o time. Esse erro específico não é raro — e não é nem o mais grave que aparece em implementações de JWT por aí.

Se você usa JWT em produção ou está prestes a usar, este post cobre o que realmente importa: a estrutura, o fluxo correto, os erros que comprometem tudo e como implementar refresh token com rotação de verdade.

JWT vs Session: a escolha que define sua arquitetura

Antes de qualquer código, a pergunta certa é: você precisa de JWT ou de sessão server-side?

CritérioJWT (stateless)Session + Redis (stateful)
Revogação imediata❌ Não nativa✓ Simples
Escalabilidade horizontal✓ Sem compartilhar estado⚠️ Exige Redis compartilhado
Microsserviços / múltiplos serviços✓ Token carrega o contexto❌ Sessão precisa ser acessível
Logout instantâneo❌ Só com blocklist✓ Destroi a sessão
Complexidade de implementaçãoMédiaBaixa

JWT resolve bem comunicação entre serviços, APIs consumidas por mobile e arquiteturas onde você não quer depender de estado compartilhado. Session com Redis é mais simples quando você tem uma aplicação web tradicional que precisa de controle granular — logout em todos os dispositivos, banimento imediato, troca de permissão que vale na próxima requisição.

Escolher JWT porque "é moderno" sem considerar a necessidade de revogação é o começo de vários problemas.

A estrutura real do token

JWT não é criptografia. É um mecanismo de assinatura. O conteúdo é codificado em Base64URL — qualquer pessoa com o token consegue ler o payload. A segurança está na integridade da assinatura, não no sigilo dos dados.

Um token tem três partes separadas por ponto:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzAxIn0.SflKxwRJSMeKKF2Qt4fwpM
       HEADER                   PAYLOAD                  SIGNATURE

Header — declara o algoritmo de assinatura:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload — as claims: dados que você quer transmitir:

{
  "sub": "user_01HXYZ",
  "role": "admin",
  "plan": "pro",
  "iat": 1747267200,
  "exp": 1747270800
}

Signature — o que garante que nada foi alterado:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  JWT_SECRET
)

Se alguém alterar qualquer byte do payload e reenviar o token, a assinatura não bate. O servidor rejeita. Se o segredo vazar, qualquer pessoa pode forjar tokens válidos — por isso o segredo é tão crítico quanto a implementação.

💡 Dica: iat (issued at), exp (expiration), sub (subject) e jti (JWT ID) são claims registradas pela RFC 7519. Use-as em vez de criar campos customizados com o mesmo propósito — toda biblioteca JWT sabe interpretar.

O fluxo completo com código

O ciclo básico de autenticação:

[cliente]  POST /auth/login  →  [servidor valida credenciais]
                             ←  access_token (15min) + refresh_token (7d)
[cliente]  GET /api/dados    →  Authorization: Bearer <access_token>
                             ←  200 OK (servidor verificou assinatura localmente)
[expirou]  POST /auth/refresh →  refresh_token no body ou cookie
                             ←  novo access_token + novo refresh_token

Implementação do middleware de autenticação em TypeScript:

import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";

function generateAccessToken(userId: string, role: string): string {
  return jwt.sign(
    { sub: userId, role },
    process.env.JWT_SECRET!,
    { expiresIn: "15m", algorithm: "HS256" }
  );
}

function authenticate(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization;

  if (!auth?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Token não fornecido" });
  }

  try {
    const token = auth.split(" ")[1];
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"],
    });
    req.user = payload as TokenPayload;
    next();
  } catch {
    return res.status(401).json({ error: "Token inválido ou expirado" });
  }
}

Note o algorithms: ["HS256"] na verificação. Não é detalhe — é o que previne um vetor de ataque real.

5 erros que comprometem sua API

1. Dados sensíveis no payload

const token = jwt.sign(
  {
    sub: user.id,
    password_hash: user.password,
    cpf: user.cpf,
    credit_card: user.card_number,
  },
  SECRET
);

Quem tiver esse token lê tudo isso. Sem precisar quebrar nada. Base64 decodifica em milissegundo. O payload deve carregar apenas o que é necessário para autorização — sub, role, plan. Se precisar de mais dados do usuário, busque no banco usando o sub.

2. Aceitar o algoritmo none

A vulnerabilidade mais conhecida do ecossistema JWT: algumas bibliotecas antigas aceitam alg: "none" no header, o que elimina a verificação de assinatura completamente. Um atacante edita o payload, declara none e o servidor aceita.

jwt.verify(token, secret);

jwt.verify(token, secret, { algorithms: ["HS256"] });

A segunda linha garante que o servidor rejeita qualquer token com algoritmo diferente do esperado.

3. Segredo fraco ou versionado no repositório

JWT_SECRET=secret
JWT_SECRET=minha-api-2024
JWT_SECRET=abc123

Segredo curto pode ser quebrado por força bruta offline. Segredo no repositório significa que qualquer pessoa com acesso ao histórico do Git tem acesso permanente, mesmo depois de rotacionar.

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Use um serviço de secrets em produção: AWS Secrets Manager, HashiCorp Vault, Doppler. Rotacione o segredo periodicamente e tenha um plano de resposta para quando precisar invalidar todos os tokens de uma vez.

4. Token sem expiração

jwt.sign({ sub: userId }, SECRET);

jwt.sign({ sub: userId }, SECRET, { expiresIn: "15m" });

Token sem exp é válido para sempre. Se aparecer num log de erro, numa resposta de debug, num screenshot de Slack — ele funciona amanhã, em seis meses, daqui a dois anos. A expiração curta limita a janela de dano de qualquer vazamento.

5. Não validar aud e iss em sistemas com múltiplos serviços

Se você tem mais de um serviço emitindo ou consumindo JWT, um token gerado para o serviço A pode ser aceito pelo serviço B se nenhum dos dois valida aud (audience) e iss (issuer).

jwt.sign(
  { sub: userId, role: "user" },
  SECRET,
  { expiresIn: "15m", issuer: "auth-service", audience: "api-service" }
);

jwt.verify(token, SECRET, {
  algorithms: ["HS256"],
  issuer: "auth-service",
  audience: "api-service",
});

⚠️ Atenção: JWT não tem revogação nativa. Um token válido emitido às 14h continua válido às 14h14 mesmo que o usuário tenha trocado a senha às 14h01. Se o seu sistema precisa de invalidação imediata (logout, banimento, conta comprometida), você vai precisar de uma blocklist — o que quebra parte da vantagem stateless. Considere session server-side para esses casos.

Armazenamento seguro: localStorage ou cookie HttpOnly?

Onde o cliente guarda o token define qual superfície de ataque você precisa mitigar.

EstratégiaXSSCSRFComplexidade
localStorageVulnerávelImuneBaixa
Cookie HttpOnlyImuneVulnerávelMédia
Cookie HttpOnly + SameSite=Strict + CSRF tokenImuneImuneAlta
Cookie HttpOnly + SameSite=Lax (padrão moderno)ImuneProtegido para maioria dos casosMédia

localStorage é a escolha mais comum por ser simples. O problema: qualquer JavaScript na página lê — scripts de analytics, bibliotecas de terceiros, extensões do navegador ou um XSS bem colocado.

Cookie com HttpOnly resolve o XSS porque o JavaScript não consegue acessar o valor. A configuração completa:

res.cookie("access_token", token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict",
  maxAge: 15 * 60 * 1000,
  path: "/",
});

SameSite=Strict bloqueia o envio do cookie em requisições cross-site — o que resolve a maior parte dos vetores CSRF sem precisar de token adicional. Para APIs consumidas por SPAs em domínio diferente, SameSite=None; Secure exige HTTPS obrigatório e uma estratégia de CSRF explícita.

Não existe escolha universalmente certa. Avalie sua superfície de ataque real: se você tem bastante conteúdo de terceiros na página, HttpOnly protege mais. Se sua API é consumida por muitos clientes diferentes, localStorage pode ser mais prático com mitigações de XSS adequadas.

Refresh token com rotação segura

Access token de 15 minutos é seguro, mas inviável sem refresh token. O fluxo com rotação:

async function refreshAccessToken(refreshToken: string) {
  const stored = await db.refreshToken.findUnique({
    where: { token: refreshToken },
    include: { user: true },
  });

  if (!stored || stored.expiresAt < new Date() || stored.revoked) {
    throw new Error("Refresh token inválido ou revogado");
  }

  await db.refreshToken.update({
    where: { id: stored.id },
    data: { revoked: true },
  });

  const newRefresh = await db.refreshToken.create({
    data: {
      token: crypto.randomUUID(),
      userId: stored.userId,
      familyId: stored.familyId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  const accessToken = generateAccessToken(stored.userId, stored.user.role);

  return { accessToken, refreshToken: newRefresh.token };
}

O campo familyId é o que permite detectar reutilização. Quando um refresh token é usado, ele é revogado e um novo é emitido na mesma família. Se o token revogado aparecer de novo (o que só acontece se houve leak), você identifica a família e revoga todos os tokens daquele usuário imediatamente.

💡 Dica: Refresh tokens são sempre stateful — precisam existir no banco. É justamente porque eles permitem emitir novos access tokens que precisam ser rastreados e revogáveis. Não há como tornar isso stateless sem abrir mão do controle.

Checklist de segurança antes de ir para produção

Use isso antes de qualquer deploy:

  • JWT_SECRET tem no mínimo 64 bytes gerados aleatoriamente
  • JWT_SECRET não está no repositório (nem no histórico do Git)
  • Access token tem expiração curta (15m a 1h)
  • algorithms está fixado na verificação (["HS256"] ou ["RS256"])
  • Payload não contém dados sensíveis além do necessário para autorização
  • Refresh tokens são armazenados no banco com campo revoked
  • Rotação de refresh token está implementada com detecção de reutilização
  • Logout revoga o refresh token no banco
  • Tokens de usuário são todos revogados em troca de senha ou conta comprometida
  • Em sistemas multi-serviço, iss e aud são validados
  • Cookie configurado com HttpOnly, Secure e SameSite adequado ao contexto
  • Logs de erro não expõem o valor do token

FAQ

JWT ou session para aplicação web tradicional? Se você precisa de revogação imediata (logout, banimento, troca de permissão), session com Redis é mais simples. JWT é melhor quando você tem múltiplos serviços ou clientes mobile. Para monólitos tradicionais, session geralmente resolve com menos complexidade.

HS256 ou RS256? HS256 usa chave simétrica — o mesmo segredo assina e verifica. RS256 usa par assimétrico — chave privada assina, pública verifica. Se só um serviço emite tokens, HS256 é suficiente. Se múltiplos serviços precisam verificar sem emitir, distribua a chave pública e use RS256.

O que fazer se o JWT_SECRET vazar? Troque o segredo imediatamente — isso invalida todos os tokens existentes. Investigue a origem do vazamento antes de gerar o novo. Se você usa AWS, use Secrets Manager para rotação automática e auditoria de acesso.

Posso invalidar um JWT antes de expirar? Não nativamente. Você precisa de uma blocklist: armazena o jti dos tokens revogados no Redis com TTL igual ao exp do token. Cada verificação consulta o Redis. Simples, mas adiciona uma dependência de rede por requisição.

JWT funciona em React Native / mobile? Sim. Armazene no Keychain (iOS) ou Keystore (Android) — nunca em AsyncStorage, que é equivalente ao localStorage em termos de segurança. O fluxo de access + refresh token funciona da mesma forma.

Próximos passos

O essencial está aqui. Para ir além:

  • Rode o checklist agora em qualquer projeto JWT que você tenha em produção — especialmente os itens de algoritmo e payload
  • Implemente familyId no refresh token se ainda não tiver — é o que separa uma implementação funcional de uma segura
  • Explore JWKS se você trabalha com múltiplos serviços — o servidor de autenticação expõe as chaves públicas via endpoint e cada serviço as busca dinamicamente
  • Leia o OWASP JWT Security Cheat Sheet — cobre vetores como kid injection e jku spoofing que ficaram fora do escopo aqui mas são reais em ambientes mais complexos

JWT bem implementado é seguro e escalável. O padrão não tem problema — a maioria dos incidentes vem de implementação descuidada ou de escolher JWT quando a necessidade real era uma sessão revogável.