Saltar para o conteúdo
Autenticação com JWT e Refresh Tokens no Express
Node.js

Autenticação com JWT e Refresh Tokens no Express

20 de agosto de 2024·Paulo de Paula

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 token

O 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áticaPor quê
Access token em memóriaSem XSS via localStorage
Refresh token httpOnlyJavaScript não acessa
Rotação obrigatóriaDetecta roubo de token
TTL curto no access (15min)Limita janela de exploração
path restrito no cookieRefresh token não vaza em outras requisições
Revogar família ao detectar replayInvalida 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).