All stories

How I Hardened Session Cookies in Next.js With __Host-, HttpOnly and SameSite

What each cookie attribute really protects against, why the __Host- prefix matters, and the session cookie setup I now ship in Next.js.

Admin

7 min read·

4views

For a long time my session cookie looked like the one in most tutorials: a name, a value, and a path. It worked, logins stuck, and I never thought about it again. Then I sat down and actually read what each cookie attribute does, and I realised my "working" cookie was readable by any injected script, sent along with cross-site form posts, and could be overwritten by a sibling subdomain. None of that showed up in testing because none of it breaks the happy path.

In this post I walk through the cookie I ship now in my Next.js apps, what each attribute protects against, and the few places where it surprised me.

The cookie I started with

This is roughly what my login route used to send back:

Set-Cookie: session=abc123; Path=/

Here is everything wrong with it:

  • No HttpOnly: any JavaScript on the page, including a script injected through an XSS bug or a compromised npm package, can read document.cookie and send the session ID somewhere else.
  • No Secure: the browser will happily send it over plain HTTP if a request ever goes out without TLS.
  • No explicit SameSite: I was relying on browser defaults, which differ between browsers and even have special-case behaviour in Chrome.
  • No lifetime: it is a session cookie in the browser sense, so its lifetime is whatever the browser decides "session" means. With "continue where you left off" enabled, that can be weeks.

The cookie I ship now

Set-Cookie: __Host-session=4f9c...e1; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=604800

Every piece of that line does a specific job, so let me go through them.

HttpOnly: keep it away from JavaScript

HttpOnly means the cookie is sent with HTTP requests but never exposed to document.cookie. It does not stop XSS, and an attacker running script on your origin can still make authenticated requests while the victim has the tab open. What it stops is exfiltration: the attacker cannot copy the session ID and replay it from their own machine later. For a session token there is no reason for client code to read it, so this one is non-negotiable.

Secure: HTTPS only

Secure tells the browser to only send the cookie over HTTPS. Even if your site forces HTTPS with redirects and HSTS, the very first request a user types without https:// could go out in the clear. With Secure set, the session ID simply is not attached to that request.

SameSite=Lax: a strong default against CSRF

SameSite controls whether the cookie is sent on requests that originate from another site. There are three values:

  • Strict: never sent on cross-site requests, including when a user clicks a link to your site from somewhere else. Safe, but it means someone clicking a link from an email lands on your app looking logged out.
  • Lax: sent on top-level navigations using safe methods (a normal link click is a GET), but not on cross-site POST forms, fetch calls, images or iframes. This blocks the classic CSRF attack where another site auto-submits a form to your endpoint.
  • None: always sent, and it requires Secure. You only want this when your cookie genuinely has to work inside a third-party context, like an embedded widget.

I set Lax explicitly instead of trusting defaults. Chrome treats cookies with no SameSite attribute as Lax, but it also has a temporary exception that lets such cookies ride along on top-level cross-site POSTs for a short window after they are set, specifically so older login flows do not break. Writing SameSite=Lax yourself opts out of that grey zone and gives you the same behaviour in every browser.

One important caveat: Lax still sends the cookie on cross-site GET navigations. If any GET endpoint in your app changes state (a "/logout" link, a "/unsubscribe?id=" handler, a delete via query string), SameSite will not protect it. Mutations belong on POST.

Max-Age: a lifetime you control

I set Max-Age in seconds so the cookie expires on a schedule I chose, and I store the same expiry on the server-side session row. The browser-side expiry is a convenience, the server-side one is the real check: an attacker holding a copied cookie does not care what Max-Age said. It is also worth knowing that Chrome caps cookie lifetimes at 400 days no matter what you send, so "remember me forever" is not actually a thing anymore.

The __Host- prefix: the attribute nobody talks about

This was the part I did not know about at all. If a cookie's name starts with __Host-, browsers only accept it when:

  1. it is set with Secure,
  2. it is set from a secure (HTTPS) origin,
  3. it has Path=/, and
  4. it has no Domain attribute.

Why does that matter? Cookies have a weird trust model: a page on blog.example.com can set a cookie with Domain=example.com, and that cookie will be sent to app.example.com too. If an attacker controls any subdomain (an old marketing site, a forgotten preview deployment, user-generated content on a subdomain), they can plant a cookie that your main app receives. That is how session fixation and cookie tossing attacks work.

A __Host- cookie is locked to the exact host that set it. A sibling subdomain cannot create a cookie with that name that your app will accept, because the browser rejects any __Host- cookie that comes with a Domain. Renaming the cookie costs nothing and closes the whole category. There is also a weaker __Secure- prefix that only enforces Secure, which is useful when you really do need a Domain.

Setting it in Next.js

In the App Router I set cookies through cookies() from next/headers, which is async in current versions of Next.js. You can only set cookies from a Server Action or a Route Handler, not while rendering a Server Component. I keep all of it in one small module so the attributes are defined exactly once:

