Voltar para Artigos
seguranca16 min de leitura

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.

12 de agosto de 2026

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 HttpOnly não são acessíveis via JavaScript — nem um XSS pode lê-los. Com Secure + 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

RefreshTokenModel.ts
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

AuthenticateUserUseCase.ts
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 },
    };
  }
}
AuthController.ts
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.

RefreshTokenUseCase.ts
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

AuthController.ts (continuação)
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:

api.ts
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.