JWT em Node.js: access token e refresh token

JWT (JSON Web Token) é o padrão para autenticação stateless em APIs REST, o servidor não precisa guardar sessões, a identidade do usuário viaja no próprio token. A combinação de access token de curta duração com refresh token rotativo é o padrão atual de segurança para APIs de produção.

Assinar e verificar JWTs com jose

jose é a biblioteca JWT recomendada para Node.js moderno (suporta Web Crypto API):

typescript
// npm install jose

import { SignJWT, jwtVerify, type JWTPayload } from 'jose'

const ACCESS_SECRET = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET!)
const REFRESH_SECRET = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!)

export interface TokenPayload extends JWTPayload {
  userId: string
  email: string
  role: string
}

// Gerar access token (curta duração — 15 minutos):
export async function gerarAccessToken(payload: Omit<TokenPayload, keyof JWTPayload>) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .setIssuer('minha-api')
    .setAudience('web-app')
    .sign(ACCESS_SECRET)
}

// Gerar refresh token (longa duração — 30 dias):
export async function gerarRefreshToken(userId: string) {
  return new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('30d')
    .setJti(crypto.randomUUID())  // JWT ID único para revogação
    .sign(REFRESH_SECRET)
}

// Verificar e decodificar access token:
export async function verificarAccessToken(token: string): Promise<TokenPayload> {
  const { payload } = await jwtVerify(token, ACCESS_SECRET, {
    issuer: 'minha-api',
    audience: 'web-app',
  })
  return payload as TokenPayload
}

Middleware de autenticação no Express

Proteger rotas com middleware que valida o JWT:

typescript
// middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import { verificarAccessToken, type TokenPayload } from '../lib/jwt'

// Estender o tipo Request para incluir o usuário:
declare global {
  namespace Express {
    interface Request {
      usuario?: TokenPayload
    }
  }
}

export async function autenticar(req: Request, res: Response, next: NextFunction) {
  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 {
    req.usuario = await verificarAccessToken(token)
    next()
  } catch (err) {
    // Distinguir token expirado de token inválido:
    const isExpired = (err as Error).name === 'JWTExpired'
    return res.status(401).json({
      error: isExpired ? 'Token expirado' : 'Token inválido',
      code: isExpired ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID',
    })
  }
}

// Middleware de autorização por role:
export function autorizar(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!roles.includes(req.usuario?.role ?? '')) {
      return res.status(403).json({ error: 'Permissão negada' })
    }
    next()
  }
}

// Usar nas rotas:
app.get('/api/admin', autenticar, autorizar('admin'), handler)

Refresh token rotativo com Redis

Renovar o access token sem exigir novo login, com proteção contra roubo:

typescript
// Armazenar refresh tokens no Redis para revogação:
import { redis } from '../lib/redis'

// Ao fazer login:
app.post('/api/auth/login', async (req, res) => {
  const usuario = await autenticarUsuario(req.body.email, req.body.password)

  const accessToken = await gerarAccessToken({
    userId: usuario.id,
    email: usuario.email,
    role: usuario.role,
  })
  const refreshToken = await gerarRefreshToken(usuario.id)

  // Armazenar refresh token no Redis (30 dias):
  const jti = extrairJti(refreshToken)  // decodificar sem verificar para pegar jti
  await redis.setex(`refresh:${jti}`, 30 * 24 * 3600, usuario.id)

  // Enviar refresh token como httpOnly cookie (mais seguro que body):
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,
    path: '/api/auth',  // só enviado para rotas de auth
  })

  res.json({ accessToken, expiresIn: 900 })
})

// Renovar access token:
app.post('/api/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refresh_token
  if (!refreshToken) return res.status(401).json({ error: 'Refresh token ausente' })

  try {
    const { payload } = await jwtVerify(refreshToken, REFRESH_SECRET)
    const jti = payload.jti!

    // Verificar se o token está no Redis (não foi revogado):
    const userId = await redis.get(`refresh:${jti}`)
    if (!userId) return res.status(401).json({ error: 'Refresh token inválido ou revogado' })

    // Rotação: deletar o token atual e gerar um novo par:
    await redis.del(`refresh:${jti}`)

    const usuario = await buscarUsuario(userId)
    const novoAccessToken = await gerarAccessToken({ userId, email: usuario.email, role: usuario.role })
    const novoRefreshToken = await gerarRefreshToken(userId)

    const novoJti = extrairJti(novoRefreshToken)
    await redis.setex(`refresh:${novoJti}`, 30 * 24 * 3600, userId)

    res.cookie('refresh_token', novoRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth', maxAge: 30 * 24 * 60 * 60 * 1000 })
    res.json({ accessToken: novoAccessToken, expiresIn: 900 })
  } catch {
    res.status(401).json({ error: 'Refresh token expirado' })
  }
})

Logout e revogação de tokens

Invalidar tokens antes do vencimento natural:

