Destino Dev

Blog

Autenticação SSR em Nuxt 3 com refresh tokens rotativos e server routes

Por John Pires · 13 de março de 2026

Contexto e motivacao

Autenticar aplicações Nuxt 3 em modo SSR exige equilíbrio entre segurança, experiência do usuário e complexidade operacional. Para aplicações reais precisamos de tokens curtos para acesso e refresh tokens para manter sessões longas sem obrigar o usuário a logar frequentemente. O uso de refresh tokens rotativos reduz o risco de replay e melhora a detecção de roubo de tokens.

Este artigo foca em um pattern prático e aplicável: cookies httpOnly para refresh token, tokens de acesso JWT curtos para APIs, endpoints server routes no Nitro para trocar tokens e lógica de rotação que invalida refresh tokens usados.

Não vou explicar conceitos básicos de JWT ou cookies; assumo que você já conhece. O objetivo é mostrar como integrar tudo no fluxo SSR do Nuxt 3, com exemplos de server routes, composable de auth e medidas práticas de segurança para produção.

Por que escolher refresh rotativos

Refresh tokens rotativos mitigam duas ameaças principais: replay e uso subsequente de um refresh token roubado. Cada uso de refresh emite um novo refresh token e invalida o anterior. Se um token antigo for reaproveitado, detectamos e forçamos logout ou bloqueio da conta.

Além disso, a rotação reduz janela de exposição. Mesmo que alguém consiga copiar um refresh token, ele perde validade depois do primeiro uso legítimo.

Visão prática do que faremos

Vamos construir um fluxo com estes componentes:

  • Server routes em /server/api para login, refresh e logout
  • Cookies httpOnly para armazenar refresh tokens (rotacionados)
  • JWT de acesso curto retornado ao cliente e enviado nas chamadas API via server routes do Nuxt
  • Composable useAuth que funciona no cliente e no servidor (SSR)

Como funciona por baixo dos panos

Arquitetonicamente, a aplicação SSR tem duas camadas: o rendering no servidor Nitro do Nuxt e as chamadas a APIs back-end. O objetivo é manter credenciais sensíveis fora do JavaScript do cliente e usar cookies httpOnly para refresh tokens. O fluxo típico:

  1. Usuário faz POST /api/login com credenciais
  2. Servidor retorna access token no corpo e seta refresh token httpOnly no cookie
  3. SSR usa access token para chamadas a APIs durante render
  4. Quando access expira, SSR ou client chama /api/refresh para obter novo access e novo refresh

Detalhes dos tokens

Access token: curto (p.ex. 15m), assinado, contém claims essenciais. Não armazenar refresh token no JS. Refresh token: longo (p.ex. 30d), armazenado em cookie httpOnly, identificado por jti ou id armazenado no banco/redis. Armazenar apenas hash do refresh token no servidor para evitar exposicao.

Ao rotacionar, eliminar o registro antigo e persistir o novo hash. Implementar lógica de 'used' flag e detecção de reuse: se um token já foi usado e aparece novamente, tratar como comprometido.

Fluxo SSR e server routes

Ao fazer SSR, o servidor do Nuxt já tem acesso ao cookie httpOnly do cliente. Um server route pode ler esse cookie e, se necessário, trocar o refresh por um novo access automaticamente antes de fazer chamadas a APIs externas. Isso evita expor refresh ao browser e mantém a renderização consistente.

Implementar um wrapper para chamadas internas que garante refresh automático no servidor simplifica o código da aplicação e centraliza a lógica.

Implementacao passo a passo

1) Exemplo: endpoint de login (server route)

O arquivo abaixo mostra um server route que valida credenciais, emite access token e seta cookie httpOnly com o refresh token rotativo. Use h3 event e useCookie do Nuxt.

// server/api/auth/login.post.ts
import { sign } from 'jsonwebtoken'
import { hash } from 'bcryptjs'
import { setCookie } from 'h3'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const { email, password } = body

  // validar credenciais contra seu DB
  const user = await findUserByEmail(email)
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return sendError(event, createError({ statusCode: 401, statusMessage: 'invalid credentials' }))
  }

  const accessToken = sign({ sub: user.id, role: user.role }, process.env.ACCESS_SECRET, { expiresIn: '15m' })

  // refresh token com id randômico. Aqui haplotype simples; em produção armazene hash no DB
  const refreshId = crypto.randomUUID()
  const refreshToken = sign({ sub: user.id, jti: refreshId }, process.env.REFRESH_SECRET, { expiresIn: '30d' })

  // armazenar hash do refresh token no DB/redis com jti
  await storeRefreshTokenHash(refreshId, await hash(refreshToken, 10), { userId: user.id, expiresIn: 30 * 24 * 3600 })

  // set cookie httpOnly; path e secure devem ser configurados conforme ambiente
  setCookie(event, 'refresh_token', refreshToken, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 30 * 24 * 3600, secure: process.env.NODE_ENV === 'production' })

  return { accessToken, user: { id: user.id, email: user.email } }
})

