# Autenticação em aplicações web: sessões, JWT, refresh tokens e OAuth 2.0

> Como escolher entre sessão opaca e token autocontido, rotacionar refresh tokens com detecção de reuso por família, guardar credenciais no navegador e usar Authorization Code com PKCE, com rota de refresh em TypeScript e Next.js.

URL: https://www.mfactor.dev/blog/autenticacao-sessoes-jwt-refresh-tokens-oauth
Autor: Alexandre Martins
Publicado em: 2026-11-11
Assuntos: Security, Authentication, OAuth, Next.js, Web Development
Site: MFactor — https://www.mfactor.dev

O HTTP não lembra nada entre uma requisição e a seguinte. Toda aplicação com login precisa provar a identidade do usuário uma vez e carregar essa prova nas requisições seguintes sem repetir a senha. O projeto da autenticação é o projeto do ciclo de vida dessa credencial: onde ela nasce, onde fica guardada, como é verificada e como morre antes do prazo.

As duas respostas clássicas têm custos opostos. O identificador de sessão opaco é fácil de revogar e exige uma consulta por requisição. O token autocontido é verificado sem consulta e não pode ser revogado antes de expirar. Boa parte das falhas vem de escolher um lado e ignorar o custo dele.

> Como manter um usuário autenticado entre requisições de forma que a verificação seja barata, a revogação seja possível e o roubo de uma credencial tenha alcance limitado?

## Sessão opaca e token autocontido

Na sessão clássica, o servidor gera um identificador aleatório, guarda o estado associado em um banco ou Redis e devolve o identificador em um cookie. O OWASP Session Management Cheat Sheet pede pelo menos 64 bits de entropia de um gerador criptográfico, regeneração do identificador após o login e timeouts de inatividade e absoluto no servidor. A revogação é um DELETE, e o custo é a consulta por requisição em um armazenamento compartilhado entre réplicas.

O JSON Web Token (JWT), especificado na RFC 7519, carrega as afirmações (claims) no próprio token, em três partes em base64url: cabeçalho, corpo e assinatura. As claims registradas incluem iss (emissor), sub (sujeito), aud (destinatário), exp (expiração) e jti (identificador). Qualquer serviço com a chave verifica o token sem consultar ninguém, e o custo é a revogação: um JWT roubado vale até exp. A RFC exige rejeitar o token quando qualquer passo da validação falha e deixa à aplicação a lista de algoritmos aceitos, o que inclui recusar none.

A combinação dos dois é a prática consolidada. Um access token em JWT com vida de minutos é verificado sem estado pelas APIs, e um refresh token opaco, guardado no servidor e revogável, obtém o próximo access token. A revogação passa a ter o atraso máximo de um access token.

## Refresh token com rotação e detecção de reuso

A RFC 6749, na seção 10.4, descreve a rotação: a cada refresh, o servidor emite um refresh token novo e invalida o anterior, mas o retém. Se um token roubado for usado pelo atacante e pelo cliente legítimo, um dos dois apresentará um token invalidado, e o servidor saberá da violação. A RFC 9700, Best Current Practice de segurança do OAuth 2.0, torna a regra obrigatória para clientes públicos: rotação ou refresh tokens vinculados ao cliente por criptografia.

A mesma RFC descreve a resposta ao reuso. O servidor não sabe qual das partes é o atacante, então revoga o refresh token ativo e, por extensão, todos os tokens da mesma concessão. A implementação usual agrupa os tokens em uma família criada no login. Cada rotação adiciona um membro e marca o anterior como usado, e um token usado que volta revoga a família inteira. O custo é um login extra do cliente legítimo em caso de ataque.

O servidor guarda apenas o hash do refresh token, para que um vazamento do banco não entregue tokens utilizáveis. A RFC 9700 recomenda ainda expiração por inatividade e revogação em troca de senha e logout.

## Armazenamento no navegador

Um token guardado em localStorage é legível por qualquer script da origem, e um ataque de cross-site scripting (XSS) que execute uma linha de JavaScript o copia para fora. O OWASP recomenda não guardar identificadores de sessão nem JWTs em localStorage ou sessionStorage. As alternativas são cookies com HttpOnly, Secure e SameSite, ou o padrão backend-for-frontend, em que o navegador só vê um cookie de sessão e o servidor intermediário guarda os tokens.

O cookie resolve o XSS de leitura e abre o cross-site request forgery (CSRF), porque o navegador o envia em qualquer requisição à origem. SameSite=Strict ou Lax impede o envio em requisições iniciadas por outros sites, e o prefixo __Host- impede que subdomínios o sobrescrevam. Nenhuma dessas medidas impede um XSS de usar a sessão enquanto a página está aberta, e por isso a Content Security Policy continua necessária.

