A maioria dos tutoriais de JWT termina com jwt.sign() e um 200 OK. Produção começa onde o tutorial termina: como renovar tokens, como invalidar sessões, como armazenar tokens no cliente, e como proteger contra ataques.

Por que JWT + Refresh Token

Access tokens curtos (15 min) limitam a janela de exposição se um token for comprometido. Refresh tokens longos (7 dias) permitem renovar o access token sem pedir login novamente. O ciclo:

1. Usuário faz login → recebe access token (15min) + refresh token (7 dias)
2. Access token expira → cliente usa refresh token para renovar
3. Refresh token expira → usuário faz login novamente

Sem refresh tokens, você precisa de access tokens longos (perigoso) ou pedir login a cada 15 minutos (ruim para experiência).

Geração de tokens

import jwt from 'jsonwebtoken'

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET

function generateTokens(user: { id: string; email: string }) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  )

  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  return { accessToken, refreshToken }
}

Dois secrets diferentes. Se o refresh secret vazar, o access token não é afetado. Nunca use o mesmo secret para ambos.

Storage seguro no cliente

O armazenamento do access token no cliente é debatido, mas a prática segura é:

  • Access token: memória JavaScript (variável). Não localStorage (vulnerável a XSS), não cookie HttpOnly (compartilha entre abas, mas funciona para muitos casos).
  • Refresh token: cookie HttpOnly, Secure, SameSite=Strict. Nunca JavaScript deve acessar o refresh token.
// No login, armazena access token em memória
let accessToken: string | null = null

async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include',  // envia cookies
    body: JSON.stringify({ email, password })
  })
  const data = await res.json()
  accessToken = data.accessToken  // em memória, não em storage
}

// Em cada request, usa o access token
async function apiRequest(url: string, options: RequestInit = {}) {
  const res = await fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`
    }
  })

  if (res.status === 401) {
    // Access token expirado: tenta renovar
    const renewed = await refreshAccessToken()
    if (renewed) {
      return apiRequest(url, options)  // tenta novamente
    }
    window.location.href = '/login'
  }

  return res
}

Refresh token flow

async function refreshAccessToken(): Promise<boolean> {
  try {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include'  // envia cookie HttpOnly
    })

    if (!res.ok) return false

    const data = await res.json()
    accessToken = data.accessToken
    return true
  } catch {
    return false
  }
}

Invalidação de tokens

JWT por si só não suporta invalidação (é stateless). Para invalidar sessões, mantenha uma lista de tokens revogados no servidor:

// Ao fazer logout, armazena o token na blacklist
async function logout(token: string) {
  const decoded = jwt.decode(token)
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)

  // Armazena na blacklist com TTL igual ao tempo restante do token
  await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}

// Middleware verifica a blacklist
async function verifyToken(token: string) {
  const isBlacklisted = await redis.get(`blacklist:${token}`)
  if (isBlacklisted) throw new Error('Token revoked')

  return jwt.verify(token, ACCESS_SECRET)
}

Proteções obrigatórias

  • HTTPS em todas as rotas. Sem exceção. Tokens em HTTP são interceptados.
  • HttpOnly + Secure + SameSite em cookies de refresh.
  • Nunca armazenar tokens em localStorage. Vulnerável a XSS.
  • Validar iss, aud, e exp em cada request. Não confie apenas na assinatura.
  • Usar algoritmo forte (RS256) em produção. HS256 funciona mas é menos seguro para distribuído.

O fluxo completo

Login → access + refresh tokens. Request com access token. Se 401, tenta refresh. Se refresh falha, redireciona para login. Logout invalida ambos os tokens. Refresh tokens rodam em background antes de expirar.

Isso é o mínimo para autenticação JWT em produção. Tudo abaixo disso é prototype, não sistema de autenticação.