Autenticación Moderna en 2026: JWT, Sesiones, OAuth y Passkeys
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.
Sesiones vs JWT: Las Compensaciones Reales#
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.
Autenticación Basada en Sesiones#
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:
- Revocación instantánea. Elimina el registro de sesión y el usuario queda desconectado. Sin esperar a la expiración. Esto importa cuando detectas actividad sospechosa.
- Visibilidad de sesiones. Puedes mostrar a los usuarios sus sesiones activas ("conectado en Chrome, Windows 11, Estambul") y permitirles revocar sesiones individuales.
- Tamaño pequeño de cookie. El ID de sesión típicamente tiene 64 caracteres. La cookie nunca crece.
- Control del lado del servidor. Puedes actualizar los datos de sesión (promover un usuario a administrador, cambiar permisos) y surte efecto en la siguiente petición.
Las desventajas también son reales:
- Consulta a base de datos en cada petición. Cada petición autenticada necesita una búsqueda de sesión. Con Redis es sub-milisegundo, pero sigue siendo una dependencia.
- El escalado horizontal requiere almacenamiento compartido. Si tienes múltiples servidores, todos necesitan acceso al mismo almacén de sesiones. Las sesiones adhesivas son una solución frágil.
- CSRF es una preocupación. Como las cookies se envían automáticamente, necesitas protección contra CSRF. Las cookies SameSite resuelven esto en gran medida, pero necesitas entender por qué.
Autenticación Basada en JWT#
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://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;
}
}Las ventajas:
- Sin almacenamiento del lado del servidor. El token es autocontenido. Verificas la firma y lees los claims. Sin consulta a base de datos.
- Funciona entre servicios. En una arquitectura de microservicios, cualquier servicio con la clave pública puede verificar el token. No se necesita un almacén de sesiones compartido.
- Escalado sin estado. Agrega más servidores sin preocuparte por la afinidad de sesiones.
Las desventajas — y estas son las que la gente pasa por alto:
- No puedes revocar un JWT. Una vez emitido, es válido hasta que expire. Si la cuenta de un usuario se ve comprometida, no puedes forzar el cierre de sesión. Puedes construir una lista de bloqueo, pero entonces has reintroducido estado del lado del servidor y perdido la ventaja principal.
- Tamaño del token. Los JWTs con unos pocos claims típicamente superan los 800+ bytes. Agrega roles, permisos y metadatos y estarás enviando kilobytes en cada petición.
- El payload es legible. El payload está codificado en Base64, no cifrado. Cualquiera puede decodificarlo. Nunca pongas datos sensibles en un JWT.
- Problemas de desfase de reloj. Si tus servidores tienen relojes diferentes (sucede), las verificaciones de expiración se vuelven poco fiables.
Cuándo Usar Cada Uno#
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.
Inmersión Profunda en JWT#
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.
Anatomía de un JWT#
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.
RS256 vs HS256: Esto Realmente Importa#
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.
El Ataque alg: none#
Esta es la vulnerabilidad JWT más famosa, y es vergonzosamente simple. Algunas bibliotecas JWT solían:
- Leer el campo
algdel header - Usar cualquier algoritmo que especificara
- Si
alg: "none", saltarse la verificación de firma por completo
Un 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.
Ataque de Confusión de Algoritmo#
Relacionado con lo anterior: si un servidor está configurado para aceptar RS256, un atacante podría:
- Obtener la clave pública del servidor (es pública, después de todo)
- Crear un token con
alg: "HS256" - Firmarlo usando la clave pública como el secreto HMAC
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.
Rotación de Refresh Tokens#
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:
- Access token: de corta duración (15 minutos). Usado para peticiones a la API.
- Refresh token: de larga duración (30 días). Usado solo para obtener un nuevo access token.
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 };
}Rotación en Cada Uso#
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 };
}Por Qué Importa la Invalidación por Familia#
Considera este escenario:
- El usuario inicia sesión, obtiene el refresh token A
- El atacante roba el refresh token A
- El atacante usa A para obtener un nuevo par (access token + refresh token B)
- El usuario intenta usar A (que aún tiene) para refrescar
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 OIDC#
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.
La Distinción Clave#
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).
Flujo de Código de Autorización con PKCE#
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,
};
}Los Tres Endpoints#
Todo proveedor OAuth/OIDC expone estos:
- Endpoint de autorización — donde rediriges al usuario para iniciar sesión y otorgar permisos. Devuelve un código de autorización.
- Endpoint de tokens — donde tu servidor intercambia el código de autorización por tokens de acceso/refresco/ID. Esta es una llamada servidor-a-servidor.
- Endpoint de UserInfo — donde puedes obtener datos adicionales del perfil del usuario usando el access token. Con OIDC, mucha de esta información ya está en el ID token.
El Parámetro State#
El parámetro state previene ataques CSRF en el callback de OAuth. Sin él:
- El atacante inicia un flujo OAuth en su propia máquina, obtiene un código de autorización
- El atacante crea una URL:
https://yourapp.com/callback?code=ATTACKER_CODE - El atacante engaña a la víctima para que haga clic (enlace por email, imagen oculta)
- Tu aplicación intercambia el código del atacante y vincula la cuenta de Google del atacante a la sesión de la víctima
Con 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 (NextAuth) con Next.js App Router#
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.
Configuración 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),
// 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",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Protección con 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");
// 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",
],
};Usando la Sesión en 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>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}Usando la Sesión en Client Components#
"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>
);
}Passkeys (WebAuthn)#
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.
Cómo Funcionan las Passkeys#
Las passkeys usan criptografía de clave pública, respaldada por biometría o PINs de dispositivo:
- Registro: El navegador genera un par de claves. La clave privada permanece en el dispositivo (en un enclave seguro, protegida por biometría). La clave pública se envía a tu servidor.
- Autenticación: El servidor envía un desafío (bytes aleatorios). El dispositivo firma el desafío con la clave privada (después de la verificación biométrica). El servidor verifica la firma con la clave pública almacenada.
Ningún secreto compartido cruza la red. No hay nada que robar mediante phishing, nada que filtrar, nada que rellenar.
Por Qué las Passkeys Son Resistentes al Phishing#
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.
Implementación con SimpleWebAuthn#
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 = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
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");
}
}
}Attestation vs Assertion#
Dos términos que encontrarás:
- Attestation (registro): El proceso de crear una nueva credencial. El autenticador "da fe" de su identidad y capacidades. Para la mayoría de las aplicaciones, no necesitas verificar la attestation — establece
attestationType: "none". - Assertion (autenticación): El proceso de usar una credencial existente para firmar un desafío. El autenticador "afirma" que el usuario es quien dice ser.
Implementación de MFA#
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 (Contraseñas de Un Solo Uso Basadas en Tiempo)#
TOTP es el protocolo detrás de Google Authenticator, Authy y 1Password. Funciona así:
- El servidor genera un secreto aleatorio (codificado en base32)
- El usuario escanea un código QR que contiene el secreto
- Tanto el servidor como la aplicación autenticadora calculan el mismo código de 6 dígitos a partir del secreto y la hora actual
- Los códigos cambian cada 30 segundos
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 = "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`;
}// 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());
}Códigos de Respaldo#
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;
}Flujo de Recuperación#
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:
- Principal: Código TOTP de la aplicación autenticadora
- Secundario: Uno de los 10 códigos de respaldo
- Último recurso: Recuperación por correo electrónico con un período de espera de 24 horas y notificación a los otros canales verificados del usuario
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." };
}Patrones de Autorización#
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 vs ABAC#
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
}La Regla de "Verificar en la Frontera"#
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.
Verificaciones de Permisos en Next.js Middleware#
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();
});Vulnerabilidades Comunes#
Estos son los ataques que veo con más frecuencia en bases de código reales. Entenderlos es esencial.
Fijación de Sesión#
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;
}CSRF (Falsificación de Petición en Sitios Cruzados)#
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.
Redirecciones Abiertas en OAuth#
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 "/";
}
}Filtración de Tokens vía Referrer#
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:
- Nunca pongas tokens en parámetros de consulta URL para autenticación
- Establece
Referrer-Policy: strict-origin-when-cross-origin(o más estricto) - Si debes poner tokens en URLs (por ejemplo, enlaces de verificación por correo), hazlos de un solo uso y de corta duración
// In your Next.js middleware or layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Inyección de Clave JWT#
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:
- Generar su propio par de claves
- Crear un JWT con su payload y firmarlo con su clave privada
- Establecer el header
jwkpara apuntar a su clave pública
Si 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.
Mi Stack de Autenticación en 2026#
Después de años construyendo sistemas de autenticación, esto es lo que realmente uso hoy.
Para la Mayoría de Proyectos: Auth.js + PostgreSQL + Passkeys#
Este es mi stack por defecto para nuevos proyectos:
- Auth.js (v5) para el trabajo pesado: proveedores OAuth, gestión de sesiones, persistencia en base de datos, protección CSRF
- PostgreSQL con adaptador Prisma para almacenamiento de sesiones y cuentas
- Passkeys vía SimpleWebAuthn como método de inicio de sesión principal para nuevos usuarios
- Email/contraseña como respaldo para usuarios que no pueden usar passkeys
- TOTP MFA como segundo factor para inicios de sesión basados en contraseña
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,
},
});Cuándo Usar Clerk o Auth0 en Su Lugar#
Recurro a un proveedor de autenticación gestionado cuando:
- El proyecto necesita SSO empresarial (SAML, SCIM). Implementar SAML correctamente es un proyecto de varios meses. Clerk lo hace de fábrica.
- El equipo no tiene experiencia en seguridad. Si nadie en el equipo puede explicar PKCE, no deberían construir autenticación desde cero.
- El tiempo de salida al mercado importa más que el costo. Auth.js es gratuito pero lleva días configurarlo correctamente. Clerk lleva una tarde.
- Necesitas garantías de cumplimiento (SOC 2, HIPAA). Los proveedores gestionados manejan la certificación de cumplimiento.
Las compensaciones de los proveedores gestionados:
- Costo: Clerk cobra por usuario activo mensual. A escala, esto se acumula.
- Dependencia del proveedor: Migrar desde Clerk o Auth0 es doloroso. Tu tabla de usuarios está en sus servidores.
- Límites de personalización: Si tu flujo de autenticación es inusual, lucharás contra las opiniones del proveedor.
- Latencia: Cada verificación de autenticación va a una API de terceros. Con sesiones en base de datos, es una consulta local.
Lo Que Evito#
- Implementar mi propia criptografía. Uso
josepara JWTs,@simplewebauthn/serverpara passkeys,bcryptoargon2para contraseñas. Nunca implementados a mano. - Almacenar contraseñas en SHA256. Usa bcrypt (factor de costo 12+) o argon2id. SHA256 es demasiado rápido — un atacante puede probar miles de millones de hashes por segundo con una GPU.
- Access tokens de larga duración. 15 minutos máximo. Usa rotación de refresh tokens para sesiones más largas.
- Secretos simétricos para verificación entre servicios. Si múltiples servicios necesitan verificar tokens, usa RS256 con un par de clave pública/privada.
- IDs de sesión personalizados con entropía insuficiente. Usa
crypto.randomBytes(32)como mínimo. UUID v4 es aceptable pero tiene menos entropía que bytes aleatorios puros.
Hashing de Contraseñas: La Forma Correcta#
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.
Lista de Verificación de Seguridad#
Antes de lanzar cualquier sistema de autenticación, verifica:
- Las contraseñas se hashean con argon2id o bcrypt (costo 12+)
- Las sesiones se regeneran después del inicio de sesión (previene fijación de sesión)
- Las cookies son
HttpOnly,Secure,SameSite=LaxoStrict - Los JWTs especifican algoritmos explícitamente (nunca confíes en el header
alg) - Los access tokens expiran en 15 minutos o menos
- La rotación de refresh tokens está implementada con detección de reutilización
- El parámetro state de OAuth se verifica (protección CSRF)
- Las URLs de redirección se validan contra una lista permitida
- La limitación de velocidad se aplica a los endpoints de inicio de sesión, registro y restablecimiento de contraseña
- Los intentos fallidos de inicio de sesión se registran con IP pero no con contraseñas
- Bloqueo de cuenta después de N intentos fallidos (con retrasos progresivos, no bloqueo permanente)
- Los tokens de restablecimiento de contraseña son de un solo uso y expiran en 1 hora
- Los códigos de respaldo de MFA se hashean como contraseñas
- CORS está configurado para permitir solo orígenes conocidos
- El header
Referrer-Policyestá establecido - No hay datos sensibles en los payloads JWT (son legibles por cualquiera)
- El contador WebAuthn se verifica y actualiza (previene clonación de credenciales)
Esta lista no es exhaustiva, pero cubre las vulnerabilidades que he visto con más frecuencia en sistemas en producción.
Conclusió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.