## OAuth 2.0 e OpenID Connect

A RFC 6749 define quatro papéis: resource owner (o usuário), client (a aplicação), authorization server (quem emite tokens) e resource server (a API). OAuth 2.0 é autorização delegada: o resultado é um access token que a aplicação usa na API em nome do usuário, e esse token não diz à aplicação quem é o usuário.

O OpenID Connect (OIDC) adiciona autenticação sobre o OAuth 2.0. Com o escopo openid, o authorization server devolve também um ID token, um JWT assinado com iss, sub, aud, exp e iat. O ID token é para a aplicação, o access token é para a API.

O fluxo recomendado é o Authorization Code com Proof Key for Code Exchange (PKCE), da RFC 7636. O cliente gera um code_verifier aleatório e envia o code_challenge, o SHA-256 do verifier em base64url, na requisição de autorização. Na troca do código por tokens, apresenta o verifier, e o servidor confere o hash. Um código interceptado no redirecionamento não serve sem ele. A RFC 9700 exige PKCE para clientes públicos e recomenda abandonar o implicit grant.

Um provedor (Keycloak, Auth0, Supabase Auth) é adequado quando o produto precisa de login social, SSO corporativo, autenticação multifator ou conformidade auditada, ao custo da dependência de um sistema externo. Mesmo assim, a aplicação continua responsável por verificar assinatura, iss, aud e exp de cada token que aceita.

## Exemplo em TypeScript e Next.js

A tabela a seguir guarda os refresh tokens por família, apenas como hash.

```mermaid
flowchart TD
  req["POST /api/auth/refresh com cookie"] --> existe{"Cookie presente?"}
  existe -- "não" --> r401a["401"]
  existe -- "sim" --> busca["SELECT pelo hash FOR UPDATE"]
  busca --> achou{"Token conhecido?"}
  achou -- "não" --> r401a
  achou -- "sim" --> usado{"Já usado ou revogado?"}
  usado -- "sim" --> familia["Revoga a família inteira"]
  familia --> apaga["Apaga o cookie"]
  apaga --> r401b["401: login necessário"]
  usado -- "não" --> expirou{"Expirado?"}
  expirou -- "sim" --> r401a
  expirou -- "não" --> rotaciona["Marca usado e insere o próximo na família"]
  rotaciona --> resposta["Novo cookie e access token de 10 min"]
```

```sql
CREATE TABLE refresh_token (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  familia_id  UUID        NOT NULL,            -- uma família por login
  usuario_id  UUID        NOT NULL,
  hash        TEXT        NOT NULL UNIQUE,     -- SHA-256 do valor entregue ao cliente
  expira_em   TIMESTAMPTZ NOT NULL,
  usado_em    TIMESTAMPTZ,                     -- preenchido na rotação
  revogado_em TIMESTAMPTZ
);

CREATE INDEX refresh_token_familia_idx ON refresh_token (familia_id);
```

A rota de refresh lê o cookie, bloqueia a linha com FOR UPDATE para que dois refreshes simultâneos não se atropelem, e decide entre desconhecido, reuso ou rotação.

```ts
// app/api/auth/refresh/route.ts
import { cookies } from "next/headers";
import { createHash, randomBytes } from "node:crypto";
import { transacao } from "@/lib/db";
import { emitirAccessToken } from "@/lib/jwt";

const VALIDADE_DIAS = 30;
const hash = (valor: string) => createHash("sha256").update(valor).digest("hex");
const naoAutorizado = () => new Response(null, { status: 401 });

export async function POST() {
  const jar = await cookies();
  const bruto = jar.get("__Host-refresh")?.value;
  if (!bruto) return naoAutorizado();

  return transacao(async (db) => {
    const { rows: [atual] } = await db.query(
      "SELECT * FROM refresh_token WHERE hash = $1 FOR UPDATE", [hash(bruto)]);
    if (!atual) return naoAutorizado();

    if (atual.usado_em || atual.revogado_em) {   // reuso: alguém tem uma cópia deste token
      await db.query("UPDATE refresh_token SET revogado_em = now() WHERE familia_id = $1 AND revogado_em IS NULL",
        [atual.familia_id]);
      jar.delete("__Host-refresh");
      return naoAutorizado();
    }
    if (atual.expira_em < new Date()) return naoAutorizado();

    const proximo = randomBytes(32).toString("base64url");   // rotação na mesma família
    await db.query("UPDATE refresh_token SET usado_em = now() WHERE id = $1", [atual.id]);
    await db.query(
      "INSERT INTO refresh_token (familia_id, usuario_id, hash, expira_em) VALUES ($1, $2, $3, now() + make_interval(days => $4))",
      [atual.familia_id, atual.usuario_id, hash(proximo), VALIDADE_DIAS]);

    jar.set("__Host-refresh", proximo, {
      httpOnly: true, secure: true, sameSite: "strict", path: "/", maxAge: VALIDADE_DIAS * 86400 });
    const accessToken = await emitirAccessToken(atual.usuario_id);
    return Response.json({ access_token: accessToken, token_type: "Bearer", expires_in: 600 });
  });
}
```

