Ir para o conteúdo
·28 min de leitura

Autenticação Moderna em 2026: JWT, Sessions, OAuth e Passkeys

O panorama completo de autenticação: quando usar sessions vs JWT, fluxos OAuth 2.0 / OIDC, rotação de refresh token, passkeys (WebAuthn) e os padrões de auth no Next.js que eu realmente uso.

Compartilhar:X / TwitterLinkedIn

Autenticação é a única área do desenvolvimento web onde "funciona" nunca é suficiente. Um bug no seu date picker é irritante. Um bug no seu sistema de autenticação é uma violação de dados.

Já implementei autenticação do zero, migrei entre provedores, depurei incidentes de roubo de tokens e lidei com as consequências de decisões do tipo "vamos resolver a segurança depois". Este post é o guia completo que gostava de ter tido quando comecei. Não apenas a teoria — os trade-offs reais, as vulnerabilidades verdadeiras e os padrões que aguentam a pressão de produção.

Vamos cobrir o panorama completo: sessions, JWTs, OAuth 2.0, passkeys, MFA e autorização. No final, vai compreender não apenas como cada mecanismo funciona, mas quando usá-lo e porquê as alternativas existem.

Sessions vs JWT: Os Trade-offs Reais#

Esta é a primeira decisão que vai enfrentar, e a internet está cheia de maus conselhos sobre isso. Deixe-me expor o que realmente importa.

Autenticação Baseada em Sessions#

Sessions são a abordagem original. O servidor cria um registo de sessão, armazena-o algures (base de dados, Redis, memória) e dá ao cliente um ID de sessão opaco num cookie.

typescript
// Criação simplificada de sessão
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 horas
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // Armazenar na sua base de dados ou Redis
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

As vantagens são reais:

  • Revogação instantânea. Apague o registo da sessão e o utilizador fica deslogado. Sem esperar pela expiração. Isto importa quando deteta atividade suspeita.
  • Visibilidade de sessões. Pode mostrar aos utilizadores as suas sessões ativas ("autenticado no Chrome, Windows 11, Lisboa") e permitir-lhes revogar individualmente.
  • Tamanho pequeno do cookie. O ID de sessão tem tipicamente 64 caracteres. O cookie nunca cresce.
  • Controlo server-side. Pode atualizar dados da sessão (promover um utilizador a admin, mudar permissões) e entra em efeito no próximo pedido.

As desvantagens também são reais:

  • Acesso à base de dados em cada pedido. Cada pedido autenticado precisa de uma pesquisa de sessão. Com Redis isto é sub-milissegundo, mas continua a ser uma dependência.
  • Escala horizontal requer armazenamento partilhado. Se tem múltiplos servidores, todos precisam de acesso ao mesmo armazenamento de sessões. Sticky sessions são uma solução frágil.
  • CSRF é uma preocupação. Porque os cookies são enviados automaticamente, precisa de proteção CSRF. Cookies SameSite resolvem isto em grande parte, mas precisa de compreender porquê.

Autenticação Baseada em JWT#

JWTs invertem o modelo. Em vez de armazenar estado de sessão no servidor, codifica-o num token assinado que o cliente mantém.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

As vantagens:

  • Sem armazenamento server-side. O token é auto-contido. Verifica a assinatura e lê as claims. Sem acesso à base de dados.
  • Funciona entre serviços. Numa arquitetura de microserviços, qualquer serviço com a chave pública pode verificar o token. Sem necessidade de armazenamento partilhado de sessões.
  • Escala stateless. Adicione mais servidores sem se preocupar com afinidade de sessões.

As desvantagens — e são estas que as pessoas passam por cima:

  • Não pode revogar um JWT. Uma vez emitido, é válido até expirar. Se a conta de um utilizador é comprometida, não pode forçar o logout. Pode construir uma blocklist, mas então reintroduziu estado server-side e perdeu a vantagem principal.
  • Tamanho do token. JWTs com algumas claims têm tipicamente 800+ bytes. Adicione roles, permissões e metadados e está a enviar kilobytes em cada pedido.
  • Payload é legível. O payload é codificado em Base64, não encriptado. Qualquer pessoa pode descodificá-lo. Nunca coloque dados sensíveis num JWT.
  • Problemas de desvio de relógio. Se os seus servidores têm relógios diferentes (acontece), as verificações de expiração tornam-se pouco fiáveis.

