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.
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.
// 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.
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:
{
"alg": "RS256",
"typ": "JWT"
}Payload — contém claims. Claims padrão têm nomes curtos:
{
"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.
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:
- Ler o campo
algdo header - Usar qualquer algoritmo que ele especificasse
- 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.
// 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.
// 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:
- Obter a chave pública do servidor (é pública, afinal)
- Criar um token com
alg: "HS256" - 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.
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:
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:
- Utilizador faz login, recebe refresh token A
- Atacante rouba refresh token A
- Atacante usa A para obter um novo par (access token + refresh token B)
- 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.
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();
}// 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:
- Authorization endpoint — para onde redireciona o utilizador para fazer login e conceder permissões. Retorna um código de autorização.
- Token endpoint — onde o seu servidor troca o código de autorização por access/refresh/ID tokens. Esta é uma chamada server-to-server.
- 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:
- Atacante inicia um fluxo OAuth na sua própria máquina, obtém um código de autorização
- Atacante cria um URL:
https://yourapp.com/callback?code=CÓDIGO_DO_ATACANTE - Atacante engana uma vítima para clicar nele (link de email, imagem oculta)
- 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#
// 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#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Proteção com Middleware#
// 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#
// 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#
"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:
- 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.
- 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.
// 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 };
}// 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 };
}// 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:
- Servidor gera um segredo aleatório (codificado em base32)
- Utilizador digitaliza um código QR contendo o segredo
- Tanto o servidor como a app autenticadora calculam o mesmo código de 6 dígitos a partir do segredo e hora atual
- Os códigos mudam a cada 30 segundos
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`;
}// 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:
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:
- Primário: Código TOTP da app autenticadora
- Secundário: Um dos 10 códigos de backup
- Ú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.
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.
// 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.
// 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.
// 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:
// "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.
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.
// 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.
// 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
// 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:
- Gerar o seu próprio par de chaves
- Criar um JWT com o seu payload e assiná-lo com a sua chave privada
- Definir o header
jwkpara 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.
// 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
josepara JWTs,@simplewebauthn/serverpara passkeys,bcryptouargon2para 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:
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=LaxouStrict - 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-Policydefinido - 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.