Practical Authentication with Claude Code: Next.js Sessions, JWT Boundaries, and OAuth
Secure Next.js auth with Claude Code: sessions, JWT boundaries, OAuth, CSRF, RBAC, audit logs, and tests.
Authentication is not a login form. It is password storage, session lifetime, cookies, JWT boundaries, OAuth callbacks, CSRF protection, password reset, RBAC, secrets, audit logs, and tests. If you ask Claude Code to “add auth” without those boundaries, it may produce a working screen while leaving production risk hidden in the design.
The most common unsafe shortcut is to use long-lived browser JWTs and store them in localStorage. When an XSS bug allows malicious JavaScript to run in the page, that token is easy to steal and hard to revoke. For normal web apps, a server-side session with an opaque HttpOnly cookie is usually easier to revoke, audit, and reason about.
This guide shows a practical Next.js App Router implementation you can copy into a demo project. It uses Zod validation, bcrypt password hashing, a signed opaque session cookie, a middleware guard, CSRF and origin checks, RBAC, audit logging, and Vitest tests. JWT is treated as a short-lived API/mobile boundary, not the default browser session. OAuth is treated as an identity-provider boundary, not a reason to skip local session design.
Use official references as the source of truth: the Next.js authentication guide, Next.js cookies API, OWASP Authentication Cheat Sheet, OWASP Password Storage Cheat Sheet, OWASP Forgot Password Cheat Sheet, MDN secure cookie configuration, Auth.js, and the Claude Code docs. For related ClaudeCodeLab reading, see secure cookie management, RBAC implementation, and Zod validation.
Separate Sessions, JWT, and OAuth
Start by naming the job of each mechanism. A session answers “is this browser still logged in?” A JWT answers “can this API client prove a signed claim for a short time?” OAuth or OIDC answers “did a trusted identity provider authenticate this person?” Those are different jobs, and mixing them makes review harder.
| Mechanism | Best fit | Strength | Risk to control |
|---|---|---|---|
| Server-side session | Admin panels, SaaS dashboards, member areas | Easy revocation and audit | Needs Redis, Postgres, DynamoDB, or another durable store |
| JWT | Mobile APIs, short-lived service-to-service calls, external API access | Verification can happen without a DB lookup | Revocation is harder; avoid long-lived browser storage |
| OAuth / OIDC | Google, GitHub, or corporate identity-provider login | You do not store the user’s password | The provider login is only the entry point; local authorization still matters |
Three use cases make the boundary concrete. In a SaaS dashboard, use an HttpOnly session cookie for browser login and require reauthentication before billing or email changes. In a paid content site, separate free readers, paid members, editors, and admins with RBAC and audit logs. In an internal B2B tool, you can let Google Workspace or Entra ID authenticate the person, but you still need tenant checks, local roles, session expiry, and revocation.
If you do need JWT, make access tokens short-lived. If refresh tokens exist, store a hash of the refresh token server-side, rotate on use, and detect reuse. The instruction to Claude Code should be “browser session uses cookies; external API uses short-lived JWT; refresh tokens are hashed and revocable”, not simply “implement JWT auth”.
Prompt Claude Code with Security Boundaries
Give Claude Code a contract before asking for code. This keeps the output grounded in reviewable behavior.
Implement authentication for a Next.js App Router app.
Requirements:
- Browser login uses server-side sessions and an HttpOnly cookie
- JWT is limited to short-lived external API tokens; do not store JWTs in localStorage
- Hash passwords with bcrypt or Argon2id, never plaintext
- Validate input with Zod and do not leak whether an email exists
- Set Secure, HttpOnly, SameSite, Path, and Max-Age on cookies
- Protect state-changing APIs with Origin checks and a CSRF token
- Prefer Auth.js or a proven library for OAuth instead of hand-rolling it
- Include RBAC, password reset notes, audit logs, and tests
- List pitfalls and verification commands at the end
In plain language, the prompt is the guardrail. It tells Claude Code which design is allowed, which shortcut is forbidden, and which tests must exist. For authentication, that guardrail matters more than the first generated route handler.
Copy-Paste Next.js Auth Demo
Install the small dependencies first.
npm install zod bcryptjs
npm install -D vitest typescript @types/node
Add a 32-character or longer secret to .env.local. Do not commit this value. Use your deployment platform’s secret store in production.
SESSION_SECRET="replace-with-at-least-32-random-characters"
Create lib/auth/password.ts. OWASP lists Argon2id as the preferred modern password hashing algorithm. This demo uses bcryptjs because it is easy to paste into a Node project; if your deployment accepts native dependencies, consider argon2 or @node-rs/argon2.
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);
}
Create lib/auth/session.ts. The in-memory Map is only for a runnable demo. Replace it with Redis, Postgres, DynamoDB, or another shared store before production.
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 leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
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) return;
if (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");
}
Create app/api/login/route.ts. The demo user is intentionally simple. In production, fetch the password hash and role from your database.
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;
}
Add middleware.ts for a routing guard. This is not the final authorization boundary; it is only a user-experience gate. Server routes must still verify the session, role, origin, and CSRF token.
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*"],
};
State-changing routes should verify more than “logged in”. This example checks session, admin role, origin, CSRF, input, and audit logging.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import {
SESSION_COOKIE_NAME,
assertCsrf,
assertSameOrigin,
getSession,
} from "@/lib/auth/session";
const settingsSchema = z.object({
displayName: z.string().min(1).max(80),
});
export async function POST(request: NextRequest) {
const session = getSession(request.cookies.get(SESSION_COOKIE_NAME)?.value);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
try {
assertSameOrigin(request);
assertCsrf(request, session);
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = settingsSchema.safeParse(await request.json());
if (!body.success) return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
console.info("audit.auth.settings_update", {
actorId: session.userId,
action: "settings.update",
result: "success",
at: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}
Finally, add 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 signed server-side 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 with Zod", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
expect(
loginInputSchema.safeParse({
email: "masa@example.com",
password: "correct-horse-battery-staple",
}).success
).toBe(true);
});
});
Password Reset and OAuth Boundary
Password reset deserves the same review as login. Return the same message whether an email exists or not. Generate high-entropy reset tokens. Store only a hash of the reset token. Expire it quickly. Make it single-use. After a password change, let the user invalidate existing sessions. Claude Code should be prompted for this whole flow, not just “send reset email”.
For OAuth, prefer a proven library such as Auth.js. The provider authenticates the user; your app still links that provider identity to a local user, creates a local session, checks roles, and logs important actions. Do not treat a provider access token as your app’s session cookie.
Pitfalls, Audit Logs, and Monetization
Concrete pitfalls to block before shipping: long-lived JWTs in localStorage, cookies without Secure or HttpOnly, password hashes made with plain SHA-256, reset tokens stored in plaintext, different login errors for unknown emails, RBAC enforced only in middleware, and audit logs that print secrets.
Audit logs should say who acted, what action happened, which result occurred, and when. They should not contain passwords, session IDs, reset tokens, OAuth access tokens, or raw secrets. For SaaS, check tenantId before role checks. A user can be an admin in one tenant and still have no right to read another tenant’s invoice.
Authentication also protects revenue. Paid content, templates, Gumroad links, enterprise lead forms, and member dashboards all depend on reliable account boundaries. Start with the free checklist, use products and templates when you need reusable prompts and review rules, and use Claude Code training or consultation when your team needs auth, RBAC, audit logs, and CI tests designed against a real repository.
Hands-On Result
When Masa tested this workflow, the useful part was not the login route alone. The improvement came from making session cookies, CSRF, RBAC, audit logging, and tests one work unit. In an earlier draft, the middleware looked like the security boundary even though it only checked cookie presence. Moving the real checks into server routes made the review simpler and prevented a class of admin-page mistakes before publication.
Summary
For browser authentication, use server-side sessions and secure cookies by default. Use JWT for short-lived API boundaries, OAuth/OIDC for external identity, CSRF checks for state-changing requests, RBAC for authorization, secret management for keys, audit logs for accountability, and tests for every assumption. Claude Code can implement the pieces quickly, but only if you give it the boundaries first.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.