O que este bloco faz: autentica, emite tokens e grava hash do refresh no armazenamento. O cookie httpOnly contém o refresh token inteiro, mas o servidor guarda apenas o hash para validação posterior.

Depois disso, o cliente recebe o access token e o cookie é enviado automaticamente em requests subsequentes.

2) Exemplo: endpoint de refresh rotativo

Este server route trata rotação: valida o refresh token, verifica se o jti ainda é válido, emite novos tokens e marca o jti antigo como usado/invalido.

// server/api/auth/refresh.post.ts
import { verify, sign } from 'jsonwebtoken'
import { compare, hash } from 'bcryptjs'
import { setCookie, clearCookie } from 'h3'

export default defineEventHandler(async (event) => {
  const refreshCookie = getCookie(event, 'refresh_token')
  if (!refreshCookie) return sendError(event, createError({ statusCode: 401 }))

  let payload
  try {
    payload = verify(refreshCookie, process.env.REFRESH_SECRET)
  } catch (err) {
    // token inválido
    clearCookie(event, 'refresh_token')
    return sendError(event, createError({ statusCode: 401 }))
  }

  const { sub: userId, jti } = payload as any

  // buscar hash salvo e comparar
  const stored = await lookupRefreshTokenHash(jti)
  if (!stored) {
    // possível replay attack -> invalidar sessão global
    clearCookie(event, 'refresh_token')
    await handlePossibleTokenReuse(userId)
    return sendError(event, createError({ statusCode: 401, statusMessage: 'token reuse detected' }))
  }

  const ok = await compare(refreshCookie, stored.hash)
  if (!ok) {
    clearCookie(event, 'refresh_token')
    await handlePossibleTokenReuse(userId)
    return sendError(event, createError({ statusCode: 401 }))
  }

  // token válido: rotacionar
  await invalidateRefreshToken(jti)

  const newJti = crypto.randomUUID()
  const newRefreshToken = sign({ sub: userId, jti: newJti }, process.env.REFRESH_SECRET, { expiresIn: '30d' })
  await storeRefreshTokenHash(newJti, await hash(newRefreshToken, 10), { userId, expiresIn: 30 * 24 * 3600 })

  const newAccess = sign({ sub: userId }, process.env.ACCESS_SECRET, { expiresIn: '15m' })

  setCookie(event, 'refresh_token', newRefreshToken, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 30 * 24 * 3600, secure: process.env.NODE_ENV === 'production' })

  return { accessToken: newAccess }
})

O que acontece aqui: verificamos e comparamos hash, invalidamos o registro antigo e geramos novos tokens. Em caso de reuse detectado, tratamos como incidente de segurança.

3) Composable useAuth

Composable que centraliza login, logout e SSR hydration. Ele deve funcionar no cliente e no servidor; aproveite useState para manter o user no store reativo do Nuxt.

// composables/useAuth.ts
export const useAuth = () => {
  const user = useState('auth.user', () => null)

  const login = async (email: string, password: string) => {
    const { data, error } = await useFetch('/api/auth/login', { method: 'POST', body: { email, password } })
    if (error.value) throw error.value
    user.value = data.value.user
    // access token é retornado no body se precisar para fetch cliente
    return data.value
  }

  const logout = async () => {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
  }

  const fetchUser = async () => {
    // rota server-side que retorna user baseada no access token ou refresh
    const { data } = await useFetch('/api/auth/me')
    user.value = data.value?.user ?? null
    return user.value
  }

  return { user, login, logout, fetchUser }
}

Na prática, fetchUser será chamado durante SSR em um plugin ou middleware para hidratar o estado antes do render.

Patterns e boas praticas

Armazenamento seguro

Armazene apenas hashes de refresh tokens no servidor. Nunca persista o token em texto claro. Use bcrypt ou argon2. Use Redis com TTL para performance em aplicações com alta escala.

Cookies httpOnly reduzem exposição a XSS. Combine com SameSite=Lax e Secure em produção. Para aplicações com subdomínios ou multi-site, ajuste o domain e path conscientemente.

Deteccao de reuse e mitigacao

Quando detectar reuse de um refresh token (jti inválido ou token já usado), considere estas ações:

  • Invalidar todas as sessões do usuário
  • Forçar reset de senha se houver indícios de comprometimento
  • Logar evento com metadados (IP, UA, timestamp)
