Secure Cookie Management with Claude Code: Next.js Sessions, CSRF, and Consent
Implement secure Next.js cookies with Claude Code: HttpOnly, Secure, SameSite, CSRF, logout, and consent boundaries.
Cookie management looks small until it becomes the reason an account can be hijacked. A session cookie is just a short value in an HTTP header, but the browser sends it automatically. That makes it useful for authentication and dangerous when the attributes are wrong.
Claude Code can produce the code quickly, but a vague prompt such as “set a login cookie” often misses production details. HttpOnly may be absent. SameSite=None may be used without Secure. The logout route may fail because it clears a different Path. Analytics cookies may be mixed with authentication cookies, turning a consent decision into a security bug.
This guide rewrites the cookie workflow as a practical checklist: the attributes to use, a copy-pasteable Next.js route handler, logout and server-side reads, CSRF protection, session fixation prevention, consent boundaries, browser behavior, official references, and verification commands you can run before shipping.
Start with a cookie inventory
Do not choose attributes before naming the purpose of each cookie. Authentication cookies are credentials. Preference cookies are UI state. Analytics and advertising cookies are tracking infrastructure. They should not share the same consent or security rules.
| Purpose | Example | Recommended attributes | Consent boundary |
|---|---|---|---|
| Authentication session | __Host-session | HttpOnly, Secure, SameSite=Lax, Path=/, short Max-Age | Usually required for the requested service, but confirm local legal rules |
| CSRF token | csrf-token | Secure, SameSite=Lax, short Max-Age | Security support cookie, not an analytics identifier |
| UI preference | theme, locale | Secure, SameSite=Lax, limited lifetime | Explain when required by your region or policy |
| Analytics or ads | _ga, campaign IDs | Set only after consent when consent is required | Separate from login and checkout cookies |
Plain-language terms help review. HttpOnly means browser JavaScript cannot read the cookie through document.cookie. Secure means the cookie is sent only over HTTPS, with localhost treated specially by browsers. SameSite decides whether the browser attaches the cookie to cross-site requests. Max-Age is a relative lifetime in seconds; Expires is an absolute date.
The official MDN guide on secure cookie configuration recommends limiting cookie scope with Secure, HttpOnly, SameSite, and prefixes. MDN’s Set-Cookie reference also documents that SameSite=None requires Secure, and that Max-Age takes precedence over Expires when both are present.
For session cookies, prefer the __Host- prefix. A supporting browser only accepts a __Host- cookie when it has Secure, no Domain, and Path=/. This makes the cookie host-only and reduces the chance that a subdomain can overwrite the session identifier. It is a small naming choice with real defensive value.
Set a secure session cookie in Next.js
The current Next.js cookies API documents cookies() as asynchronous and lists httpOnly, secure, sameSite, maxAge, path, domain, priority, and partitioned as supported options. Server Components can read cookies, while mutation belongs in a Route Handler or Server Action.
Create app/api/login/route.ts with the following demo. It uses an in-memory session map so you can test the header immediately. In production, replace the map with Redis, Postgres, DynamoDB, or another store that survives deploys and scales across instances.
import { createHmac, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
const SESSION_COOKIE = "__Host-session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
type SessionRecord = {
userId: string;
expiresAt: number;
};
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(12),
});
function createSessionToken() {
const id = randomBytes(32).toString("base64url");
const signature = createHmac("sha256", env.SESSION_SECRET)
.update(id)
.digest("base64url");
return `${id}.${signature}`;
}
async function authenticate(email: string, password: string) {
if (email === "masa@example.com" && password === "correct-horse-battery-staple") {
return { id: "user_123" };
}
return null;
}
export async function POST(request: NextRequest) {
const body = loginSchema.safeParse(await request.json());
if (!body.success) {
return NextResponse.json({ error: "Invalid login payload" }, { status: 400 });
}
const user = await authenticate(body.data.email, body.data.password);
if (!user) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = createSessionToken();
sessions.set(token, {
userId: user.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
});
return response;
}
This code intentionally creates a new token on every successful login. That matters for session fixation. OWASP describes session fixation as an attack where the application does not assign a new session ID during authentication, allowing an attacker to make the victim authenticate with a known session ID.
Logout and server-side reads
Deleting a cookie is not a generic “remove by name” operation. The browser matches the cookie by name plus scope. If you set Path=/, clear it with Path=/. If you set a Domain, clear with the same Domain. With __Host- cookies, do not set Domain at all.
Create app/api/logout/route.ts:
import { NextResponse } from "next/server";
const SESSION_COOKIE = "__Host-session";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}
For a real app, also delete the server-side session record. Clearing only the browser cookie prevents the current browser from using it, but a stolen token may remain valid until the server-side row expires or is revoked.
Read the cookie on the server with await cookies():
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "__Host-session";
export default async function AccountPage() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionToken) {
redirect("/login");
}
return <main>Account dashboard</main>;
}
Do not treat “cookie exists” as “user is authenticated.” The server must verify the session record, expiration, user ID, revocation status, and any risk flags. The cookie is only a pointer.
CSRF is not solved by HttpOnly
CSRF means cross-site request forgery: another site causes a logged-in browser to send a state-changing request to your site. Because browsers attach cookies automatically, HttpOnly protects confidentiality but does not stop the browser from sending the cookie.
OWASP’s CSRF Prevention Cheat Sheet recommends tokens for state-changing requests, and it warns that XSS can defeat CSRF defenses. For cookie-authenticated apps, use a synchronizer token pattern or a signed double-submit token. The following helper signs a token with the session value so the server can validate it without storing every token.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const CSRF_SECRET = process.env.SESSION_SECRET;
if (!CSRF_SECRET || CSRF_SECRET.length < 32) {
throw new Error("SESSION_SECRET must be at least 32 characters");
}
export function createCsrfToken(sessionToken: string) {
const nonce = randomBytes(16).toString("base64url");
const signature = createHmac("sha256", CSRF_SECRET)
.update(`${sessionToken}.${nonce}`)
.digest("base64url");
return `${nonce}.${signature}`;
}
export function verifyCsrfToken(sessionToken: string, token: string) {
const [nonce, signature] = token.split(".");
if (!nonce || !signature) return false;
const expected = createHmac("sha256", CSRF_SECRET)
.update(`${sessionToken}.${nonce}`)
.digest("base64url");
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Send the token in an X-CSRF-Token header or hidden form field for POST, PUT, PATCH, and DELETE. Do not mutate server state from GET routes. SameSite=Lax is useful defense in depth, but it is not a replacement for a token on sensitive operations.
Browser behavior that changes reviews
Browsers do not expose Set-Cookie to frontend JavaScript. You can see it in DevTools or with curl -i, but fetch() response headers will filter it out. For cross-origin requests, the browser also requires credential settings to send and receive cookies as expected. That means CORS, credentials, and cookie attributes must be reviewed together.
Another common surprise is SameSite=Lax. Some browsers use Lax-like behavior when the attribute is omitted, but relying on defaults is not a security policy. Always set it explicitly. Lax allows top-level safe-method navigation, so a GET endpoint that changes state is still a bug. Strict is stronger but can be annoying for flows that start from external links. None should be reserved for genuine cross-site use and always paired with Secure.
Max-Age and Expires also deserve a deliberate choice. Max-Age=28800 means eight hours from receipt. Expires means a specific timestamp. Prefer Max-Age in application code unless you have a compatibility reason, then test the exact header. Session restore features in browsers can make “until browser close” less predictable than teams expect.
Consent boundary and monetization use cases
A consent boundary is the line between cookies needed to deliver the requested service and cookies used for analytics, advertising, experiments, or retargeting. The European Commission’s cookies policy shows the same practical separation: consent preference, authentication, and analytics are described as different purposes. This article is not legal advice, but the engineering rule is clear: do not bundle analytics consent with authentication security.
Use case 1: a SaaS login. Use __Host-session, a short Max-Age, server-side revocation, CSRF tokens, and a new session on login. Admin routes may use SameSite=Strict and step-up verification for billing or permission changes.
Use case 2: a content site with a free PDF, products, and consultation CTAs. Keep authentication and checkout cookies separate from analytics cookies. A reader who rejects analytics should still be able to download the free PDF, buy a product, or submit a consultation inquiry.
Use case 3: localized UI preferences. A language or theme cookie may need to be readable by JavaScript, so it will not be HttpOnly. That does not make it suitable for secrets. Never store session tokens, roles, feature entitlements, or prices in client-readable cookies.
Failure cases to ask Claude Code to catch
First failure: __Host-session without Secure, with Domain, or without Path=/. The browser should reject it, but your tests should catch the bad header before users do.
Second failure: logout that does not clear the cookie because Path or Domain changed. If login uses Path=/, logout must also use Path=/.
Third failure: relying on SameSite instead of a real CSRF token. SameSite helps, but it does not fix unsafe GET mutations, same-site subdomain problems, or XSS.
Fourth failure: storing access tokens in localStorage or a client-readable cookie. If XSS lands, the token is exposed. Keep credentials in HttpOnly cookies and validate authorization on the server.
Fifth failure: consent code that blocks security cookies. Rejecting analytics should not disable login, CSRF protection, cart continuity, or checkout.
Prompts and verification
Use a prompt that states the security contract:
Implement a Next.js App Router login cookie.
Requirements:
- cookie name: __Host-session
- attributes: HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age
- never set Domain
- create a new session token after every successful login
- clear with Max-Age=0 and the same Path
- do not mix analytics consent cookies with authentication cookies
- include CSRF guidance for state-changing requests
- verify against MDN, Next.js, and OWASP docs
Check the login header:
curl -i -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"masa@example.com","password":"correct-horse-battery-staple"}'
Expected shape:
Set-Cookie: __Host-session=...; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax
Check logout:
curl -i -X POST http://localhost:3000/api/logout
The response should return the same cookie name, Path=/, and Max-Age=0. For browser-level verification, use Playwright’s context.cookies() and inspect httpOnly, secure, sameSite, and expiration.
Links, CTA, and tested result
For the broader auth flow, continue with the Claude Code authentication implementation guide, the JWT authentication comparison, and the security audit guide. Official references: MDN Set-Cookie, MDN secure cookie configuration, Next.js cookies, OWASP Session Management, and OWASP CSRF Prevention.
If you want reusable prompts, implementation checklists, and review templates, start with the ClaudeCodeLab products page. If the hard part is applying this to a real team repository with consent, checkout, and security review rules, the training and consultation page is the next step.
After trying this workflow, the biggest improvement came from making the cookie contract explicit before asking Claude Code to edit files. The generated code was much closer when the prompt named __Host-, logout scope, CSRF tokens, and the analytics consent boundary. A vague “make cookies secure” prompt still produced code that needed manual cleanup.
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 Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
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.