Use Cases (Actualizado: 2/6/2026)

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.

Autenticación segura con Claude Code: sesiones Next.js, límites JWT y OAuth

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.

MecanismoMejor usoVentajaRiesgo que controlar
Sesión servidorDashboards SaaS, áreas de miembros, adminRevocación y auditoría simplesNecesita Redis, Postgres, DynamoDB u otro store
JWTAPI móvil, llamadas cortas entre servicios, APIs externasVerificación sin consultar base de datosRevocación difícil; evitar almacenamiento largo en navegador
OAuth / OIDCGoogle, GitHub, SSO corporativoNo guardas la contraseña del usuarioEl 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.

#Claude Code #authentication #Next.js #JWT #OAuth #security
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.