Autenticación segura con Claude Code: sesiones Next.js, límites JWT y OAuth
Implementa autenticación segura con Claude Code: sesiones Next.js, JWT, OAuth, CSRF, RBAC, auditoría y tests.
La autenticación no es solo un formulario de login. Antes de publicar necesitas pensar en almacenamiento de contraseñas, sesiones, cookies, JWT, callbacks OAuth, CSRF, recuperación de contraseña, RBAC, secretos, logs de auditoría y pruebas. Si le pides a Claude Code “añade auth” sin esas fronteras, puede entregar una pantalla funcional con riesgos de producción escondidos.
El atajo peligroso más frecuente es guardar JWT de larga duración en localStorage. Si aparece XSS, es decir, JavaScript malicioso ejecutándose dentro de la página, ese token se puede robar y es difícil revocarlo. Para una aplicación web normal, suele ser más seguro usar una sesión del lado del servidor y enviar al navegador solo un identificador opaco mediante una cookie HttpOnly.
Esta guía usa Next.js App Router y muestra un ejemplo copiable con validación Zod, hashing bcrypt, cookie de sesión firmada, middleware guard, protección CSRF, verificación de Origin, RBAC, auditoría y tests con Vitest. JWT queda reservado para APIs o clientes móviles con tokens cortos. OAuth se trata como frontera de proveedor de identidad, no como sustituto de la autorización local.
Consulta siempre la documentación oficial: Next.js Authentication guide, cookies API de Next.js, OWASP Authentication Cheat Sheet, OWASP Password Storage Cheat Sheet, OWASP Forgot Password Cheat Sheet, MDN Secure cookie configuration, Auth.js y Claude Code docs. Para ampliar dentro de ClaudeCodeLab, revisa gestión de cookies, RBAC y validación con Zod.
Separar sesión, JWT y OAuth
Una sesión responde “este navegador sigue conectado”. Un JWT responde “este cliente puede demostrar claims firmados durante poco tiempo”. OAuth/OIDC responde “un proveedor confiable autenticó a esta persona”. Mezclar estas responsabilidades en una sola función complica la revisión.
| Mecanismo | Mejor uso | Ventaja | Riesgo que controlar |
|---|---|---|---|
| Sesión servidor | Dashboards SaaS, áreas de miembros, admin | Revocación y auditoría simples | Necesita Redis, Postgres, DynamoDB u otro store |
| JWT | API móvil, llamadas cortas entre servicios, APIs externas | Verificación sin consultar base de datos | Revocación difícil; evitar almacenamiento largo en navegador |
| OAuth / OIDC | Google, GitHub, SSO corporativo | No guardas la contraseña del usuario | El login externo no reemplaza permisos locales |
Tres casos reales lo muestran. En un SaaS, la sesión web vive en cookie HttpOnly y los cambios de facturación piden reautenticación. En un sitio de contenido de pago, lectores gratuitos, compradores, editores y admins se separan con RBAC y logs. En una herramienta B2B interna, Google Workspace o Entra ID pueden autenticar, pero la aplicación todavía valida tenant, rol, expiración y revocación.
Prompt seguro para Claude Code
Antes de pedir código, entrega el contrato de seguridad.
Implementa autenticación para Next.js App Router.
Requisitos:
- Login web con sesión del servidor y cookie HttpOnly
- JWT solo para tokens cortos de API externa; no usar localStorage
- Contraseñas con bcrypt o Argon2id, nunca texto plano
- Validación con Zod y sin filtrar si el email existe
- Cookies con Secure, HttpOnly, SameSite, Path y Max-Age explícitos
- APIs de cambio de estado con Origin check y token CSRF
- OAuth con Auth.js u otra librería probada, no implementación propia completa
- Incluir RBAC, password reset, audit logs y tests
- Listar pitfalls y comandos de verificación
El objetivo es impedir que Claude Code elija un diseño cómodo pero inseguro. En autenticación, el prompt debe decir qué no está permitido.
Implementación mínima en Next.js
Instala dependencias.
npm install zod bcryptjs
npm install -D vitest typescript @types/node
Define un secreto de al menos 32 caracteres en .env.local y no lo subas al repositorio.
SESSION_SECRET="replace-with-at-least-32-random-characters"
Archivo lib/auth/password.ts. OWASP recomienda Argon2id como opción moderna; aquí usamos bcryptjs por facilidad de copia.
import bcrypt from "bcryptjs";
import { z } from "zod";
export const passwordSchema = z.string().min(12).max(128);
export async function hashPassword(password: string) {
const parsed = passwordSchema.parse(password);
return bcrypt.hash(parsed, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
Archivo lib/auth/session.ts. El Map es solo para demo local; en producción cámbialo por Redis, PostgreSQL o DynamoDB.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
export type Role = "user" | "admin";
type SessionRecord = { userId: string; role: Role; csrfToken: string; expiresAt: number };
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
export const SESSION_COOKIE_NAME =
env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
function signSessionId(sessionId: string) {
return createHmac("sha256", env.SESSION_SECRET).update(sessionId).digest("base64url");
}
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
export function createSession(userId: string, role: Role = "user") {
const sessionId = randomBytes(32).toString("base64url");
const token = `${sessionId}.${signSessionId(sessionId)}`;
const csrfToken = randomBytes(32).toString("base64url");
sessions.set(sessionId, {
userId,
role,
csrfToken,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
return { token, csrfToken };
}
export function getSession(token?: string) {
if (!token) return null;
const [sessionId, signature] = token.split(".");
if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return { id: sessionId, ...session };
}
export function destroySession(token?: string) {
const sessionId = token?.split(".")[0];
if (sessionId) sessions.delete(sessionId);
}
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) throw new Error("Bad origin");
}
export function assertCsrf(request: Request, session: { csrfToken: string }) {
const submitted = request.headers.get("x-csrf-token");
if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}
Route Handler app/api/login/route.ts. En una app real, passwordHash y role vienen de la base de datos.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { SESSION_COOKIE_NAME, createSession, sessionCookieOptions } from "@/lib/auth/session";
export const runtime = "nodejs";
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(12).max(128),
});
async function findUserByEmail(email: string) {
if (email !== "masa@example.com") return null;
return {
id: "user_123",
role: "admin" as const,
passwordHash: await hashPassword("correct-horse-battery-staple"),
};
}
export async function POST(request: NextRequest) {
const parsed = loginInputSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
const user = await findUserByEmail(parsed.data.email);
const passwordOk = user ? await verifyPassword(parsed.data.password, user.passwordHash) : false;
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createSession(user.id, user.role);
const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
response.cookies.set({ name: SESSION_COOKIE_NAME, value: session.token, ...sessionCookieOptions });
response.cookies.set({
name: "csrf-token",
value: session.csrfToken,
secure: sessionCookieOptions.secure,
sameSite: "lax",
path: "/",
maxAge: sessionCookieOptions.maxAge,
});
return response;
}
middleware.ts protege rutas de forma superficial. No lo uses como frontera final de autorización.
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE_NAME =
process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
const pathname = request.nextUrl.pathname;
if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
Test mínimo test/auth.test.ts.
import { beforeAll, describe, expect, it } from "vitest";
beforeAll(() => {
process.env.NODE_ENV = "test";
process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});
describe("auth primitives", () => {
it("hashes and verifies passwords", async () => {
const { hashPassword, verifyPassword } = await import("../lib/auth/password");
const hash = await hashPassword("correct-horse-battery-staple");
await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
});
it("creates and destroys a session", async () => {
const { createSession, destroySession, getSession } = await import("../lib/auth/session");
const session = createSession("user_123", "admin");
expect(getSession(session.token)?.role).toBe("admin");
destroySession(session.token);
expect(getSession(session.token)).toBeNull();
});
it("validates login input", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
});
});
Password reset, OAuth y JWT
La recuperación de contraseña no debe revelar si un correo existe. Usa el mismo mensaje para ambos casos, genera tokens con alta entropía, guarda solo el hash del token, expira rápido y marca el token como usado. Tras cambiar contraseña, permite invalidar sesiones existentes.
Para OAuth, usa Auth.js u otra librería madura. El proveedor autentica; tu app vincula esa identidad a un usuario local, crea sesión propia, valida roles y registra eventos importantes. No uses el access token del proveedor como cookie de sesión de tu app.
JWT es útil para APIs, pero no es una solución universal. Si lo usas, define aud, iss, exp, rotación de claves y respuesta ante filtración.
Pitfalls, auditoría y monetización
Bloquea estos errores antes de publicar: JWT largos en localStorage, cookies sin Secure o HttpOnly, passwords con SHA-256 simple, reset tokens en texto plano, errores que revelan si el email existe, RBAC solo en middleware y logs que imprimen secretos.
Un audit log debe registrar actor, acción, resultado y hora. No debe registrar contraseñas, IDs de sesión, reset tokens ni OAuth access tokens. En SaaS, valida tenantId antes que role; ser admin en un tenant no autoriza leer facturas de otro.
La autenticación también protege ingresos: contenido premium, plantillas, enlaces Gumroad, formularios B2B y dashboards de miembros. Empieza con la checklist gratuita, usa productos y plantillas si necesitas material reutilizable, y recurre a formación o consultoría Claude Code para diseñar auth, RBAC, logs y CI sobre un repo real.
Resultado práctico
Cuando Masa probó este flujo, el mayor valor no fue la ruta de login, sino tratar cookie de sesión, CSRF, RBAC, auditoría y tests como una sola unidad. En una versión anterior, el middleware parecía una frontera de seguridad aunque solo verificaba presencia de cookie. Mover las comprobaciones reales al servidor hizo la revisión más clara y redujo errores en páginas admin.
Resumen
Para autenticación web con Claude Code, empieza por las fronteras. Navegador: sesión de servidor y cookie segura. API: JWT corto. Login externo: OAuth/OIDC con librería. Cambios de estado: CSRF y Origin. Autorización: RBAC. Operaciones sensibles: auditoría y tests.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.