Quando Usar Cada Um#

A minha regra geral:

Use sessions quando: Tem uma aplicação monolítica, precisa de revogação instantânea, está a construir um produto orientado ao consumidor onde a segurança da conta é crítica, ou os seus requisitos de autenticação podem mudar frequentemente.

Use JWTs quando: Tem uma arquitetura de microserviços onde os serviços precisam de verificar identidade independentemente, está a construir comunicação API-para-API, ou está a implementar um sistema de autenticação de terceiros.

Na prática: A maioria das aplicações deveria usar sessions. O argumento "JWTs são mais escaláveis" só se aplica se realmente tem um problema de escala que o armazenamento de sessões não resolve — e o Redis lida com milhões de pesquisas de sessão por segundo. Já vi demasiados projetos escolherem JWTs porque parecem mais modernos, e depois constroem uma blocklist e um sistema de refresh tokens mais complexo do que sessions teria sido.

JWT em Profundidade#

Mesmo que escolha autenticação baseada em sessions, vai encontrar JWTs através de OAuth, OIDC e integrações de terceiros. Compreender os detalhes internos é inegociável.

Anatomia de um JWT#

Um JWT tem três partes separadas por pontos: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Header — declara o algoritmo e o tipo de token:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — contém claims. Claims padrão têm nomes curtos:

json
{
  "sub": "user_123",       // Subject (sobre quem é)
  "iss": "https://auth.example.com",  // Issuer (quem criou)
  "aud": "https://api.example.com",   // Audience (quem deve aceitar)
  "iat": 1709312000,       // Issued At (timestamp Unix)
  "exp": 1709312900,       // Expiration (timestamp Unix)
  "role": "admin"          // Claim personalizada
}

Signature — prova que o token não foi adulterado. Criada assinando o header e payload codificados com uma chave secreta.

RS256 vs HS256: Isto Realmente Importa#

HS256 (HMAC-SHA256) — simétrico. O mesmo segredo assina e verifica. Simples, mas todo serviço que precisa de verificar tokens deve ter o segredo. Se qualquer um deles for comprometido, um atacante pode forjar tokens.

RS256 (RSA-SHA256) — assimétrico. Uma chave privada assina, uma chave pública verifica. Apenas o servidor de autenticação precisa da chave privada. Qualquer serviço pode verificar com a chave pública. Se um serviço de verificação for comprometido, o atacante pode ler tokens mas não forjá-los.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — use isto quando múltiplos serviços verificam tokens
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // CRÍTICO: restringir sempre os algoritmos
  });
 
  return payload;
}

Regra: Use RS256 sempre que tokens cruzem fronteiras de serviços. Use HS256 apenas quando o mesmo serviço assina e verifica.

O Ataque alg: none#

Esta é a vulnerabilidade JWT mais famosa, e é embaraçosamente simples. Algumas bibliotecas JWT costumavam:

  1. Ler o campo alg do header
  2. Usar qualquer algoritmo que ele especificasse
  3. Se alg: "none", saltar a verificação de assinatura completamente

Um atacante podia pegar num JWT válido, mudar o payload (ex.: definir "role": "admin"), definir alg como "none", remover a assinatura e enviá-lo. O servidor aceitava.

typescript
// VULNERÁVEL — nunca faça isto
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "Sem assinatura necessária" — CATASTRÓFICO
    return JSON.parse(atob(payloadB64));
  }
 
  // ... verificar assinatura
}

A correção é simples: especifique sempre o algoritmo esperado explicitamente. Nunca deixe o token dizer-lhe como verificá-lo.

typescript
// SEGURO — o algoritmo é hardcoded, não lido do token
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Aceitar apenas RS256 — ignorar o header
});

Bibliotecas modernas como jose tratam isto corretamente por defeito, mas ainda assim deve passar explicitamente a opção algorithms como defesa em profundidade.

Ataque de Confusão de Algoritmo#

Relacionado com o anterior: se um servidor está configurado para aceitar RS256, um atacante pode:

  1. Obter a chave pública do servidor (é pública, afinal)
  2. Criar um token com alg: "HS256"
  3. Assiná-lo usando a chave pública como segredo HMAC