typescript
// Logout: revogar refresh token e bloquear access token até expirar
app.post('/api/auth/logout', autenticar, async (req, res) => {
  const refreshToken = req.cookies.refresh_token

  // Revogar refresh token:
  if (refreshToken) {
    try {
      const { payload } = await jwtVerify(refreshToken, REFRESH_SECRET)
      await redis.del(`refresh:${payload.jti}`)
    } catch {
      // Token já expirado — não precisa fazer nada
    }
  }

  // Adicionar access token à blacklist até ele expirar naturalmente:
  // (necessário para logout imediato — sem isso, o access token ainda é válido)
  const accessToken = req.headers.authorization!.slice(7)
  const exp = req.usuario!.exp!
  const ttl = exp - Math.floor(Date.now() / 1000)
  if (ttl > 0) {
    await redis.setex(`blacklist:${req.usuario!.jti ?? accessToken}`, ttl, '1')
  }

  // Limpar cookie:
  res.clearCookie('refresh_token', { path: '/api/auth' })
  res.json({ message: 'Logout realizado com sucesso' })
})

// Adicionar verificação de blacklist no middleware:
export async function autenticar(req, res, next) {
  // ... verificar token ...
  const naBlacklist = await redis.exists(`blacklist:${payload.jti}`)
  if (naBlacklist) return res.status(401).json({ error: 'Token revogado' })
  // ...
}

// Revogar TODOS os tokens de um usuário (ex: troca de senha):
async function revogarTodosTokens(userId: string) {
  const keys = await redis.keys(`refresh:*`)
  for (const key of keys) {
    if (await redis.get(key) === userId) await redis.del(key)
  }
}

Boas práticas de segurança para JWT

Erros comuns e como evitá-los:

typescript
// ❌ ERRO: usar algoritmo "none" (sem assinatura):
// Nunca aceite tokens com alg: "none" — desative explicitamente

// ❌ ERRO: segredo fraco:
// JWT_SECRET=secret → facilmente quebrado por brute force
// ✅ CORRETO: segredo forte (32+ bytes aleatórios):
// openssl rand -base64 32

// ❌ ERRO: armazenar dados sensíveis no payload:
// { userId: '123', senha: 'hash' }  ← payload é base64, não criptografado!
// O payload de um JWT é VISÍVEL para qualquer um que tenha o token.
// ✅ CORRETO: apenas dados não-sensíveis + identificadores:
// { userId: '123', role: 'user', email: 'user@example.com' }

// ❌ ERRO: access token de longa duração:
// .setExpirationTime('30d')  ← se vazar, atacante tem 30 dias
// ✅ CORRETO: access token curto + refresh token:
// .setExpirationTime('15m')  ← janela de exposição mínima

// ❌ ERRO: armazenar JWT no localStorage:
// localStorage.setItem('token', jwt)  ← vulnerável a XSS
// ✅ CORRETO: access token em memória (variável JS), refresh em httpOnly cookie

// ❌ ERRO: não verificar issuer e audience:
// await jwtVerify(token, secret)  ← aceita tokens de outras APIs
// ✅ CORRETO:
// await jwtVerify(token, secret, { issuer: 'minha-api', audience: 'web-app' })

// ✅ Rotação automática no cliente (React/Vue):
// Interceptar 401 com código TOKEN_EXPIRED,
// chamar /api/auth/refresh, repetir a requisição original,
// nunca pedir login novamente (seamless refresh)

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

JWT (stateless): sem estado no servidor, escala horizontalmente sem sticky sessions, funciona com múltiplas instâncias/microserviços, bom para SPAs e mobile. Sessões (stateful): mais fácil de revogar instantaneamente, menor payload nas requisições, estado centralizado. Para APIs REST consumidas por frontend SPA ou app mobile: JWT com refresh token é o padrão moderno. Para aplicações web tradicionais com server-side rendering: sessões podem ser mais simples.

JWTs não podem ser revogados individualmente sem estado no servidor. Com access token de 15min: se vazar, o atacante tem 15 minutos de janela. Com token de 30 dias: 30 dias de janela. O refresh token tem vida longa mas fica em cookie httpOnly (inacessível a JavaScript), enquanto o access token fica em memória (sem persistência, limpo ao fechar a aba).

jsonwebtoken: a mais usada, madura, mas usa callbacks/sync e não suporta Web Crypto API nativamente. jose: moderna, usa Promises + async/await, suporta Web Crypto (compatível com Edge Runtime, Cloudflare Workers, Deno), implementa JWS, JWE, JWK e JWKS. Para Node.js novo: jose. Para código legado que já usa jsonwebtoken: manter jsonwebtoken é razoável. Para Edge/Serverless: jose é obrigatório.

A chave secreta deve ter no mínimo 256 bits (32 bytes aleatórios): openssl rand -base64 32. Armazene como variável de ambiente (JWT_ACCESS_SECRET), nunca no código. Use chaves diferentes para access e refresh tokens, se um vazar, o outro permanece seguro. Para escala: use chaves assimétricas RS256 (par público/privado), o endpoint público de verificação usa a chave pública sem expor a privada.

Apenas dados necessários para autorização na maioria das requisições: userId, email, role. Não coloque: senha (mesmo hashed), dados pessoais extensos, informações que mudam frequentemente. O payload aumenta o tamanho de cada header HTTP. Para autorização mais detalhada: armazene permissões granulares em banco e consulte por userId quando necessário, não coloque no JWT.