// lib/session-cookie.ts
import "server-only";
import { cookies } from "next/headers";
import { randomBytes } from "node:crypto";

const isProd = process.env.NODE_ENV === "production";

// __Host- needs Secure, Path=/ and no Domain. Over plain http in dev,
// some browsers refuse Secure cookies, so fall back to a plain name.
export const SESSION_COOKIE = isProd ? "__Host-session" : "session";

const SEVEN_DAYS = 60 * 60 * 24 * 7;

export function newSessionId(): string {
  // 32 random bytes = 256 bits of entropy, URL-safe
  return randomBytes(32).toString("base64url");
}

export async function setSessionCookie(sessionId: string) {
  const cookieStore = await cookies();
  cookieStore.set({
    name: SESSION_COOKIE,
    value: sessionId,
    httpOnly: true,     // not readable from document.cookie
    secure: isProd,     // HTTPS only (required for __Host-)
    sameSite: "lax",    // not sent on cross-site POSTs, fetches or iframes
    path: "/",          // required for __Host-
    maxAge: SEVEN_DAYS, // seconds, not milliseconds
    // no `domain` on purpose: host-only cookie
  });
}

export async function clearSessionCookie() {
  const cookieStore = await cookies();
  cookieStore.delete(SESSION_COOKIE);
}

export async function readSessionId(): Promise<string | undefined> {
  const cookieStore = await cookies();
  return cookieStore.get(SESSION_COOKIE)?.value;
}

Two details I tripped over. First, maxAge is in seconds, the same unit as the HTTP attribute. I once passed a millisecond value and created a cookie that lived for about nineteen years (well, 400 days, thanks to Chrome's cap). Second, the dev fallback: a __Host- cookie must be Secure, and not every browser accepts Secure cookies on http://localhost. Switching the name by environment keeps local development painless while production gets the full protection.

Rotate the session on login

Good cookie attributes do not help if the ID itself is predictable or reused. Two rules I follow:

  • Generate IDs with a cryptographically secure random source. crypto.randomBytes(32) gives 256 bits, which is far beyond guessable. Never use Math.random() or an incrementing database ID.
  • Issue a brand new session ID at login and whenever privileges change (password reset, enabling 2FA, switching to an admin role). Reusing a pre-login ID is exactly what session fixation exploits.

Here is the login Server Action that ties it together:

// app/login/actions.ts
"use server";

import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { verifyPassword } from "@/lib/password";
import {
  newSessionId,
  readSessionId,
  setSessionCookie,
} from "@/lib/session-cookie";

export async function login(formData: FormData) {
  const email = String(formData.get("email") ?? "");
  const password = String(formData.get("password") ?? "");

  const user = await db.user.findUnique({ where: { email } });
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return { error: "Invalid email or password" };
  }

  // Throw away any session that existed before login (anti-fixation)
  const previous = await readSessionId();
  if (previous) {
    await db.session.deleteMany({ where: { id: previous } });
  }

  const id = newSessionId();
  await db.session.create({
    data: {
      id,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  await setSessionCookie(id);
  redirect("/dashboard");
}

Logout does the mirror image: delete the session row first, then clear the cookie. If you only clear the cookie, a copy of it stolen earlier still works until it expires on the server.

How I check it is actually working

I do not trust that the attributes made it to the browser until I see them. My routine:

  1. Open DevTools, go to Application → Cookies, and confirm the HttpOnly, Secure and SameSite columns for the session cookie.
  2. In the console, run document.cookie and make sure the session cookie is not in the output.
  3. Run curl -I against the login endpoint on the deployed site and read the raw Set-Cookie header. Proxies and CDNs occasionally rewrite or strip attributes, so check the real production response, not just local dev.
  4. If a __Host- cookie silently is not being set, look at the DevTools Network tab: Chrome shows a warning icon on the response explaining which prefix rule it violated. Nine times out of ten it is a Domain attribute someone added.

What cookies will not fix

It is easy to overstate this. Hardened cookies reduce the blast radius of other bugs; they do not replace fixing them. HttpOnly does not make XSS harmless, it only stops token theft. SameSite=Lax is strong CSRF protection for POST, but if you have state-changing GETs, or need to support cross-site embedding with SameSite=None, you still need a proper CSRF token or an Origin header check. And none of this matters if your session store never expires rows or you skip authorisation checks in your Server Actions.

Key takeaways

  • Ship session cookies with HttpOnly, Secure, an explicit SameSite=Lax and a Max-Age in seconds.
  • Name the cookie with the __Host- prefix so subdomains cannot plant or overwrite it; it requires Secure, Path=/ and no Domain.
  • Keep state-changing actions on POST, because Lax still sends cookies on cross-site GET navigations.
  • Generate session IDs with crypto.randomBytes and rotate them on login and privilege changes.
  • Enforce expiry on the server, delete the session row on logout, and verify the real Set-Cookie header in production.
Next.jsSecurityAuthenticationTypeScript
4views

Written by Admin

Published November 8, 2026 · Updated Nov 13, 2026