Autenticação Invisível: A Arquitetura de Refresh Tokens
A implementação completa do fluxo de Access + Refresh Token: rotação de tokens, armazenamento seguro via HttpOnly Cookie, revogação de sessão, e proteção contra ataques de reutilização.
O JWT tem um problema estrutural: é stateless. Uma vez emitido, ele não pode ser revogado antes de expirar. Se um invasor roubar um JWT com validade de 7 dias, ele tem 7 dias de acesso livre — e você não pode fazer nada a não ser esperar o token expirar. A solução de força bruta seria emitir JWTs que expiram em 15 minutos, mas aí o usuário seria deslogado constantemente, destruindo a UX.
A arquitetura de Refresh Tokens resolve esse trade-off separando autenticação em dois tokens com responsabilidades distintas. Neste artigo vamos implementar o fluxo completo no Node.js: geração, rotação, revogação e proteção contra ataques de reutilização de refresh token.
Os Dois Tokens: Responsabilidades Distintas
- Access Token (JWT) — duração curta (5–15 min). Enviado em cada requisição no header
Authorization: Bearer <token>. Stateless: o servidor verifica apenas a assinatura, sem consultar banco. Se vazar, o dano é limitado à janela de expiração. - Refresh Token — duração longa (7–30 dias). Um token opaco (UUID ou string aleatória criptograficamente segura) armazenado no banco de dados. Usado exclusivamente para obter um novo Access Token. Stateful: o servidor precisa consultá-lo no banco, o que permite revogação.
Onde Armazenar os Tokens no Frontend
Essa é uma das decisões de segurança mais importantes e mais mal compreendidas:
- `localStorage` — acessível via JavaScript, vulnerável a ataques XSS (Cross-Site Scripting). Nunca armazene tokens sensíveis aqui.
- `sessionStorage` — mesmo problema do localStorage: acessível via JS.
- `HttpOnly Cookie` — a opção correta para Refresh Tokens. Cookies com a flag
HttpOnlynão são acessíveis via JavaScript — nem um XSS pode lê-los. ComSecure+SameSite=Strict, a proteção é robusta. - Memory (variável em memória) — a opção correta para Access Tokens no frontend. Não persiste entre refreshes de página (o que é intencional para tokens de curta duração).
A maioria dos tutoriais de JWT armazena o token no localStorage. Isso é uma vulnerabilidade de segurança real. Qualquer script injetado via XSS (inclusive via dependência npm comprometida) pode ler e exfiltrar o token.
Implementação: Backend Node.js + TypeScript
Schema do Banco de Dados
import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { UserModel } from './UserModel';
@Entity('refresh_tokens')
export class RefreshTokenModel {
@PrimaryColumn('uuid')
id: string;
// O token em si — string aleatória de 64 bytes (128 chars hex)
@Column({ unique: true })
token: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => UserModel, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserModel;
// Expiração explícita no banco — não confiamos apenas no frontend
@Column({ name: 'expires_at', type: 'timestamp' })
expiresAt: Date;
// Importante: marca o token como usado (para detecção de reutilização)
@Column({ name: 'is_revoked', default: false })
isRevoked: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}Geração de Tokens no Login
import { sign } from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import { addDays } from 'date-fns';
interface AuthenticateDTO {
email: string;
password: string;
}
interface AuthenticateResponse {
accessToken: string;
refreshToken: string;
user: { id: string; name: string; email: string };
}
export class AuthenticateUserUseCase {
constructor(
private usersRepo: IUsersRepository,
private refreshTokensRepo: IRefreshTokensRepository,
private hashProvider: IHashProvider
) {}
async execute({ email, password }: AuthenticateDTO): Promise<AuthenticateResponse> {
const user = await this.usersRepo.findByEmail(email);
if (!user) {
// Mesma mensagem para email inválido e senha inválida
// (evita enumeração de usuários)
throw new AppError('Credenciais inválidas.', 401);
}
const passwordMatch = await this.hashProvider.compare(password, user.passwordHash);
if (!passwordMatch) {
throw new AppError('Credenciais inválidas.', 401);
}
// Access Token: JWT de curta duração
const accessToken = sign(
{ email: user.email, name: user.name },
process.env.JWT_SECRET as string,
{
subject: user.id,
expiresIn: '15m',
}
);
// Refresh Token: string aleatória de 64 bytes (não é JWT)
const refreshTokenValue = randomBytes(64).toString('hex');
await this.refreshTokensRepo.create({
id: crypto.randomUUID(),
token: refreshTokenValue,
userId: user.id,
expiresAt: addDays(new Date(), 30),
isRevoked: false,
});
return {
accessToken,
refreshToken: refreshTokenValue,
user: { id: user.id, name: user.name, email: user.email },
};
}
}Rota de Login: Enviando o Refresh Token via Cookie
import type { Request, Response } from 'express';
import { AuthenticateUserUseCase } from '../usecases/AuthenticateUserUseCase';
export class AuthController {
constructor(private authenticateUser: AuthenticateUserUseCase) {}
async login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body;
const { accessToken, refreshToken, user } = await this.authenticateUser.execute({
email,
password,
});
// Refresh Token vai no HttpOnly Cookie — nunca acessível via JavaScript
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // Protege contra XSS
secure: true, // Só enviado via HTTPS
sameSite: 'strict', // Protege contra CSRF
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias em milliseconds
path: '/auth/refresh', // Cookie só é enviado para essa rota específica
});
// Access Token vai no body — o frontend guarda em memória
res.json({
accessToken,
user,
});
}
}Rotação de Refresh Tokens: Detectando Reutilização
Refresh Token Rotation é a prática de emitir um novo Refresh Token a cada renovação e invalidar o anterior. Isso serve para detectar ataques de reutilização (Replay Attacks): se um invasor roubar um Refresh Token e usá-lo depois de você já ter renovado, o servidor detecta que um token revogado foi usado e pode encerrar todas as sessões do usuário como medida de segurança.
import { sign } from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import { addDays, isPast } from 'date-fns';
export class RefreshTokenUseCase {
constructor(
private usersRepo: IUsersRepository,
private refreshTokensRepo: IRefreshTokensRepository
) {}
async execute(incomingToken: string): Promise<{ accessToken: string; newRefreshToken: string }> {
const storedToken = await this.refreshTokensRepo.findByToken(incomingToken);
if (!storedToken) {
throw new AppError('Refresh Token inválido.', 401);
}
// Detecta reutilização: token já foi revogado
if (storedToken.isRevoked) {
// Situação crítica: um token revogado foi apresentado.
// Isso pode indicar que o Refresh Token original foi roubado.
// Medida de segurança máxima: revoga TODOS os tokens do usuário.
await this.refreshTokensRepo.revokeAllForUser(storedToken.userId);
throw new AppError(
'Token de sessão suspeito detectado. Faça login novamente.',
401
);
}
// Verifica expiração
if (isPast(storedToken.expiresAt)) {
throw new AppError('Sessão expirada. Faça login novamente.', 401);
}
const user = await this.usersRepo.findById(storedToken.userId);
if (!user) {
throw new AppError('Usuário não encontrado.', 401);
}
// Rotação: invalida o token antigo
await this.refreshTokensRepo.revoke(storedToken.id);
// Gera novo Access Token
const accessToken = sign(
{ email: user.email, name: user.name },
process.env.JWT_SECRET as string,
{ subject: user.id, expiresIn: '15m' }
);
// Gera novo Refresh Token (rotação)
const newRefreshTokenValue = randomBytes(64).toString('hex');
await this.refreshTokensRepo.create({
id: crypto.randomUUID(),
token: newRefreshTokenValue,
userId: user.id,
expiresAt: addDays(new Date(), 30),
isRevoked: false,
});
return { accessToken, newRefreshToken: newRefreshTokenValue };
}
}Rota de Refresh e Logout
async refresh(req: Request, res: Response): Promise<void> {
// Lê o token do HttpOnly Cookie (não do body)
const incomingToken = req.cookies['refresh_token'];
if (!incomingToken) {
res.status(401).json({ error: 'Refresh Token não fornecido.' });
return;
}
const { accessToken, newRefreshToken } = await this.refreshToken.execute(incomingToken);
// Atualiza o cookie com o novo Refresh Token
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
res.json({ accessToken });
}
async logout(req: Request, res: Response): Promise<void> {
const token = req.cookies['refresh_token'];
if (token) {
// Revoga o token no banco para invalidar a sessão no servidor
await this.refreshTokensRepo.revokeByToken(token);
}
// Limpa o cookie
res.clearCookie('refresh_token', { path: '/auth/refresh' });
res.status(204).send();
}Axios Interceptor no Frontend
O frontend precisa interceptar erros 401 e renovar o token de forma transparente ao usuário. O segredo é usar uma fila para não fazer múltiplas chamadas de refresh simultâneas:
import axios from 'axios';
let accessToken: string | null = null; // Access Token em memória
let isRefreshing = false;
let failedQueue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true, // Necessário para enviar/receber cookies
});
// Request interceptor: injeta o Access Token em toda requisição
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor: lida com 401 e renova o token
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// Se já está renovando, enfileira a requisição
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// O cookie HttpOnly é enviado automaticamente pelo browser
const { data } = await api.post<{ accessToken: string }>('/auth/refresh');
accessToken = data.accessToken;
// Resolve a fila com o novo token
failedQueue.forEach(({ resolve }) => resolve(accessToken!));
failedQueue = [];
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh falhou — redireciona para login
failedQueue.forEach(({ reject }) => reject(refreshError));
failedQueue = [];
accessToken = null;
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export function setAccessToken(token: string) {
accessToken = token;
}
export function clearAccessToken() {
accessToken = null;
}O problema da fila é essencial. Se o usuário abre 5 abas e todas fazem requisições simultaneamente quando o Access Token expira, sem a fila você teria 5 chamadas de /auth/refresh em paralelo — a primeira renova com sucesso e rotaciona o Refresh Token, as outras 4 usam o token já revogado e detectam ataque de reutilização, encerrando a sessão indevidamente.
Conclusão
O fluxo de Refresh Tokens é o padrão da indústria para autenticação em SPAs e aplicações móveis por um motivo: combina segurança (Access Tokens de curta duração, revogação via banco) com boa UX (renovação transparente). Os pontos críticos para acertar são: armazenar o Refresh Token em HttpOnly Cookie, implementar rotação de tokens, detectar reutilização de tokens revogados, e usar uma fila no Axios para evitar múltiplas renovações simultâneas.