Regra prática: trate reuse como incidente crítico, porque ele evidencia que um token foi copiado e usado fora do fluxo legitimo.

Server-side wrapper para chamadas API

Crie um wrapper server-side para chamadas a APIs externas que automaticamente usa o access token atual e tenta refresh se necessário. Isso centraliza a lógica e evita duplicação.

// server/utils/proxyFetch.ts
import { getCookie } from 'h3'

export async function proxyFetch(event, url, opts = {}) {
  // obter access do header ou tentar SSR refresh
  let access = getAccessFromSomewhere(event)
  let resp = await $fetch(url, { headers: { Authorization: `Bearer ${access}` }, ...opts })

  if (resp?.status === 401) {
    // tentar refresh via server route
    const refresh = await $fetch('/api/auth/refresh', { method: 'POST' })
    access = refresh.accessToken
    resp = await $fetch(url, { headers: { Authorization: `Bearer ${access}` }, ...opts })
  }

  return resp
}

Esse pattern garante que SSR não falhe por token expirado e evita lógica repetida nos componentes.

Armadilhas comuns e como evitar

XSS vs CSRF

Armazenar refresh token em cookie httpOnly evita XSS, mas cookies são sujeitos a CSRF. Mitigue com uma das abordagens:

  • CSRF token sincronizado (double submit cookie)
  • Somente aceitar refresh via server route que verifica origem e user agent
  • Limitar refresh endpoint a POST e SameSite=Lax

Se sua API for acessada via fetch com credentials, considere usar um header custom apenas em chamadas internas do seu frontend e validar no servidor.

Condições de corrida ao rotacionar

Problema: duas requisições simultâneas usando o mesmo refresh token podem gerar dois novos tokens e permitir replay. Solução:

  • Use locking por jti no Redis ou DB para serializar o uso do refresh token
  • Marcar o token como 'pending' e rejeitar usos concorrentes
  • Detectar reuse e forçar logout

Sem controle, você terá inconsistências e brechas de segurança.

Cookies em ambientes locais e proxys

Em dev, Secure cookie pode bloquear testes locais. Configure logicamente: apenas setar Secure em producao. Se usar proxy (nginx) verifique headers de forwarded e padronize cookie domain/path.

Performance: evitar refresh em cada SSR

Não chame /refresh a cada SSR. Verifique a validade do access token localmente e só faça refresh quando o access estiver realmente expirado. Para isso, armazene exp do access token no estado SSR.

Armazenamento e exemplo simples de in-memory store (para testes)

Store minimal para desenvolvimento

Um exemplo simples de armazenamento em memória. Não usar em produção, apenas para testar localmente a lógica de rotação.

// server/utils/tokenStore.ts
const store = new Map()

export async function storeRefreshTokenHash(jti, hash, meta) {
  store.set(jti, { hash, meta, createdAt: Date.now() })
}

export async function lookupRefreshTokenHash(jti) {
  return store.get(jti)
}

export async function invalidateRefreshToken(jti) {
  store.delete(jti)
}

export async function handlePossibleTokenReuse(userId) {
  // ação mínima: invalidar todos os tokens do usuário
  for (const [k, v] of store) {
    if (v.meta.userId === userId) store.delete(k)
  }
}

Isso demonstra a API do store. Em produção substitua por Redis ou um DB relacional com índices e TTLs.

Conclusao e recomendações práticas

Implementar autenticação SSR em Nuxt 3 com refresh tokens rotativos exige atenção a detalhes: cookies httpOnly, armazenamento de hashes, detecção de reuse e lock para evitar condições de corrida. Server routes do Nuxt/Nitro tornam o fluxo natural porque permitem operar no contexto do request e manipular cookies server-side sem expor tokens ao cliente.

Recomendações finais:

  • Use hashes no servidor para refresh tokens
  • Implemente rotação e detecção de reuse com logs e ações de mitigação
  • Centralize refresh e proxy de API em server routes para simplificar SSR
  • Armazene access com exp curto e refresh com lifecycle controlado via Redis/DB

Seguindo esse pattern você terá um sistema mais resistente a roubo de tokens e um SSR previsível. O código mostrado é um ponto de partida: adapte a persistência para sua infra (Redis, RDS), acrescente monitoramento e testes de ataque para elevar a segurança.

Se precisar, posso transformar esses exemplos em um template de módulo Nuxt 3 que encapsula store e server routes com opções configuráveis.

Assuntos: Nuxt.js, ssr, auth, refresh-tokens, server-routes

Leia também