Tutorial de JWT costuma terminar em jwt.sign() e um 200 OK. Produção começa no ponto em que o tutorial para: renovação de token, revogação de sessão, armazenamento no cliente e os ataques que pegam implementação ingênua.

Dois tokens, duas durações

Access token de 15 minutos limita a janela de exposição se o token vazar. Refresh token de 7 dias renova o access token sem pedir login de novo. O RFC 6749, seção 6, define esse par como grant separado:

1. login         -> access token (15 min) + refresh token (7 dias)
2. access expira -> cliente chama /auth/refresh com o cookie
3. refresh expira -> usuário faz login de novo

Access token longo amplia a janela de roubo. Pedir login a cada 15 minutos manda o usuário pro suporte. O par curto e longo resolve os dois lados.

Geração com assinaturas separadas

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 }
}

Segredo único para os dois tokens transfere o vazamento de um para o outro. Chave de assinatura sai de variável de ambiente e não entra em commit.

O RFC 8725, Best Current Practice para JWT, recomenda fixar o algoritmo esperado na verificação. Sem essa fixação, a implementação aceita o campo alg do token como instrução, e um atacante monta token com alg: none ou troca HS256 por RS256 com a chave pública como segredo.

// algoritmo vem da sua configuração, do token
const payload = jwt.verify(token, ACCESS_SECRET, {
  algorithms: ['RS256'],
  issuer: 'https://api.example.com',
  audience: 'web-app',
})

Access token em memória, refresh token em cookie

  • Access token: variável em memória JavaScript. localStorage persiste o token para qualquer script XSS ler junto.
  • Refresh token: cookie com HttpOnly, Secure e SameSite=Strict. A referência do Set-Cookie na MDN descreve cada flag: HttpOnly tira o cookie do alcance de document.cookie, Secure restringe o envio a HTTPS, SameSite bloqueia o envio em requisição cross-site.
let accessToken: string | null = null

async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify({ email, password }),
  })
  const data = await res.json()
  accessToken = data.accessToken
}

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) {
    const renewed = await refreshAccessToken()
    if (renewed) return apiRequest(url, options)
    window.location.href = '/login'
  }

  return res
}

Renovação no 401

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

    if (!res.ok) return false

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

O cookie viaja sozinho, então o refresh não depende de nenhum estado no cliente. Duas chamadas simultâneas que disparam o refresh ao mesmo tempo podem se enfrentar quando a rotação está ligada; serialize as renovações numa fila de uma vaga por aba.

Revogação com lista negra e TTL

JWT confere assinatura sem consultar o servidor, e essa propriedade é justamente a que impede revogação automática. O RFC 8725 recomenda lista de negação para token revogado. No logout, guarde o token com o TTL igual ao tempo que falta para o exp dele:

async function logout(token: string) {
  const decoded = jwt.decode(token) as { exp: number }
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)

  await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}

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

  return jwt.verify(token, ACCESS_SECRET, { algorithms: ['RS256'] })
}

Refresh token pede a mesma atenção no logout: apague o cookie de refresh no cliente e invalide o valor do refresh no servidor. Sem isso, o refresh sobrevive ao logout e renova access token para uma sessão que o usuário encerrou.

Rotação do refresh token

Cada uso do refresh emite um refresh novo e invalida o anterior. O reuso de um refresh já consumido indica que alguém copiou o token, e a resposta certa é derrubar a árvore inteira de sessão do usuário.

O fluxo completo

Login entrega os dois tokens. A request carrega o access token no header e o cookie de refresh. Um 401 dispara a renovação; quando a renovação falha, o cliente manda o usuário para o login. Logout apaga os dois lados. O OWASP Authentication Cheat Sheet cobre o resto da lista: HTTPS em todas as rotas, expiração curta, senha com hash adequado e limite de tentativa.

Referências