Autenticação com JWT e Refresh Tokens no Express
A autenticação stateless com JWT tem um problema clássico: se você usa tokens com longa duração, o roubo de um token dá acesso por dias ou semanas. Se usa tokens curtos, o usuário precisa fazer login a cada 15 minutos. Refresh tokens resolvem esse dilema.
O modelo de segurança
Access Token: válido por 15 minutos — enviado em cada requisição
Refresh Token: válido por 7 dias — usado APENAS para obter novo access tokenO access token fica em memória no cliente (nunca no localStorage). O refresh token fica em cookie httpOnly — JavaScript não consegue lê-lo, eliminando XSS como vetor.
Quando o access token expira, o cliente faz uma requisição ao endpoint /refresh usando o cookie httpOnly. O servidor valida, invalida o refresh token antigo e emite um par novo (rotação).
Gerando os tokens
// lib/tokens.js
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
export function gerarAccessToken(usuario) {
return jwt.sign(
{
sub: usuario.id,
email: usuario.email,
role: usuario.role,
},
ACCESS_SECRET,
{ expiresIn: '15m', issuer: 'minha-api' }
);
}
export function gerarRefreshToken(usuarioId) {
// Adiciona jti (JWT ID) único para rastrear e invalidar tokens individuais
const jti = randomBytes(32).toString('hex');
const token = jwt.sign(
{ sub: usuarioId, jti },
REFRESH_SECRET,
{ expiresIn: '7d', issuer: 'minha-api' }
);
return { token, jti };
}Armazenando refresh tokens no Redis
Banco de dados é lento demais para validar tokens em cada refresh. Redis é ideal: rápido, TTL nativo, e fácil de invalidar por família.
// lib/refreshTokenStore.js
import { redis } from './redis.js';
const TTL_SEGUNDOS = 7 * 24 * 60 * 60; // 7 dias
export async function salvarRefreshToken(usuarioId, jti) {
const key = `refresh:${usuarioId}:${jti}`;
await redis.setex(key, TTL_SEGUNDOS, '1');
}
export async function validarRefreshToken(usuarioId, jti) {
const key = `refresh:${usuarioId}:${jti}`;
const existe = await redis.get(key);
return existe !== null;
}
export async function revogarRefreshToken(usuarioId, jti) {
await redis.del(`refresh:${usuarioId}:${jti}`);
}
export async function revogarTodosTokens(usuarioId) {
// Logout em todos os dispositivos
const keys = await redis.keys(`refresh:${usuarioId}:*`);
if (keys.length > 0) await redis.del(...keys);
}Endpoint de login
// routes/auth.js
import express from 'express';
import bcrypt from 'bcrypt';
import { gerarAccessToken, gerarRefreshToken } from '../lib/tokens.js';
import { salvarRefreshToken } from '../lib/refreshTokenStore.js';
import { Usuario } from '../models/Usuario.js';
const router = express.Router();
router.post('/login', async (req, res) => {
const { email, senha } = req.body;
const usuario = await Usuario.findByEmail(email);
if (!usuario || !(await bcrypt.compare(senha, usuario.senhaHash))) {
return res.status(401).json({ error: 'Credenciais inválidas' });
}
const accessToken = gerarAccessToken(usuario);
const { token: refreshToken, jti } = gerarRefreshToken(usuario.id);
await salvarRefreshToken(usuario.id, jti);
// Refresh token em cookie httpOnly — JavaScript não acessa
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dias em ms
path: '/auth/refresh', // cookie só enviado nesse path
});
res.json({ accessToken });
});Middleware de autenticação
// middlewares/autenticar.js
import jwt from 'jsonwebtoken';
export function autenticar(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token não fornecido' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
issuer: 'minha-api',
});
req.usuario = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expirado', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Token inválido' });
}
}Endpoint /refresh com rotação
A rotação é a parte mais importante: ao usar um refresh token, ele é imediatamente invalidado e um novo par é emitido. Se um atacante roubar o token e tentar usar DEPOIS do usuário legítimo, o token já não existe.
router.post('/refresh', async (req, res) => {
const tokenRecebido = req.cookies.refreshToken;
if (!tokenRecebido) return res.status(401).json({ error: 'Sem refresh token' });
let payload;
try {
payload = jwt.verify(tokenRecebido, process.env.JWT_REFRESH_SECRET, {
issuer: 'minha-api',
});
} catch {
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.status(401).json({ error: 'Refresh token inválido' });
}
const { sub: usuarioId, jti } = payload;
// Valida que o token ainda existe no Redis
const valido = await validarRefreshToken(usuarioId, jti);
if (!valido) {
// Token já foi usado — possível ataque de replay
// Revoga TODOS os tokens do usuário (token family invalidation)
await revogarTodosTokens(usuarioId);
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.status(401).json({ error: 'Token reutilizado — faça login novamente' });
}
// Rotação: revoga o token atual e emite um novo par
await revogarRefreshToken(usuarioId, jti);
const usuario = await Usuario.findById(usuarioId);
const novoAccessToken = gerarAccessToken(usuario);
const { token: novoRefreshToken, jti: novoJti } = gerarRefreshToken(usuarioId);
await salvarRefreshToken(usuarioId, novoJti);
res.cookie('refreshToken', novoRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
res.json({ accessToken: novoAccessToken });
});Logout
router.post('/logout', autenticar, async (req, res) => {
const tokenRecebido = req.cookies.refreshToken;
if (tokenRecebido) {
try {
const payload = jwt.verify(tokenRecebido, process.env.JWT_REFRESH_SECRET);
await revogarRefreshToken(payload.sub, payload.jti);
} catch {
// Token já expirado, tudo bem
}
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.json({ message: 'Logout realizado' });
});
// Logout em todos os dispositivos
router.post('/logout-all', autenticar, async (req, res) => {
await revogarTodosTokens(req.usuario.sub);
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.json({ message: 'Sessões encerradas em todos os dispositivos' });
});Cliente: interceptor automático com Axios
// lib/apiClient.js
import axios from 'axios';
const api = axios.create({ baseURL: '/api', withCredentials: true });
let accessToken = null;
export function setAccessToken(token) { accessToken = token; }
// Adiciona access token em cada requisição
api.interceptors.request.use((config) => {
if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
// Renova automaticamente quando expira
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (
error.response?.status === 401 &&
error.response?.data?.code === 'TOKEN_EXPIRED' &&
!original._retry
) {
original._retry = true;
const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true });
setAccessToken(data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original);
}
return Promise.reject(error);
}
);
export default api;Resumo das boas práticas
| Prática | Por quê |
|---|---|
| Access token em memória | Sem XSS via localStorage |
| Refresh token httpOnly | JavaScript não acessa |
| Rotação obrigatória | Detecta roubo de token |
| TTL curto no access (15min) | Limita janela de exploração |
path restrito no cookie | Refresh token não vaza em outras requisições |
| Revogar família ao detectar replay | Invalida sessão comprometida |
Esse modelo protege contra os ataques mais comuns: XSS (access em memória + cookie httpOnly), CSRF (sameSite strict), e roubo de refresh token (rotação + invalidação de família).