Se o servidor lê o header alg e muda para verificação HS256, a chave pública (que toda a gente conhece) torna-se o segredo partilhado. A assinatura é válida. O atacante forjou um token.

Novamente, a correção é a mesma: nunca confie no algoritmo do header do token. Codifique-o sempre em hardcode.

Rotação de Refresh Token#

Se usa JWTs, precisa de uma estratégia de refresh tokens. Enviar um access token de longa duração é pedir problemas — se for roubado, o atacante tem acesso durante toda a duração.

O padrão:

  • Access token: curta duração (15 minutos). Usado para pedidos à API.
  • Refresh token: longa duração (30 dias). Usado apenas para obter um novo access token.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // Agrupa tokens relacionados
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // Armazenar registo do refresh token
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Rotação a Cada Uso#

Cada vez que o cliente usa um refresh token para obter um novo access token, emite um novo refresh token e invalida o antigo:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // Token não existe — possível roubo
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // Token expirado
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // ESTE TOKEN JÁ FOI USADO.
    // Alguém está a fazê-lo replay — ou o utilizador legítimo
    // ou um atacante. De qualquer forma, matar toda a família.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `Reutilização de refresh token detetada para utilizador ${record.userId}, família ${record.familyId}. Todos os tokens da família invalidados.`
    );
 
    return null;
  }
 
  // Marcar token atual como usado (não apagar — precisamos para deteção de reutilização)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Emitir novo par com o mesmo ID de família
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Mesma família
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Porquê a Invalidação por Família Importa#

Considere este cenário:

  1. Utilizador faz login, recebe refresh token A
  2. Atacante rouba refresh token A
  3. Atacante usa A para obter um novo par (access token + refresh token B)
  4. Utilizador tenta usar A (que ainda tem) para renovar

Sem deteção de reutilização, o utilizador apenas recebe um erro. O atacante continua com token B. O utilizador faz login novamente, sem nunca saber que a sua conta foi comprometida.

Com deteção de reutilização e invalidação por família: quando o utilizador tenta usar o token A já usado, o sistema deteta a reutilização, invalida todos os tokens da família (incluindo B) e força tanto o utilizador como o atacante a re-autenticar. O utilizador recebe um pedido "por favor faça login novamente" e pode perceber que algo está errado.

Esta é a abordagem usada pelo Auth0, Okta e Auth.js. Não é perfeita — se o atacante usar o token antes do utilizador legítimo, o utilizador legítimo torna-se o que dispara o alerta de reutilização. Mas é o melhor que podemos fazer com bearer tokens.

OAuth 2.0 & OIDC#

OAuth 2.0 e OpenID Connect são os protocolos por trás de "Entrar com Google/GitHub/Apple." Compreendê-los é essencial mesmo que use uma biblioteca, porque quando as coisas falham — e vão falhar — precisa de saber o que está a acontecer ao nível do protocolo.

A Distinção Chave#

OAuth 2.0 é um protocolo de autorização. Responde: "Esta aplicação pode aceder aos dados deste utilizador?" O resultado é um access token que concede permissões específicas (scopes).

OpenID Connect (OIDC) é uma camada de autenticação construída sobre OAuth 2.0. Responde: "Quem é este utilizador?" O resultado é um ID token (um JWT) que contém informação de identidade do utilizador.

Quando "entra com Google," está a usar OIDC. O Google diz à sua aplicação quem é o utilizador (autenticação). Também pode solicitar scopes OAuth para aceder ao calendário ou drive (autorização).

Authorization Code Flow com PKCE#

Este é o fluxo que deve usar para aplicações web. O PKCE (Proof Key for Code Exchange) foi originalmente desenhado para apps móveis mas agora é recomendado para todos os clientes, incluindo aplicações server-side.

typescript
import { randomBytes, createHash } from "crypto";
 
