El panorama completo de autenticación: cuándo usar sesiones vs JWT, flujos OAuth 2.0/OIDC, rotación de refresh tokens, passkeys (WebAuthn) y los patrones de autenticación en Next.js que realmente uso.
La autenticación es el área del desarrollo web donde "funciona" nunca es suficiente. Un bug en tu selector de fechas es molesto. Un bug en tu sistema de autenticación es una filtración de datos.
He implementado autenticación desde cero, migrado entre proveedores, depurado incidentes de robo de tokens y lidiado con las consecuencias de decisiones tipo "arreglaremos la seguridad después". Este artículo es la guía completa que me hubiera gustado tener cuando empecé. No solo la teoría — las compensaciones reales, las vulnerabilidades reales y los patrones que aguantan bajo presión en producción.
Cubriremos todo el panorama: sesiones, JWTs, OAuth 2.0, passkeys, MFA y autorización. Al final, entenderás no solo cómo funciona cada mecanismo, sino cuándo usarlo y por qué existen las alternativas.
Esta es la primera decisión que enfrentarás, e Internet está lleno de malos consejos al respecto. Permíteme explicar lo que realmente importa.
Las sesiones son el enfoque original. El servidor crea un registro de sesión, lo almacena en algún lugar (base de datos, Redis, memoria) y le da al cliente un ID de sesión opaco en una cookie.
// Simplified session creation
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 hours
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Store in your database or 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;
}Las ventajas son reales:
Las desventajas también son reales:
Los JWTs invierten el modelo. En lugar de almacenar el estado de sesión en el servidor, lo codificas en un token firmado que el cliente mantiene.
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://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}Las ventajas:
Las desventajas — y estas son las que la gente pasa por alto:
Mi regla general:
Usa sesiones cuando: Tienes una aplicación monolítica, necesitas revocación instantánea, estás construyendo un producto orientado al consumidor donde la seguridad de la cuenta es crítica, o tus requisitos de autenticación pueden cambiar frecuentemente.
Usa JWTs cuando: Tienes una arquitectura de microservicios donde los servicios necesitan verificar la identidad de forma independiente, estás construyendo comunicación API-a-API, o estás implementando un sistema de autenticación de terceros.
En la práctica: La mayoría de las aplicaciones deberían usar sesiones. El argumento de "los JWTs son más escalables" solo aplica si realmente tienes un problema de escalado que el almacenamiento de sesiones no puede resolver — y Redis maneja millones de búsquedas de sesión por segundo. He visto demasiados proyectos elegir JWTs porque suenan más modernos, para luego construir una lista de bloqueo y un sistema de refresh tokens que es más complejo de lo que habrían sido las sesiones.
Incluso si eliges autenticación basada en sesiones, encontrarás JWTs a través de OAuth, OIDC e integraciones de terceros. Entender los detalles internos es innegociable.
Un JWT tiene tres partes separadas por puntos: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — declara el algoritmo y tipo de token:
{
"alg": "RS256",
"typ": "JWT"
}Payload — contiene los claims. Los claims estándar tienen nombres cortos:
{
"sub": "user_123", // Subject (sobre quién es)
"iss": "https://auth.example.com", // Issuer (quién lo creó)
"aud": "https://api.example.com", // Audience (quién debe aceptarlo)
"iat": 1709312000, // Issued At (timestamp Unix)
"exp": 1709312900, // Expiration (timestamp Unix)
"role": "admin" // Claim personalizado
}Signature — demuestra que el token no ha sido manipulado. Se crea firmando el header y payload codificados con una clave secreta.
HS256 (HMAC-SHA256) — simétrico. El mismo secreto firma y verifica. Simple, pero cada servicio que necesita verificar tokens debe tener el secreto. Si cualquiera de ellos se ve comprometido, un atacante puede falsificar tokens.
RS256 (RSA-SHA256) — asimétrico. Una clave privada firma, una clave pública verifica. Solo el servidor de autenticación necesita la clave privada. Cualquier servicio puede verificar con la clave pública. Si un servicio de verificación se ve comprometido, el atacante puede leer tokens pero no falsificarlos.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — use this when multiple services verify 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"], // CRITICAL: always restrict algorithms
});
return payload;
}Regla: Usa RS256 siempre que los tokens crucen fronteras de servicios. Usa HS256 solo cuando el mismo servicio firma y verifica.
alg: noneEsta es la vulnerabilidad JWT más famosa, y es vergonzosamente simple. Algunas bibliotecas JWT solían:
alg del headeralg: "none", saltarse la verificación de firma por completoUn atacante podía tomar un JWT válido, cambiar el payload (por ejemplo, establecer "role": "admin"), poner alg en "none", eliminar la firma y enviarlo. El servidor lo aceptaría.
// VULNERABLE — never do this
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "No signature needed" — CATASTROPHIC
return JSON.parse(atob(payloadB64));
}
// ... verify signature
}La solución es simple: siempre especifica el algoritmo esperado explícitamente. Nunca dejes que el token te diga cómo verificarlo.
// SAFE — algorithm is hardcoded, not read from the token
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Only accept RS256 — ignore the header
});Las bibliotecas modernas como jose manejan esto correctamente por defecto, pero aún deberías pasar explícitamente la opción algorithms como defensa en profundidad.
Relacionado con lo anterior: si un servidor está configurado para aceptar RS256, un atacante podría:
alg: "HS256"Si el servidor lee el header alg y cambia a verificación HS256, la clave pública (que todos conocen) se convierte en el secreto compartido. La firma es válida. El atacante ha falsificado un token.
Nuevamente, la solución es la misma: nunca confíes en el algoritmo del header del token. Siempre codifícalo directamente.
Si usas JWTs, necesitas una estrategia de refresh tokens. Enviar un access token de larga duración es buscar problemas — si es robado, el atacante tiene acceso durante toda su vida útil.
El patrón:
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Groups related tokens together
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);
// Store refresh token record
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 };
}Cada vez que el cliente usa un refresh token para obtener un nuevo access token, emites un nuevo refresh token e invalidas el anterior:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token doesn't exist — possible theft
return null;
}
if (record.expiresAt < new Date()) {
// Token expired
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// THIS TOKEN WAS ALREADY USED.
// Someone is replaying it — either the legitimate user
// or an attacker. Either way, kill the entire family.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Refresh token reuse detected for user ${record.userId}, family ${record.familyId}. All tokens in family invalidated.`,
);
return null;
}
// Mark current token as used (don't delete — we need it for reuse detection)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Issue new pair with the same family ID
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Same family
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 };
}Considera este escenario:
Sin detección de reutilización, el usuario simplemente obtiene un error. El atacante continúa con el token B. El usuario inicia sesión de nuevo, sin saber nunca que su cuenta fue comprometida.
Con detección de reutilización e invalidación por familia: cuando el usuario intenta usar el token A ya utilizado, el sistema detecta la reutilización, invalida cada token en la familia (incluyendo B) y fuerza tanto al usuario como al atacante a re-autenticarse. El usuario recibe un mensaje de "por favor inicia sesión de nuevo" y podría darse cuenta de que algo anda mal.
Este es el enfoque utilizado por Auth0, Okta y Auth.js. No es perfecto — si el atacante usa el token antes que el usuario legítimo, el usuario legítimo se convierte en quien activa la alerta de reutilización. Pero es lo mejor que podemos hacer con bearer tokens.
OAuth 2.0 y OpenID Connect son los protocolos detrás de "Iniciar sesión con Google/GitHub/Apple". Entenderlos es esencial incluso si usas una biblioteca, porque cuando las cosas se rompen — y se romperán — necesitas saber qué está pasando a nivel de protocolo.
OAuth 2.0 es un protocolo de autorización. Responde: "¿Puede esta aplicación acceder a los datos de este usuario?" El resultado es un access token que otorga permisos específicos (scopes).
OpenID Connect (OIDC) es una capa de autenticación construida sobre OAuth 2.0. Responde: "¿Quién es este usuario?" El resultado es un ID token (un JWT) que contiene información de identidad del usuario.
Cuando "inicias sesión con Google", estás usando OIDC. Google le dice a tu aplicación quién es el usuario (autenticación). También podrías solicitar scopes de OAuth para acceder a su calendario o drive (autorización).
Este es el flujo que deberías usar para aplicaciones web. PKCE (Proof Key for Code Exchange) fue diseñado originalmente para aplicaciones móviles pero ahora se recomienda para todos los clientes, incluyendo aplicaciones del lado del servidor.
import { randomBytes, createHash } from "crypto";
// Step 1: Generate PKCE values and redirect the user
function initiateOAuthFlow() {
// Code verifier: random 43-128 character string
const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
// Code challenge: SHA256 hash of the verifier, base64url-encoded
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
// State: random value for CSRF protection
const state = randomBytes(16).toString("hex");
// Store both in the session (server-side!) before redirecting
// NEVER put the code_verifier in a cookie or URL parameter
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();
}// Step 2: Handle the 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");
// Check for errors from the provider
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// Verify state matches (CSRF protection)
if (state !== session.oauthState) {
throw new Error("State mismatch — possible CSRF attack");
}
// Exchange the authorization code for 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: proves we started this flow
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — for API calls to Google
// tokens.id_token — JWT with user identity (OIDC)
// tokens.refresh_token — for getting new access tokens
// Step 3: Verify the ID token and extract user info
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Todo proveedor OAuth/OIDC expone estos:
El parámetro state previene ataques CSRF en el callback de OAuth. Sin él:
https://yourapp.com/callback?code=ATTACKER_CODECon state: tu aplicación genera un valor aleatorio, lo almacena en la sesión y lo incluye en la URL de autorización. Cuando llega el callback, verificas que el state coincida. El atacante no puede falsificar esto porque no tiene acceso a la sesión de la víctima.
Auth.js es a lo que recurro primero en la mayoría de los proyectos Next.js. Maneja el flujo OAuth, la gestión de sesiones, la persistencia en base de datos y la protección CSRF. Aquí hay una configuración lista para producción.
// 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),
// Use database sessions (not JWT) for better security
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Extend session every 24 hours
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Request specific scopes
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Get refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Email/password login (use carefully)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", 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: {
// Control who can sign in
async signIn({ user, account }) {
// Block sign-in for banned users
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Add custom fields to the session
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Fetch role from database
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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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");
// Redirect logged-in users away from auth pages
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Redirect unauthenticated users to login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl));
}
// Check admin role
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"],
};// 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>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Sign In</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "User"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Las passkeys son la mejora de autenticación más significativa en años. Son resistentes al phishing, resistentes a la repetición, y eliminan toda la categoría de vulnerabilidades relacionadas con contraseñas. Si estás iniciando un nuevo proyecto en 2026, deberías soportar passkeys.
Las passkeys usan criptografía de clave pública, respaldada por biometría o PINs de dispositivo:
Ningún secreto compartido cruza la red. No hay nada que robar mediante phishing, nada que filtrar, nada que rellenar.
Cuando se crea una passkey, está vinculada al origen (por ejemplo, https://example.com). El navegador solo usará la passkey en el origen exacto para el que fue creada. Si un atacante crea un sitio similar en https://exarnple.com, la passkey simplemente no será ofrecida. Esto es aplicado por el navegador, no por la vigilancia del usuario.
Esto es fundamentalmente diferente de las contraseñas, donde los usuarios rutinariamente ingresan sus credenciales en sitios de phishing porque la página se ve correcta.
SimpleWebAuthn es la biblioteca que recomiendo. Maneja el protocolo WebAuthn correctamente y tiene buenos tipos TypeScript.
// Server-side: Registration
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { GenerateRegistrationOptionsOpts, VerifiedRegistrationResponse } from "@simplewebauthn/server";
const rpName = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
async function startRegistration(userId: string, userEmail: string) {
// Get user's existing passkeys to exclude them
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", // We don't need attestation for most apps
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Store the challenge temporarily — we need it for verification
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300, // 5 minute expiry
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge expired or not found");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registration verification failed: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credential } = verification.registrationInfo;
// Store the credential in the database
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Clean up
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Server-side: Authentication
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// If we know the user (e.g., they entered their email), limit to their 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",
});
// Store challenge for verification
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("Credential not found");
}
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("Authentication verification failed");
}
// IMPORTANT: Update the counter to prevent replay attacks
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Client-side: Registration
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Get options from your server
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// This triggers the browser's passkey UI (biometric prompt)
const credential = await webAuthnRegister(options);
// Send the credential to your server for verification
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 registered successfully!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("User cancelled the passkey registration");
}
}
}Dos términos que encontrarás:
attestationType: "none".Incluso con passkeys, encontrarás escenarios donde MFA vía TOTP es necesario — passkeys como segundo factor junto con contraseñas, o para soportar usuarios cuyos dispositivos no admiten passkeys.
TOTP es el protocolo detrás de Google Authenticator, Authy y 1Password. Funciona así:
import { createHmac, randomBytes } from "crypto";
// Generate a TOTP secret for a user
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;
}
// Generate the TOTP URI for QR code
function generateTOTPUri(secret: string, userEmail: string, issuer: string = "example.com"): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Verify a TOTP code
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Check current time step and adjacent ones (clock drift tolerance)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Constant-time comparison to prevent timing attacks
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Convert time step to 8-byte big-endian buffer
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamic truncation
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());
}Los usuarios pierden sus teléfonos. Siempre genera códigos de respaldo durante la configuración de MFA:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from(
{ length: count },
() => randomBytes(4).toString("hex").toUpperCase(), // 8-character hex codes
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hash the codes before storing — treat them like passwords
const hashedCodes = codes.map((code) => createHash("sha256").update(code).digest("hex"));
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Return the plain codes ONCE for the user to save
// After this, we only have the 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;
// Mark as used — each backup code works exactly once
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}La recuperación de MFA es la parte que la mayoría de los tutoriales omiten y la mayoría de las aplicaciones reales arruinan. Esto es lo que implemento:
El período de espera es crítico. Si un atacante ha comprometido el correo del usuario, no quieres permitirle desactivar MFA instantáneamente. El retraso de 24 horas le da al usuario legítimo tiempo para notar el correo e intervenir.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Don't reveal whether the account exists
return { message: "If that email exists, we've sent recovery instructions." };
}
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 hours
status: "pending",
},
});
// Send email with recovery link
await sendEmail(email, {
subject: "Account Recovery Request",
body: `
A request was made to disable MFA on your account.
If this was you, click the link below after 24 hours: ...
If this was NOT you, please change your password immediately.
`,
});
return { message: "If that email exists, we've sent recovery instructions." };
}La autenticación te dice quién es alguien. La autorización te dice qué tiene permitido hacer. Equivocarse en esto es cómo terminas en las noticias.
RBAC (Control de Acceso Basado en Roles): Los usuarios tienen roles, los roles tienen permisos. Simple, fácil de razonar, funciona para la mayoría de las aplicaciones.
// RBAC — straightforward role checks
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: ["*"], // Careful with wildcards
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Usage in an API route
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Control de Acceso Basado en Atributos): Los permisos dependen de atributos del usuario, el recurso y el contexto. Más flexible pero más complejo.
// ABAC — when RBAC isn't enough
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;
// Users can always read their own resources
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admins can read any resource in their department
if (action === "read" && user.role === "admin" && user.department === resource.department) {
return true;
}
// Classified resources require MFA and minimum clearance
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Destructive actions blocked outside business hours
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Default deny
}Este es el principio de autorización más importante: verifica los permisos en cada frontera de confianza, no solo a nivel de UI.
// BAD — only checking in the component
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// This hides the button, but doesn't prevent deletion
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
// ALSO BAD — checking in a server action but not the API route
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// An attacker can still call POST /api/posts/123 directly
// GOOD — check at every boundary
// 1. Hide the button in the UI (UX, not security)
// 2. Check in the server action (defense in depth)
// 3. Check in the API route (the actual security boundary)
// 4. Optionally, check in middleware (for route-level protection)La verificación en la UI es para la experiencia del usuario. La verificación en el servidor es para la seguridad. Nunca confíes en solo una de ellas.
El middleware se ejecuta antes de cada petición coincidente. Es un buen lugar para control de acceso de grano grueso:
// "Is this user allowed to access this section at all?"
// Fine-grained checks ("Can this user edit THIS post?") belong in the route handler
// because middleware doesn't have access to the request body or route params easily.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Route-level access control
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();
});Estos son los ataques que veo con más frecuencia en bases de código reales. Entenderlos es esencial.
El ataque: Un atacante crea una sesión válida en tu sitio, luego engaña a la víctima para que use ese ID de sesión (por ejemplo, mediante un parámetro URL o estableciendo una cookie a través de un subdominio). Cuando la víctima inicia sesión, la sesión del atacante ahora tiene un usuario autenticado.
La solución: Siempre regenera el ID de sesión después de una autenticación exitosa. Nunca permitas que un ID de sesión pre-autenticación se transfiera a una sesión post-autenticación.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
// CRITICAL: Delete the old session and create a new one
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Create a completely new session with a new ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}El ataque: Un usuario ha iniciado sesión en tu sitio. Visita una página maliciosa que realiza una petición a tu sitio. Como las cookies se envían automáticamente, la petición está autenticada.
La solución moderna: Cookies SameSite. Establecer SameSite: Lax (el valor por defecto en la mayoría de los navegadores ahora) previene que las cookies se envíen en peticiones POST de origen cruzado, lo cual cubre la mayoría de los escenarios CSRF.
// SameSite=Lax covers most CSRF scenarios:
// - Blocks cookies on cross-origin POST, PUT, DELETE
// - Allows cookies on cross-origin GET (top-level navigation)
// This is fine because GET requests shouldn't have side effects
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // This is your CSRF protection
maxAge: 86400,
path: "/",
});Para APIs que aceptan JSON, obtienes protección adicional gratis: el header Content-Type: application/json no puede ser establecido por formularios HTML, y CORS previene que JavaScript en otros orígenes haga peticiones con headers personalizados.
Si necesitas garantías más fuertes (por ejemplo, aceptas envíos de formularios), usa el patrón de cookie de doble envío o un token sincronizador. Auth.js maneja esto por ti.
El ataque: Un atacante crea una URL de callback OAuth que redirige a su sitio después de la autenticación: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
Si tu manejador de callback redirige ciegamente al parámetro redirect_to, el usuario termina en el sitio del atacante, potencialmente con tokens en la URL.
// VULNERABLE
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... authenticate the user ...
return Response.redirect(redirectTo); // Could be https://evil.com!
}
// SAFE
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Validate the redirect URL
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... authenticate the user ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Only allow redirects to the same origin
if (url.origin !== base.origin) {
return "/";
}
// Only allow path redirects (no javascript: or data: URIs)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Si pones tokens en URLs (no lo hagas), se filtrarán a través del header Referer cuando los usuarios hagan clic en enlaces. Esto ha causado filtraciones reales, incluyendo en GitHub.
Reglas:
Referrer-Policy: strict-origin-when-cross-origin (o más estricto)// In your Next.js middleware or layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Un ataque menos conocido: algunas bibliotecas JWT soportan un header jwk o jku que indica al verificador dónde encontrar la clave pública. Un atacante puede:
jwk para apuntar a su clave públicaSi tu biblioteca busca y usa ciegamente la clave del header jwk, la firma se verifica. La solución: nunca permitas que el token especifique su propia clave de verificación. Siempre usa claves de tu propia configuración.
Después de años construyendo sistemas de autenticación, esto es lo que realmente uso hoy.
Este es mi stack por defecto para nuevos proyectos:
La estrategia de sesión está respaldada por base de datos (no JWT), lo que me da revocación instantánea y gestión de sesiones simple.
// This is my typical auth.ts for a new project
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 has built-in passkey support
// This uses SimpleWebAuthn under the hood
}),
],
experimental: {
enableWebAuthn: true,
},
});Recurro a un proveedor de autenticación gestionado cuando:
Las compensaciones de los proveedores gestionados:
jose para JWTs, @simplewebauthn/server para passkeys, bcrypt o argon2 para contraseñas. Nunca implementados a mano.crypto.randomBytes(32) como mínimo. UUID v4 es aceptable pero tiene menos entropía que bytes aleatorios puros.Ya que lo mencionamos — así es como se hashean contraseñas correctamente en 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id is the recommended algorithm
// These are reasonable defaults for a web application
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}¿Por qué argon2id sobre bcrypt? Argon2id es memory-hard, lo que significa que atacarlo requiere no solo potencia de CPU sino también grandes cantidades de RAM. Esto hace que los ataques con GPU y ASIC sean significativamente más costosos. Bcrypt sigue siendo aceptable — no está roto — pero argon2id es la mejor opción para nuevos proyectos.
Antes de lanzar cualquier sistema de autenticación, verifica:
HttpOnly, Secure, SameSite=Lax o Strictalg)Referrer-Policy está establecidoEsta lista no es exhaustiva, pero cubre las vulnerabilidades que he visto con más frecuencia en sistemas en producción.
La autenticación es uno de esos dominios donde el panorama sigue evolucionando, pero los fundamentos permanecen iguales: verifica la identidad, emite las credenciales mínimas necesarias, verifica permisos en cada frontera y asume la brecha.
El cambio más grande en 2026 es que las passkeys se están volviendo mainstream. El soporte del navegador es universal, el soporte de plataforma (iCloud Keychain, Google Password Manager) hace que la experiencia de usuario sea fluida, y las propiedades de seguridad son genuinamente superiores a cualquier cosa que hayamos tenido antes. Si estás construyendo una nueva aplicación, haz de las passkeys tu método de inicio de sesión principal y trata las contraseñas como el respaldo.
El segundo cambio más grande es que implementar tu propia autenticación se ha vuelto más difícil de justificar. Auth.js v5, Clerk y soluciones similares manejan las partes difíciles correctamente. La única razón para ir personalizado es cuando tus requisitos genuinamente no encajan en ninguna solución existente — y eso es más raro de lo que la mayoría de los desarrolladores piensan.
Elijas lo que elijas, prueba tu autenticación como lo haría un atacante. Intenta reproducir tokens, falsificar firmas, acceder a rutas que no deberías y manipular URLs de redirección. Los bugs que encuentras antes del lanzamiento son los que no salen en las noticias.