O access token é um JWT de 10 minutos. A verificação fixa algoritmo, emissor e destinatário, o que rejeita alg none, tokens de outro emissor e tokens emitidos para outra API.

```ts
// lib/jwt.ts
import { SignJWT, jwtVerify } from "jose";

const chave = new TextEncoder().encode(process.env.JWT_SECRET);   // HS256 em emissor único; com vários serviços, chave assimétrica e JWKS
const EMISSOR = "https://auth.exemplo.com";
const API = "api.exemplo.com";

export function emitirAccessToken(usuarioId: string) {
  return new SignJWT({ scope: "pedidos:ler pedidos:escrever" })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuer(EMISSOR).setAudience(API).setSubject(usuarioId)
    .setIssuedAt().setExpirationTime("10m")
    .sign(chave);
}

export async function verificarAccessToken(token: string) {
  const { payload } = await jwtVerify(token, chave, {
    issuer: EMISSOR, audience: API, algorithms: ["HS256"],   // lista fixa: nenhum outro alg passa
  });
  return payload;
}
```

## Desafios

Duas abas que fazem refresh ao mesmo tempo produzem um falso positivo de reuso: a segunda apresenta um token que a primeira acabou de marcar como usado. O cliente deve serializar os refreshes em um único voo (single flight), e o servidor pode tolerar o token recém-usado por poucos segundos.

O logout com JWT é incompleto: apagar o cookie e revogar a família impede novos access tokens, mas o access token em uso vale até exp. Uma lista de jti revogados consultada pelas APIs encerra de imediato, mas reintroduz o estado que o JWT dispensava.

A rotação de chaves de assinatura exige o cabeçalho kid e um endpoint JWKS, para que as APIs aceitem tokens da chave antiga até que expirem. Diferenças de relógio entre emissor e verificador pedem tolerância de alguns segundos em exp e nbf.

Tokens aparecem em lugares que ninguém planejou: logs de acesso com a query string, cabeçalhos capturados pelo monitoramento, URLs de redirecionamento. Access token no cabeçalho Authorization, refresh token em cookie e mascaragem nos logs reduzem a superfície.

## Boas práticas

1. Use access tokens curtos verificados sem estado e refresh tokens opacos, revogáveis e guardados no servidor como hash.
2. Rotacione o refresh token a cada uso e revogue a família inteira ao detectar reuso.
3. Guarde credenciais em cookies com HttpOnly, Secure, SameSite e prefixo __Host-, nunca em localStorage.
4. Fixe a lista de algoritmos, o emissor e o destinatário na verificação de todo JWT.
5. Use Authorization Code com PKCE e não use o implicit grant.
6. Revogue refresh tokens em troca de senha, logout e mudança de privilégio.
7. Trate o ID token como prova de identidade para a aplicação e o access token como credencial para a API.

## Conclusão

Autenticação é a gestão do ciclo de vida de uma credencial, e as decisões de projeto são sobre onde fica o estado e quanto tempo cada credencial vale. A sessão opaca concentra o estado no servidor, e o JWT o distribui para quem verifica. O par access token curto e refresh token rotacionado coloca o estado onde a revogação precisa dele e o tira de onde a verificação precisa ser barata.

A detecção de reuso por família transforma a rotação em defesa: um token roubado deixa de ser uma sessão permanente e passa a ser um alarme. Com cookies bem configurados, PKCE e verificação estrita de cada token, o roubo de uma credencial tem alcance medido em minutos, e não em semanas.

## Referências

- IETF, RFC 6749: The OAuth 2.0 Authorization Framework, seção 10.4. https://www.rfc-editor.org/rfc/rfc6749.html
- IETF, RFC 7519: JSON Web Token (JWT). https://www.rfc-editor.org/rfc/rfc7519.html
- IETF, RFC 7636: Proof Key for Code Exchange by OAuth Public Clients. https://www.rfc-editor.org/rfc/rfc7636.html
- IETF, RFC 9700: Best Current Practice for OAuth 2.0 Security, seção 4.14. https://www.rfc-editor.org/rfc/rfc9700.html
- OpenID Foundation, OpenID Connect Core 1.0. https://openid.net/specs/openid-connect-core-1_0.html
- OWASP, Session Management Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