// Passo 1: Gerar valores PKCE e redirecionar o utilizador
function initiateOAuthFlow() {
  // Code verifier: string aleatória de 43-128 caracteres
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // Code challenge: hash SHA256 do verifier, codificado em base64url
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: valor aleatório para proteção CSRF
  const state = randomBytes(16).toString("hex");
 
  // Armazenar ambos na sessão (server-side!) antes de redirecionar
  // NUNCA colocar o code_verifier num cookie ou parâmetro de URL
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// Passo 2: Tratar o callback
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // Verificar erros do provedor
  if (error) {
    throw new Error(`Erro OAuth: ${error}`);
  }
 
  // Verificar que state corresponde (proteção CSRF)
  if (state !== session.oauthState) {
    throw new Error("State não corresponde — possível ataque CSRF");
  }
 
  // Trocar o código de autorização por tokens
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: prova que nós iniciámos este fluxo
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — para chamadas API ao Google
  // tokens.id_token — JWT com identidade do utilizador (OIDC)
  // tokens.refresh_token — para obter novos access tokens
 
  // Passo 3: Verificar o ID token e extrair info do utilizador
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

Os Três Endpoints#

Todo provedor OAuth/OIDC expõe estes:

  1. Authorization endpoint — para onde redireciona o utilizador para fazer login e conceder permissões. Retorna um código de autorização.
  2. Token endpoint — onde o seu servidor troca o código de autorização por access/refresh/ID tokens. Esta é uma chamada server-to-server.
  3. UserInfo endpoint — onde pode buscar dados adicionais do perfil do utilizador usando o access token. Com OIDC, muito disto já está no ID token.

O Parâmetro State#

O parâmetro state previne ataques CSRF no callback OAuth. Sem ele:

  1. Atacante inicia um fluxo OAuth na sua própria máquina, obtém um código de autorização
  2. Atacante cria um URL: https://yourapp.com/callback?code=CÓDIGO_DO_ATACANTE
  3. Atacante engana uma vítima para clicar nele (link de email, imagem oculta)
  4. A sua aplicação troca o código do atacante e associa a conta Google do atacante à sessão da vítima

Com state: a sua aplicação gera um valor aleatório, armazena-o na sessão e inclui-o no URL de autorização. Quando o callback chega, verifica que o state corresponde. O atacante não pode forjar isto porque não tem acesso à sessão da vítima.

Auth.js (NextAuth) com Next.js App Router#

Auth.js é o que uso primeiro na maioria dos projetos Next.js. Trata da dança OAuth, gestão de sessões, persistência na base de dados e proteção CSRF. Aqui está uma configuração pronta para produção.

Configuração Básica#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // Usar sessões de base de dados (não JWT) para melhor segurança
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30 dias
    updateAge: 24 * 60 * 60,   // Estender sessão a cada 24 horas
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Solicitar scopes específicos
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // Obter refresh token
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // Login por email/senha (usar com cuidado)
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Senha", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Controlar quem pode fazer login
    async signIn({ user, account }) {
      // Bloquear login de utilizadores banidos
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Adicionar campos personalizados à sessão
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Buscar role da base de dados
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Proteção com Middleware#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // Redirecionar utilizadores autenticados para fora das páginas de auth
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // Redirecionar utilizadores não autenticados para login
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // Verificar role de admin
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

Usar a Sessão em Server Components#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Bem-vindo, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

Usar a Sessão em Client Components#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>A carregar...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Entrar</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "Utilizador"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkeys (WebAuthn)#

Passkeys são a melhoria mais significativa em autenticação nos últimos anos. São resistentes a phishing, resistentes a replay e eliminam toda a categoria de vulnerabilidades relacionadas com senhas. Se está a começar um novo projeto em 2026, deve suportar passkeys.

Como as Passkeys Funcionam#

Passkeys usam criptografia de chave pública, apoiada por biometria ou PINs do dispositivo:

  1. Registo: O navegador gera um par de chaves. A chave privada fica no dispositivo (num enclave seguro, protegida por biometria). A chave pública é enviada para o seu servidor.
  2. Autenticação: O servidor envia um desafio (bytes aleatórios). O dispositivo assina o desafio com a chave privada (após verificação biométrica). O servidor verifica a assinatura com a chave pública armazenada.

Nenhum segredo partilhado cruza a rede. Não há nada para fazer phishing, nada para vazar, nada para stuffing.

Porquê as Passkeys São Resistentes a Phishing#

Quando uma passkey é criada, fica vinculada à origin (ex.: https://example.com). O navegador só usará a passkey na origin exata para a qual foi criada. Se um atacante criar um site semelhante em https://exarnple.com, a passkey simplesmente não será oferecida. Isto é imposto pelo navegador, não pela vigilância do utilizador.

Isto é fundamentalmente diferente das senhas, onde os utilizadores introduzem rotineiramente as suas credenciais em sites de phishing porque a página parece correta.

Implementação com SimpleWebAuthn#

SimpleWebAuthn é a biblioteca que recomendo. Trata o protocolo WebAuthn corretamente e tem bons tipos TypeScript.

typescript
// Server-side: Registo
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // Obter passkeys existentes do utilizador para excluí-las
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // Não precisamos de attestation para a maioria das apps
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // Armazenar o desafio temporariamente — precisamos para verificação
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // 5 minutos de expiração
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("Desafio expirado ou não encontrado");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`Falha na verificação de registo: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Falha na verificação de registo");
  }
 
  const { credential } = verification.registrationInfo;
 
  // Armazenar a credencial na base de dados
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // Limpar
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// Server-side: Autenticação
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // Se sabemos quem é o utilizador (ex.: introduziu o email), limitar às suas passkeys
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // Armazenar desafio para verificação
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("Credencial não encontrada");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("Falha na verificação de autenticação");
  }
 
  // IMPORTANTE: Atualizar o contador para prevenir ataques de replay
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// Client-side: Registo
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // Obter opções do seu servidor
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // Isto aciona a UI de passkey do navegador (prompt biométrico)
    const credential = await webAuthnRegister(options);
 
    // Enviar a credencial ao seu servidor para verificação
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("Passkey registada com sucesso!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("Utilizador cancelou o registo de passkey");
    }
  }
}

Attestation vs Assertion#

Dois termos que vai encontrar:

  • Attestation (registo): O processo de criar uma nova credencial. O autenticador "atesta" a sua identidade e capacidades. Para a maioria das aplicações, não precisa de verificar attestation — defina attestationType: "none".
  • Assertion (autenticação): O processo de usar uma credencial existente para assinar um desafio. O autenticador "afirma" que o utilizador é quem diz ser.

Implementação de MFA#

Mesmo com passkeys, vai encontrar cenários onde MFA via TOTP é necessário — passkeys como segundo fator ao lado de senhas, ou suportar utilizadores cujos dispositivos não suportam passkeys.

TOTP (Time-Based One-Time Passwords)#

TOTP é o protocolo por trás do Google Authenticator, Authy e 1Password. Funciona assim:

  1. Servidor gera um segredo aleatório (codificado em base32)
  2. Utilizador digitaliza um código QR contendo o segredo
  3. Tanto o servidor como a app autenticadora calculam o mesmo código de 6 dígitos a partir do segredo e hora atual
  4. Os códigos mudam a cada 30 segundos
typescript
import { createHmac, randomBytes } from "crypto";
 
// Gerar um segredo TOTP para um utilizador
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// Gerar o URI TOTP para código QR
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Verificar um código TOTP
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // Verificar o passo de tempo atual e os adjacentes (tolerância de desvio de relógio)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // Comparação em tempo constante para prevenir timing attacks
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // Converter passo de tempo em buffer big-endian de 8 bytes
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // Truncação dinâmica
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

Códigos de Backup#

Os utilizadores perdem os seus telemóveis. Gere sempre códigos de backup durante a configuração de MFA:

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // Códigos hex de 8 caracteres
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // Hashear os códigos antes de armazenar — tratá-los como senhas
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // Retornar os códigos em texto plano UMA VEZ para o utilizador guardar
  // Depois disto, só temos os hashes
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // Marcar como usado — cada código de backup funciona exatamente uma vez
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

Fluxo de Recuperação#

A recuperação de MFA é a parte que a maioria dos tutoriais salta e a maioria das aplicações reais estraga. Aqui está o que implemento:

  1. Primário: Código TOTP da app autenticadora
  2. Secundário: Um dos 10 códigos de backup
  3. Último recurso: Recuperação baseada em email com um período de espera de 24 horas e notificação para os outros canais verificados do utilizador

O período de espera é crítico. Se um atacante comprometeu o email do utilizador, não quer permitir que desabilite MFA instantaneamente. O atraso de 24 horas dá ao utilizador legítimo tempo para notar o email e intervir.

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // Não revelar se a conta existe
    return { message: "Se esse email existir, enviámos instruções de recuperação." };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 horas
      status: "pending",
    },
  });
 
  // Enviar email com link de recuperação
  await sendEmail(email, {
    subject: "Pedido de Recuperação de Conta",
    body: `
      Foi feito um pedido para desativar MFA na sua conta.
      Se foi você, clique no link abaixo após 24 horas: ...
      Se NÃO foi você, por favor mude a sua senha imediatamente.
    `,
  });
 
  return { message: "Se esse email existir, enviámos instruções de recuperação." };
}

Padrões de Autorização#

A autenticação diz-lhe quem alguém é. A autorização diz-lhe o que tem permissão para fazer. Errar nisto é como acaba nas notícias.

RBAC vs ABAC#

RBAC (Role-Based Access Control): Utilizadores têm roles, roles têm permissões. Simples, fácil de raciocinar, funciona para a maioria das aplicações.

typescript
// RBAC — verificações diretas de role
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // Cuidado com wildcards
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Utilização numa rota API
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Não autorizado" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Proibido" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (Attribute-Based Access Control): Permissões dependem de atributos do utilizador, do recurso e do contexto. Mais flexível mas mais complexo.

typescript
// ABAC — quando RBAC não é suficiente
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // Utilizadores podem sempre ler os seus próprios recursos
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // Admins podem ler qualquer recurso no seu departamento
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // Recursos classificados requerem MFA e clearance mínimo
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // Ações destrutivas bloqueadas fora do horário de trabalho
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // Negação por defeito
}

A Regra "Verificar na Fronteira"#

Este é o princípio de autorização mais importante: verifique permissões em cada fronteira de confiança, não apenas ao nível da UI.

typescript
// MAU — verificar apenas no componente
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // Isto esconde o botão, mas não previne a eliminação
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>Apagar</button>;
}
 
// TAMBÉM MAU — verificar num server action mas não na rota API
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("Proibido");
  await db.post.delete({ where: { id: postId } });
}
// Um atacante pode ainda chamar POST /api/posts/123 diretamente
 
// BOM — verificar em cada fronteira
// 1. Esconder o botão na UI (UX, não segurança)
// 2. Verificar no server action (defesa em profundidade)
// 3. Verificar na rota API (a fronteira de segurança real)
// 4. Opcionalmente, verificar no middleware (para proteção ao nível da rota)

A verificação na UI é para experiência do utilizador. A verificação no servidor é para segurança. Nunca dependa de apenas uma delas.

Verificações de Permissão no Middleware do Next.js#

O middleware corre antes de cada pedido correspondente. É um bom lugar para controlo de acesso grosseiro:

typescript
// "Este utilizador tem permissão para aceder a esta secção?"
// Verificações granulares ("Este utilizador pode editar ESTE post?") pertencem ao route handler
// porque o middleware não tem acesso fácil ao corpo do pedido ou parâmetros de rota.
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // Controlo de acesso ao nível da rota
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

Vulnerabilidades Comuns#

Estes são os ataques que vejo mais frequentemente em codebases reais. Compreendê-los é essencial.

Session Fixation#

O ataque: Um atacante cria uma sessão válida no seu site, depois engana a vítima para usar esse ID de sessão (ex.: via parâmetro de URL ou definindo um cookie através de um subdomínio). Quando a vítima faz login, a sessão do atacante agora tem um utilizador autenticado.

A correção: Regenere sempre o ID de sessão após autenticação bem-sucedida. Nunca permita que um ID de sessão pré-autenticação transite para uma sessão pós-autenticação.

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("Credenciais inválidas");
 
  // CRÍTICO: Apagar a sessão antiga e criar uma nova
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // Criar uma sessão completamente nova com um novo ID
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF (Cross-Site Request Forgery)#

O ataque: Um utilizador está autenticado no seu site. Visita uma página maliciosa que faz um pedido ao seu site. Porque os cookies são enviados automaticamente, o pedido está autenticado.

A correção moderna: Cookies SameSite. Definir SameSite: Lax (o padrão na maioria dos navegadores agora) previne que cookies sejam enviados em pedidos POST cross-origin, o que cobre a maioria dos cenários CSRF.

typescript
// SameSite=Lax cobre a maioria dos cenários CSRF:
// - Bloqueia cookies em POST, PUT, DELETE cross-origin
// - Permite cookies em GET cross-origin (navegação top-level)
//   Isto é aceitável porque pedidos GET não devem ter efeitos secundários
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // Esta é a sua proteção CSRF
  maxAge: 86400,
  path: "/",
});

Para APIs que aceitam JSON, obtém proteção adicional de graça: o header Content-Type: application/json não pode ser definido por formulários HTML, e CORS previne JavaScript de outras origens de fazer pedidos com headers personalizados.

Se precisa de garantias mais fortes (ex.: aceita submissões de formulário), use o padrão double-submit cookie ou um token sincronizador. O Auth.js trata disto por si.

Open Redirects no OAuth#

O ataque: Um atacante cria um URL de callback OAuth que redireciona para o seu site após autenticação: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token

Se o seu handler de callback redireciona cegamente para o parâmetro redirect_to, o utilizador acaba no site do atacante, potencialmente com tokens no URL.

typescript
// VULNERÁVEL
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... autenticar o utilizador ...
  return Response.redirect(redirectTo); // Pode ser https://evil.com!
}
 
// SEGURO
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // Validar o URL de redirecionamento
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... autenticar o utilizador ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // Permitir apenas redirecionamentos para a mesma origin
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // Permitir apenas redirecionamentos de caminho (sem javascript: ou data: URIs)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Vazamento de Token via Referrer#

Se coloca tokens em URLs (não faça isso), vão vazar através do header Referer quando os utilizadores clicam em links. Isto causou violações reais, incluindo no GitHub.

Regras:

  • Nunca coloque tokens em parâmetros de query de URL para autenticação
  • Defina Referrer-Policy: strict-origin-when-cross-origin (ou mais restritivo)
  • Se deve colocar tokens em URLs (ex.: links de verificação de email), torne-os de uso único e com curta duração
typescript
// No seu middleware ou layout do Next.js
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT Key Injection#

Um ataque menos conhecido: algumas bibliotecas JWT suportam um header jwk ou jku que diz ao verificador onde encontrar a chave pública. Um atacante pode:

  1. Gerar o seu próprio par de chaves
  2. Criar um JWT com o seu payload e assiná-lo com a sua chave privada
  3. Definir o header jwk para apontar para a sua chave pública

Se a sua biblioteca busca e usa cegamente a chave do header jwk, a assinatura verifica. A correção: nunca permita que o token especifique a sua própria chave de verificação. Use sempre chaves da sua própria configuração.

A Minha Stack de Auth em 2026#

Após anos a construir sistemas de autenticação, aqui está o que realmente uso hoje.

Para a Maioria dos Projetos: Auth.js + PostgreSQL + Passkeys#

Esta é a minha stack padrão para novos projetos:

  • Auth.js (v5) para o trabalho pesado: provedores OAuth, gestão de sessões, CSRF, adaptador de base de dados
  • PostgreSQL com adaptador Prisma para armazenamento de sessões e contas
  • Passkeys via SimpleWebAuthn como método de login principal para novos utilizadores
  • Email/senha como fallback para utilizadores que não podem usar passkeys
  • TOTP MFA como segundo fator para logins baseados em senha

A estratégia de sessão é suportada por base de dados (não JWT), o que me dá revogação instantânea e gestão simples de sessões.

typescript
// Este é o meu auth.ts típico para um novo projeto
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5 tem suporte nativo a passkeys
      // Usa SimpleWebAuthn por baixo
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

Quando Usar Clerk ou Auth0 em Vez Disso#

Recorro a um provedor de auth gerido quando:

  • O projeto precisa de SSO empresarial (SAML, SCIM). Implementar SAML corretamente é um projeto de vários meses. O Clerk faz isso de origem.
  • A equipa não tem experiência em segurança. Se ninguém na equipa consegue explicar PKCE, não deviam estar a construir auth do zero.
  • O tempo de lançamento importa mais que o custo. Auth.js é gratuito mas leva dias a configurar corretamente. Clerk leva uma tarde.
  • Precisa de garantias de conformidade (SOC 2, HIPAA). Provedores geridos tratam da certificação de conformidade.

Os trade-offs dos provedores geridos:

  • Custo: Clerk cobra por utilizador ativo mensal. Em escala, isto acumula.
  • Dependência do fornecedor: Migrar do Clerk ou Auth0 é doloroso. A sua tabela de utilizadores está nos servidores deles.
  • Limites de personalização: Se o seu fluxo de auth é incomum, vai lutar contra as opiniões do provedor.
  • Latência: Cada verificação de auth vai a uma API de terceiros. Com sessões de base de dados, é uma query local.

O Que Evito#

  • Criar a minha própria criptografia. Uso jose para JWTs, @simplewebauthn/server para passkeys, bcrypt ou argon2 para senhas. Nunca feito à mão.
  • Armazenar senhas em SHA256. Use bcrypt (fator de custo 12+) ou argon2id. SHA256 é demasiado rápido — um atacante pode tentar milhares de milhões de hashes por segundo com uma GPU.
  • Access tokens de longa duração. Máximo 15 minutos. Use rotação de refresh tokens para sessões mais longas.
  • Segredos simétricos para verificação cross-service. Se múltiplos serviços precisam de verificar tokens, use RS256 com um par de chaves pública/privada.
  • IDs de sessão personalizados com entropia insuficiente. Use crypto.randomBytes(32) no mínimo. UUID v4 é aceitável mas tem menos entropia do que bytes aleatórios puros.

Hashing de Senhas: A Forma Correta#

Já que o mencionámos — aqui está como hashear senhas corretamente em 2026:

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2id é o algoritmo recomendado
// Estes são padrões razoáveis para uma aplicação web
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3 iterações
    parallelism: 4,     // 4 threads
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

Porquê argon2id em vez de bcrypt? Argon2id é memory-hard, o que significa que atacá-lo requer não apenas poder de CPU mas também grandes quantidades de RAM. Isto torna ataques com GPU e ASIC significativamente mais caros. Bcrypt ainda é bom — não está quebrado — mas argon2id é a melhor escolha para novos projetos.

Checklist de Segurança#

Antes de enviar qualquer sistema de autenticação, verifique:

  • Senhas hasheadas com argon2id ou bcrypt (custo 12+)
  • Sessões regeneradas após login (previne session fixation)
  • Cookies são HttpOnly, Secure, SameSite=Lax ou Strict
  • JWTs especificam algoritmos explicitamente (nunca confiar no header alg)
  • Access tokens expiram em 15 minutos ou menos
  • Rotação de refresh token implementada com deteção de reutilização
  • Parâmetro state do OAuth verificado (proteção CSRF)
  • URLs de redirecionamento validados contra uma allowlist
  • Rate limiting aplicado em endpoints de login, registo e reset de senha
  • Tentativas falhadas de login registadas com IP mas não com senhas
  • Bloqueio de conta após N tentativas falhadas (com atrasos progressivos, não bloqueio permanente)
  • Tokens de reset de senha de uso único que expiram em 1 hora
  • Códigos de backup MFA hasheados como senhas
  • CORS configurado para permitir apenas origens conhecidas
  • Header Referrer-Policy definido
  • Sem dados sensíveis em payloads JWT (são legíveis por qualquer pessoa)
  • Contador WebAuthn verificado e atualizado (previne clonagem de credenciais)

Esta lista não é exaustiva, mas cobre as vulnerabilidades que vi mais frequentemente em sistemas em produção.

Conclusão#

Autenticação é um daqueles domínios onde o panorama continua a evoluir, mas os fundamentos permanecem os mesmos: verificar identidade, emitir as credenciais mínimas necessárias, verificar permissões em cada fronteira e assumir violação.

A maior mudança em 2026 é as passkeys a tornarem-se mainstream. O suporte dos navegadores é universal, o suporte de plataformas (iCloud Keychain, Google Password Manager) torna o UX fluido, e as propriedades de segurança são genuinamente superiores a tudo o que tivemos antes. Se está a construir uma nova aplicação, faça das passkeys o seu método de login principal e trate as senhas como fallback.

A segunda maior mudança é que construir autenticação própria tornou-se mais difícil de justificar. Auth.js v5, Clerk e soluções similares tratam as partes difíceis corretamente. A única razão para ir por conta própria é quando os seus requisitos genuinamente não encaixam em nenhuma solução existente — e isso é mais raro do que a maioria dos programadores pensa.

Independentemente do que escolher, teste a sua autenticação como um atacante faria. Tente replay de tokens, forjar assinaturas, aceder a rotas que não deveria e manipular URLs de redirecionamento. Os bugs que encontra antes do lançamento são os que não dão notícia.

Posts Relacionados