JWT e Refresh Tokens: Autenticação Stateless Segura em Node.js
Implemente autenticação segura com JWT e refresh tokens em Node.js: geração, validação, rotação de tokens, revogação e proteção contra ataques comuns.
O problema com JWT puro
JWT (JSON Web Token) é frequentemente mal implementado. O pattern mais comum — emitir um JWT com expiração longa (7 dias, 30 dias) — é inseguro: se o token vazar, o atacante tem acesso por todo o período de validade, sem como revogar.
A solução é usar tokens de curta duração com refresh tokens.
O fluxo correto: Access Token + Refresh Token
AUTENTICAÇÃO INICIAL
↓ POST /auth/login (credenciais)
↓ servidor verifica usuário
← access_token (15 min) + refresh_token (7 dias, httpOnly cookie)
USO NORMAL
↓ GET /api/dados (Authorization: Bearer access_token)
← dados
RENOVAÇÃO (quando access_token expira)
↓ POST /auth/refresh (envia refresh_token via cookie)
← novo access_token (15 min) + novo refresh_token rotacionado
O refresh token vai no cookie httpOnly (inacessível ao JavaScript), prevenindo XSS. O access token vai no header Authorization para cada requisição.
Implementação em Node.js/TypeScript
Geração de tokens
import jwt from 'jsonwebtoken'
import { randomBytes } from 'crypto'
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET!
interface TokenPayload {
sub: string // userId
email: string
role: string
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, ACCESS_TOKEN_SECRET, {
expiresIn: '15m',
algorithm: 'HS256',
issuer: 'minha-empresa.com',
audience: 'minha-empresa-api',
})
}
export function generateRefreshToken(): string {
// Refresh token é opaque — apenas um ID aleatório
// Os dados ficam no banco, não no token
return randomBytes(64).toString('hex')
}
Armazenando refresh tokens no banco
// prisma/schema.prisma (exemplo)
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
userAgent String?
ipAddress String?
}
// Salvar o refresh token
async function saveRefreshToken(
userId: string,
token: string,
req: Request
) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 dias
await db.refreshToken.create({
data: {
token: await hash(token), // nunca guardar o token em texto puro
userId,
expiresAt,
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
}
})
}
Endpoint de login
// POST /auth/login
export async function login(req: Request, res: Response) {
const { email, password } = req.body
const user = await db.user.findUnique({ where: { email } })
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
// Mesma mensagem para usuário não existente ou senha errada
// (previne user enumeration)
return res.status(401).json({ error: 'Credenciais inválidas' })
}
const accessToken = generateAccessToken({
sub: user.id,
email: user.email,
role: user.role,
})
const refreshToken = generateRefreshToken()
await saveRefreshToken(user.id, refreshToken, req)
// Refresh token em cookie httpOnly
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dias em ms
path: '/auth', // cookie só enviado para /auth/*
})
return res.json({ access_token: accessToken })
}
Endpoint de refresh
// POST /auth/refresh
export async function refresh(req: Request, res: Response) {
const refreshToken = req.cookies['refresh_token']
if (!refreshToken) return res.status(401).json({ error: 'Token faltando' })
// Buscar no banco (comparar hash)
const storedToken = await db.refreshToken.findFirst({
where: {
token: await hash(refreshToken),
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: { user: true }
})
if (!storedToken) {
// Token não encontrado: pode ser ataque de replay
// Revogar todos os tokens do usuário (se conseguirmos identificá-lo)
return res.status(401).json({ error: 'Token inválido' })
}
// Rotacionar: revogar o token atual e emitir novo
await db.refreshToken.update({
where: { id: storedToken.id },
data: { revokedAt: new Date() }
})
const newAccessToken = generateAccessToken({
sub: storedToken.user.id,
email: storedToken.user.email,
role: storedToken.user.role,
})
const newRefreshToken = generateRefreshToken()
await saveRefreshToken(storedToken.user.id, newRefreshToken, req)
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth',
})
return res.json({ access_token: newAccessToken })
}
Middleware de autenticação
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.status(401).json({ error: 'Token requerido' })
try {
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],
issuer: 'minha-empresa.com',
audience: 'minha-empresa-api',
}) as TokenPayload
req.user = payload
next()
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expirado', code: 'TOKEN_EXPIRED' })
}
return res.status(401).json({ error: 'Token inválido' })
}
}
Checklist de segurança JWT
| Item | Por quê |
|---|---|
| Access token com TTL curto (15 min) | Limita janela de ataque se vazar |
| Refresh token em httpOnly cookie | Inacessível ao JS — previne XSS |
| Refresh token opaque + salvo no banco | Permite revogação imediata |
| Rotação de refresh token a cada uso | Detecta replay attacks |
| Hash do refresh token no banco | Previne exposição em dump de banco |
| Algoritmo HS256 ou RS256 explícito | Previne ataque do algoritmo none |
| issuer e audience validados | Previne cross-service token forgery |
| Logout revoga o refresh token | Sessão termina de verdade |
