Índice
- Por que a maioria das implementações JWT falha em produção
- O que é JWT e como ele funciona?
- Como gerar um token JWT no Node.js
- Middleware de autenticação no Next.js App Router
- Como usar os dados do usuário nas rotas protegidas
- Os 5 erros mais comuns com JWT (e como evitar)
- Refresh Tokens: a arquitetura certa para produção
- Benchmark: HS256 vs RS256 — qual é mais rápido?
- Checklist de segurança JWT
- FAQ — Perguntas frequentes
Por que a maioria das implementações JWT falha em produção
Se você já protegeu uma rota com JWT e só descobriu que fez errado quando leu sobre XSS — ou pior, quando aconteceu em produção — este artigo é para você.
JWT parece trivial de implementar. Uma chamada jwt.sign(), uma chamada jwt.verify(), e tudo funciona no Postman. O problema está justamente aí: o que funciona no Postman pode estar errado de formas que só aparecem quando alguém está ativamente tentando quebrar sua aplicação.
Os erros mais comuns não são bugs de código — são decisões de configuração aceitas sem questionamento: guardar o token no localStorage, usar um secret gerado aleatoriamente no teclado, não especificar o algoritmo de verificação.
Vamos construir a implementação certa desde o início.
O que você vai encontrar aqui:
- Como gerar tokens JWT de forma segura no endpoint de login
- Middleware completo para o Next.js App Router (TypeScript)
- 5 erros críticos com exemplos reais de como corrigir
- Arquitetura de refresh tokens para produção (com diagrama)
- Benchmark de performance: HS256 vs RS256
- Checklist de segurança completo
Pré-requisitos: Node.js 18+, familiaridade com APIs REST e Next.js App Router.
O que é JWT e como ele funciona?
JWT (JSON Web Token) é um padrão aberto (RFC 7519) para transmitir informações entre duas partes de forma compacta e verificável. Um token JWT é composto por três partes codificadas em Base64 separadas por pontos: Header (algoritmo de assinatura), Payload (dados do usuário) e Signature (verificação de integridade). O payload não é criptografado — apenas codificado.
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiJ9 . SflKxwRJSMeKKF2QT4fw
^ ^ ^
HEADER PAYLOAD SIGNATURE
(algoritmo) (dados do usuário) (garante integridade)
As três partes de um token JWT explicadas
Header — Contém o tipo do token (JWT) e o algoritmo de assinatura usado:
{
"alg": "HS256",
"typ": "JWT"
}
Payload (Claims) — Contém os dados transmitidos. Os claims reservados mais importantes são:
{
"sub": "user_123", // subject: ID do usuário
"email": "ana@example.com",
"role": "admin",
"iat": 1711324800, // issued at: quando foi emitido
"exp": 1711325700 // expiration: quando expira (unix timestamp)
}
Signature — Gerada assim:
HMACSHA256(
base64url(header) + "." + base64url(payload),
JWT_SECRET
)
Se qualquer byte do header ou payload for alterado, a assinatura não bate — e o token é rejeitado.
JWT é criptografado?
Não. O payload é apenas codificado em Base64 — qualquer pessoa pode decodificá-lo em segundos com atob() no browser ou jwt.io. Nunca inclua senhas, tokens de terceiros, chaves de API ou dados sensíveis no payload.
O que o JWT garante é integridade (ninguém adulterou o token) e autenticidade (foi emitido por quem tem o secret). Não garante confidencialidade (o conteúdo não é secreto).
Como gerar um token JWT no Node.js
Instalando as dependências
npm install jsonwebtoken bcrypt
npm install --save-dev @types/jsonwebtoken @types/bcrypt
Endpoint de login com geração de token
// app/api/auth/login/route.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
// 1. Busca o usuário no banco
const user = await db.user.findUnique({ where: { email } });
// 2. Verifica credenciais com timing-safe compare
// Importante: retornar o MESMO erro para email inexistente e senha errada
// evita que um atacante enumere usuários cadastrados
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return Response.json(
{ error: 'Credenciais inválidas' },
{ status: 401 }
);
}
// 3. Gera o access token (vida curta)
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
// Não inclua: passwordHash, tokens de API, CPF, dados financeiros
},
process.env.JWT_SECRET!,
{
expiresIn: '15m', // Access token: vida curta por segurança
algorithm: 'HS256', // Especifique SEMPRE — evita ataques de confusão
}
);
// 4. Gera o refresh token (vida longa)
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '30d', algorithm: 'HS256' }
);
// 5. Armazena refresh token no banco (com hash, não em texto puro)
await db.refreshToken.create({
data: {
userId: user.id,
tokenHash: await bcrypt.hash(refreshToken, 10),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
// 6. Retorna tokens em cookies httpOnly (NUNCA no body — veja seção de erros)
const response = Response.json({ ok: true, user: { id: user.id, email: user.email } });
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 15, // 15 minutos
path: '/',
});
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 dias
path: '/api/auth/refresh', // Acessível apenas nessa rota
});
return response;
}
Middleware de autenticação no Next.js App Router
Crie middleware.ts na raiz do projeto (no mesmo nível de app/, não dentro dele):
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
// Rotas que não exigem autenticação
const PUBLIC_ROUTES = [
'/',
'/login',
'/register',
'/sobre',
'/api/auth/login',
'/api/auth/register',
'/api/auth/refresh',
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Verifica se a rota é pública
const isPublic = PUBLIC_ROUTES.some(
route => pathname === route || pathname.startsWith(`${route}/`)
);
if (isPublic) return NextResponse.next();
// Lê o token do cookie httpOnly
const token = request.cookies.get('access_token')?.value;
if (!token) {
return handleUnauthenticated(request);
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'], // Especifique — nunca deixe o padrão
}) as jwt.JwtPayload;
// Injeta dados do usuário via headers internos
// (headers com x- são convenção para dados internos — não exponha ao cliente)
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub as string);
response.headers.set('x-user-role', (payload.role as string) ?? 'user');
response.headers.set('x-user-email', payload.email as string);
return response;
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
// Access token expirado — cliente deve usar o refresh token
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'Token expirado', code: 'TOKEN_EXPIRED' },
{ status: 401 }
);
}
// Para páginas, redireciona para login com contexto
return NextResponse.redirect(
new URL('/login?reason=session_expired', request.url)
);
}
// Token inválido, assinatura incorreta ou algoritmo manipulado
return NextResponse.json(
{ error: 'Token inválido', code: 'TOKEN_INVALID' },
{ status: 401 }
);
}
}
function handleUnauthenticated(request: NextRequest) {
const { pathname } = request.nextUrl;
// Rotas de API retornam JSON
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'Não autenticado', code: 'UNAUTHENTICATED' },
{ status: 401 }
);
}
// Páginas redirecionam para o login salvando a URL de destino
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
// Aplica o middleware nas rotas protegidas
matcher: [
'/api/:path*',
'/dashboard/:path*',
'/profile/:path*',
'/settings/:path*',
],
};
Lendo os dados do usuário nas rotas protegidas
Com o middleware injetando os dados via headers, cada rota protegida pode lê-los sem consultar o banco:
// app/api/profile/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
// O middleware já validou o token — leia os headers com segurança
const userId = request.headers.get('x-user-id');
const userRole = request.headers.get('x-user-role');
if (!userId) {
// Não deve acontecer se o middleware estiver correto, mas defenda-se mesmo assim
return Response.json({ error: 'Contexto de usuário ausente' }, { status: 401 });
}
// Verificação de permissão por role
if (userRole !== 'admin') {
return Response.json({ error: 'Acesso não autorizado' }, { status: 403 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
// NUNCA selecione: passwordHash, tokens, dados de pagamento
},
});
if (!user) {
return Response.json({ error: 'Usuário não encontrado' }, { status: 404 });
}
return Response.json(user);
}
Os 5 erros mais comuns com JWT (e como evitar)
Erro 1: Guardar o token no localStorage
O localStorage é acessível por qualquer JavaScript rodando na página — incluindo scripts injetados por XSS. Um token roubado via XSS equivale a uma sessão comprometida sem possibilidade de revogação imediata.
// ❌ Vulnerável a XSS — qualquer script malicioso pode fazer isso:
const token = localStorage.getItem('jwt');
fetch('/api/dados', { headers: { Authorization: `Bearer ${token}` } });
Solução: Cookie httpOnly. O JavaScript da página não consegue acessar cookies marcados como httpOnly — ponto final.
// ✅ Correto: cookie httpOnly inacessível via document.cookie ou localStorage
response.cookies.set('access_token', token, {
httpOnly: true, // JavaScript não acessa
secure: true, // Apenas em HTTPS
sameSite: 'lax', // Proteção básica anti-CSRF
maxAge: 60 * 15, // Expiração curta: 15 minutos
path: '/',
});
Erro 2: JWT_SECRET fraco ou exposto
Um secret como "minha-senha-123" ou "jwt_secret_dev" pode ser quebrado por força bruta em questão de horas com ferramentas como Hashcat. Um secret versionado no repositório pode ser extraído do histórico do Git mesmo depois de removido.
# ❌ Fraco — não use strings digitadas à mão
JWT_SECRET=minhasenhasecreta123
# ✅ Correto: gere com criptografia real (64 bytes = 128 caracteres hex)
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Saída: a3f8c2e1d7b4... (128 caracteres aleatórios)
Regras para o secret:
- Gerado com
crypto.randomBytes(64)ou equivalente - Salvo em
.env.local(Next.js) — nunca em.envcommitado .env*no.gitignoreantes do primeiro commit- Em produção: variáveis de ambiente do provedor (Vercel, Railway, Fly.io) — nunca arquivos
.envem servidores
Erro 3: Não especificar o algoritmo de verificação
O padrão jwt.verify() sem { algorithms: [...] } aceita qualquer algoritmo declarado no header do token. Isso abre espaço para ataques de confusão de algoritmo onde um atacante manipula o header para "alg": "none" ou troca HS256 por RS256.
// ❌ Aceita qualquer algoritmo — incluindo "alg: none"
jwt.verify(token, process.env.JWT_SECRET!);
// ✅ Rejeita qualquer token que não use HS256
jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ['HS256'] });
Erro 4: Tokens sem expiração (ou com expiração muito longa)
Tokens sem expiresIn são válidos para sempre. Se um token vazar (logs, cache, intermediário), ele pode ser usado indefinidamente.
// ❌ Token eterno — se vazar, vaza para sempre
jwt.sign({ sub: user.id }, secret);
// ❌ Expiração de 30 dias para access token — janela enorme de abuso
jwt.sign({ sub: user.id }, secret, { expiresIn: '30d' });
// ✅ Access token curto + refresh token longo
jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' }); // access
jwt.sign({ sub: user.id }, refreshSecret, { expiresIn: '30d' }); // refresh
Erro 5: Não tratar erros de verificação de forma específica
Tratar todos os erros de JWT como "token inválido" genérico impede que o cliente saiba se deve renovar o token (expirado) ou fazer login novamente (inválido/adulterado).
// ❌ Tudo é "inválido" — cliente não sabe o que fazer
try {
jwt.verify(token, secret);
} catch {
return Response.json({ error: 'Token inválido' }, { status: 401 });
}
// ✅ Erros específicos — cliente pode agir corretamente
try {
jwt.verify(token, secret, { algorithms: ['HS256'] });
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
// Cliente deve chamar /api/auth/refresh com o refresh token
return Response.json(
{ error: 'Token expirado', code: 'TOKEN_EXPIRED' },
{ status: 401 }
);
}
if (err instanceof jwt.JsonWebTokenError) {
// Token malformado ou assinatura inválida — faça login novamente
return Response.json(
{ error: 'Token inválido', code: 'TOKEN_INVALID' },
{ status: 401 }
);
}
// Erro inesperado — logue internamente mas não exponha detalhes
console.error('[JWT] Erro inesperado:', err);
return Response.json({ error: 'Erro de autenticação' }, { status: 500 });
}
Refresh Tokens: a arquitetura certa para produção
Access tokens com 15 minutos de vida são seguros, mas forçariam o usuário a fazer login toda hora. A solução padrão da indústria é o par access token + refresh token:
┌──────────────────────────────────────────────────────────────────┐
│ Fluxo completo de autenticação │
│ │
│ 1. LOGIN │
│ POST /api/auth/login │
│ → access_token (15min) → Cookie httpOnly │
│ → refresh_token (30d) → Cookie httpOnly (path restrito) │
│ │
│ 2. REQUISIÇÃO NORMAL │
│ GET /api/profile │
│ → Middleware lê access_token do cookie │
│ → Token válido? → Responde normalmente │
│ │
│ 3. ACCESS TOKEN EXPIRADO │
│ GET /api/profile → 401 { code: "TOKEN_EXPIRED" } │
│ → Cliente chama POST /api/auth/refresh │
│ → Middleware valida refresh_token │
│ → Gera NOVO par de tokens (rotação) │
│ → Invalida refresh_token antigo no banco │
│ → Repete a requisição original │
│ │
│ 4. LOGOUT │
│ POST /api/auth/logout │
│ → Invalida refresh_token no banco │
│ → Limpa os cookies │
└──────────────────────────────────────────────────────────────────┘
Endpoint de refresh
// app/api/auth/refresh/route.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get('refresh_token')?.value;
if (!refreshToken) {
return Response.json({ error: 'Refresh token ausente' }, { status: 401 });
}
// 1. Valida a assinatura do refresh token
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!, {
algorithms: ['HS256'],
}) as jwt.JwtPayload;
} catch {
return Response.json({ error: 'Refresh token inválido' }, { status: 401 });
}
// 2. Verifica se o token existe no banco (não foi revogado)
const storedTokens = await db.refreshToken.findMany({
where: { userId: payload.sub, expiresAt: { gt: new Date() } },
});
const validToken = await Promise.any(
storedTokens.map(async (stored) => {
const match = await bcrypt.compare(refreshToken, stored.tokenHash);
if (!match) throw new Error('no match');
return stored;
})
).catch(() => null);
if (!validToken) {
// Possível reutilização de token — pode indicar roubo
// Invalide TODOS os tokens do usuário como medida de segurança
await db.refreshToken.deleteMany({ where: { userId: payload.sub } });
return Response.json(
{ error: 'Token inválido ou já utilizado', code: 'TOKEN_REUSE_DETECTED' },
{ status: 401 }
);
}
// 3. Invalida o token usado (rotação: um refresh token = um uso)
await db.refreshToken.delete({ where: { id: validToken.id } });
// 4. Busca o usuário atual (role pode ter mudado)
const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user) {
return Response.json({ error: 'Usuário não encontrado' }, { status: 401 });
}
// 5. Gera novo par de tokens
const newAccessToken = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '15m', algorithm: 'HS256' }
);
const newRefreshToken = jwt.sign(
{ sub: user.id },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '30d', algorithm: 'HS256' }
);
// 6. Salva novo refresh token no banco
await db.refreshToken.create({
data: {
userId: user.id,
tokenHash: await bcrypt.hash(newRefreshToken, 10),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
// 7. Retorna novos tokens em cookies
const response = Response.json({ ok: true });
response.cookies.set('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 15,
path: '/',
});
response.cookies.set('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/api/auth/refresh',
});
return response;
}
Interceptor no frontend (renovação automática)
// lib/api-client.ts
async function apiFetch(url: string, options?: RequestInit) {
const response = await fetch(url, { ...options, credentials: 'include' });
// Se o access token expirou, tenta renovar automaticamente
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
const refresh = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (refresh.ok) {
// Repete a requisição original com o novo token
return fetch(url, { ...options, credentials: 'include' });
}
// Refresh falhou — redireciona para login
window.location.href = '/login?reason=session_expired';
}
}
return response;
}
Benchmark: HS256 vs RS256 — qual é mais rápido?
A escolha do algoritmo afeta performance. Rodando 10.000 verificações em Node.js 20 (Apple M2):
// Script de benchmark — rode você mesmo
const jwt = require('jsonwebtoken');
const { generateKeyPairSync } = require('crypto');
const { performance } = require('perf_hooks');
// Setup
const hs256Secret = require('crypto').randomBytes(64);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const hs256Token = jwt.sign({ sub: 'user_123', role: 'admin' }, hs256Secret, { algorithm: 'HS256' });
const rs256Token = jwt.sign({ sub: 'user_123', role: 'admin' }, privateKey, { algorithm: 'RS256' });
const ITERATIONS = 10_000;
function bench(label, fn) {
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) fn();
const ms = performance.now() - start;
console.log(`${label}: ${(ms / ITERATIONS).toFixed(4)}ms por operação (total: ${ms.toFixed(0)}ms)`);
}
bench('HS256 sign ', () => jwt.sign({ sub: 'user_123' }, hs256Secret, { algorithm: 'HS256' }));
bench('RS256 sign ', () => jwt.sign({ sub: 'user_123' }, privateKey, { algorithm: 'RS256' }));
bench('HS256 verify ', () => jwt.verify(hs256Token, hs256Secret, { algorithms: ['HS256'] }));
bench('RS256 verify ', () => jwt.verify(rs256Token, publicKey, { algorithms: ['RS256'] }));
Resultados típicos (Node.js 20, hardware moderno):
| Operação | HS256 | RS256 | Diferença |
|---|---|---|---|
| Sign | ~0.01ms | ~1.2ms | RS256 é ~120x mais lento |
| Verify | ~0.01ms | ~0.08ms | RS256 é ~8x mais lento |
| 10k verify | ~100ms total | ~800ms total | Relevante em alta escala |
Quando usar cada um:
- HS256 → APIs monolíticas, aplicações com um único serviço verificando tokens. Mais rápido, mais simples.
- RS256 → Microsserviços onde múltiplos serviços precisam verificar tokens sem conhecer o secret de assinatura. Você distribui a chave pública livremente; apenas o serviço de auth tem a privada.
Para a maioria das aplicações Next.js, HS256 é suficiente e mais performático.
Checklist de segurança JWT para produção
| Configuração | ✅ Recomendação | ⚠️ Por que importa |
|---|---|---|
| Storage | Cookie httpOnly | Elimina XSS como vetor de roubo de token |
| Algoritmo | Especifique { algorithms: ['HS256'] } | Previne ataques de confusão de algoritmo |
| JWT_SECRET | 64+ bytes via crypto.randomBytes(64) | Resistente a força bruta |
| JWT_REFRESH_SECRET | Secret diferente do access token | Limita blast radius em caso de vazamento |
| Expiração (access) | 15m | Limita janela de abuso em caso de vazamento |
| Expiração (refresh) | 30d com rotação | Equilíbrio entre UX e segurança |
Cookie secure | true em produção | Token só trafega em HTTPS |
Cookie sameSite | lax (mínimo) | Proteção básica anti-CSRF |
Cookie path (refresh) | /api/auth/refresh | Limita escopo do refresh token |
| Payload | Apenas dados não-sensíveis | Base64 não é criptografia |
| Erros JWT | Trate TokenExpiredError separadamente | Cliente sabe quando renovar vs. re-autenticar |
| Refresh token | Hash no banco, uso único | Detecta roubo por reutilização |
| Secrets em produção | Variáveis de ambiente do provedor | Nunca em arquivos .env em servidor |
FAQ — Perguntas frequentes sobre JWT
JWT é mais seguro que sessão com cookie de sessão tradicional?
Depende da implementação dos dois lados. Sessões tradicionais (ID no cookie, dados no servidor) são igualmente seguras quando bem configuradas. JWT tem vantagens em arquiteturas distribuídas — não requer consulta ao banco a cada requisição e funciona melhor em microsserviços. Para uma aplicação monolítica simples, sessões tradicionais podem ser mais adequadas.
É possível invalidar um JWT antes de expirar?
Não nativamente — essa é a maior limitação do JWT. As soluções comuns são: manter uma blocklist de tokens revogados no Redis (eficiente para tokens de vida curta), usar access tokens muito curtos (15min) combinados com refresh token rotation, ou adotar sessões para casos onde revogação imediata é crítica (ex: "deslogar de todos os dispositivos").
Qual a diferença entre HS256 e RS256?
HS256 usa uma chave simétrica: o mesmo secret serve para assinar e para verificar. É mais simples e mais rápido. RS256 usa par assimétrico: a chave privada assina, a pública verifica. RS256 é ideal quando múltiplos serviços precisam verificar tokens sem acesso ao secret de assinatura — você publica a chave pública e cada serviço verifica de forma independente.
Devo usar JWT para autenticação de sessão em aplicações web?
Para SPAs e apps mobile que consomem APIs, JWT é uma boa escolha. Para aplicações web server-side tradicionais (com SSR completo), cookies de sessão com ID no banco podem ser mais simples, mais fáceis de revogar e igualmente seguros. O Next.js App Router com cookies httpOnly funciona bem com ambas as abordagens.
Como lidar com a expiração do token de forma transparente para o usuário?
Implemente um wrapper em torno do fetch que intercepta respostas 401 com code: "TOKEN_EXPIRED", chama automaticamente o endpoint de refresh e repete a requisição original. O usuário não percebe a renovação. O exemplo está na seção de interceptor do frontend acima.
O que fazer se suspeitar que um refresh token foi roubado?
Com refresh token rotation implementado, uma tentativa de reutilizar um token já usado aciona o alerta. A resposta correta é invalidar todos os refresh tokens do usuário imediatamente, forçando re-autenticação em todos os dispositivos. Notifique o usuário e investigue o vetor de vazamento.
Preciso de dois secrets diferentes para access e refresh token?
Sim. Usar o mesmo secret para os dois tipos de token significa que um vazamento compromete ambos. Com secrets separados (JWT_SECRET e JWT_REFRESH_SECRET), o blast radius de um vazamento fica contido ao tipo de token afetado.
Próximo passo
A implementação acima cobre o caminho completo de autenticação JWT para produção. Se você está migrando de localStorage para cookies httpOnly, o maior cuidado é com o período de transição — usuários com tokens antigos no localStorage vão fazer logout na próxima sessão.
Na Parte 2 desta série: autenticação JWT em arquiteturas multi-tenant — onde cada workspace tem seu próprio espaço de dados e as permissões precisam ser verificadas em dois níveis (usuário + tenant).