OwlTheEngineer

Cookie flags: HttpOnly, Secure, SameSite

Three attributes decide whether your cookie is sent at all. Here's what each one does, and why yours is missing.

Owl Backend 7 min read

The bug always looks the same. It works on localhost. It works in Postman. In the browser, on the real domain, the request arrives with no cookie and your server says 401 — and nothing in your code is wrong.

The cookie is set. It’s just not being sent. That decision isn’t yours; it’s the browser’s, and it’s made entirely from the flags you put on the cookie when you handed it over.

One name, one value, and a list of attributes:

Set-Cookie: sid=9f2c1b8e4a7d; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600

sid=9f2c1b8e4a7d is the whole cookie. Everything after the first semicolon never travels back to you — the browser keeps the attributes for itself and uses them to answer one question, on every single request:

Do I attach this cookie to this request, or not?

Three attributes do almost all of that work.

Secure — https only

Set-Cookie: sid=...; Secure

The browser will only attach the cookie over https. On plain http it simply isn’t sent, and the request goes out looking like an anonymous one.

This is not hygiene. If you’re using a session cookie, that string is the session. There’s no password in it, no second factor, nothing to crack — whoever reads it off the wire is you, on your account, until it expires. One request over http on a café network is the entire attack.

The practical catch: localhost is treated as a secure context, so Secure cookies work in local development and then vanish the moment you test over an IP address or a plain-http staging box. That’s usually the first “it worked yesterday”.

HttpOnly — your JavaScript can’t see it

Set-Cookie: sid=...; HttpOnly

Without it:

document.cookie
// "sid=9f2c1b8e4a7d; theme=dark"

With it:

document.cookie
// "theme=dark"

The session is gone from that list. Not deleted — hidden. The browser still stores it and still sends it on every request to your domain. It just refuses to hand it to script.

That distinction is the part people get wrong in both directions:

  • It does not stop XSS. A script injected into your page can still make requests, and the browser will happily attach the cookie to them. HttpOnly doesn’t stop an attacker acting as the user inside your page.
  • It does stop exfiltration. The attacker can’t read the token, so they can’t post it to their own server and reuse it from their laptop, next week, from anywhere. That turns “permanent account takeover” into “damage while the tab is open”, which is a very large difference.

So: HttpOnly is not a fix for XSS. It’s the thing that stops one XSS from becoming a stolen credential.

SameSite — who started this request?

This is the one that breaks things, and the reason is that it isn’t about your site at all. It’s about the page the request came from.

The browser looks at the site in the address bar, compares it to the site the request is going to, and asks: same site, or different?

Where the request startedWhat happens
The same siteThe cookie is sent. Always. Whatever the value.
A different siteNow the value decides.

Three values, and only three:

ValueSent cross-site?
LaxOnly when the user clicked through to you — a normal link, a top-level navigation. Not on a cross-site POST, not on an image, iframe or fetch.
StrictNever. Not even when someone clicks a link to you from another site.
NoneAlways — and the browser rejects it unless you also set Secure.

Lax is the modern default: a cookie with no SameSite at all behaves as Lax in current browsers. Which means the default is already the sensible one, and the two times you’ll notice SameSite are the two times you deviated from it.

What each one costs

Strict sounds like the safe choice and has a very visible side effect: someone clicks a link to your app from Slack or an email, lands on it, and is logged out — because the cookie didn’t come along on the way in. They refresh, and now they’re logged in, which reads as a flaky app. Use it for genuinely dangerous surfaces, not for your whole session.

None is the one to be deliberate about. It means “send this cookie to me from anybody’s page”, which is exactly the condition CSRF needs. If you set None because your frontend is on a different domain from your API, you’ve taken on the protection the flag was doing for you, and you now need something else — a CSRF token, or an Origin check on every state-changing request.

The debugging table

When the cookie isn’t being sent, it’s nearly always one of these:

SymptomLikely cause
Works on localhost, not on stagingSecure on a non-https origin
Works on your domain, not from your SPA on another domainSameSite=Lax blocking a cross-site fetch
Set, but never sent back at allDomain or Path doesn’t match the request
Sent on GET, not on POSTSameSite=Lax — a cross-site POST doesn’t carry it
Browser logged “rejected because of SameSite=None”None without Secure
Cookie is there, document.cookie is emptyHttpOnly, working exactly as intended

Open the network tab, click the request, and read the Cookies panel. The browser will tell you which cookie it withheld and why. That’s faster than any amount of reasoning about your own code.

The other attributes, briefly

They matter less, but they’re the rest of the line:

  • Path — the URL prefix the cookie is sent under. Almost always /. A cookie scoped to /api and read from / is a confusing afternoon.
  • Domain — set it and the cookie also goes to subdomains; leave it off and it stays on the exact host. Leaving it off is the tighter default, and the right one unless you actually need app.example.com and api.example.com to share.
  • Max-Age / Expires — without either, it’s a session cookie that dies with the browser. With one, it’s a login that survives a restart. Pick a number; “forever” is not a number.
  • __Host- prefix — name the cookie __Host-sid and the browser enforces the good configuration for you: it must be Secure, Path=/, and have no Domain. Free belt-and-braces, one rename.

So where does the token go?

This is the question the flags actually answer, and there are only two candidates.

In a cookie, the browser sends it. Automatically, on every request to your origin, whether or not you remembered to. Add HttpOnly and your own JavaScript can’t read it, which means a malicious script can’t either.

In localStorage, you send it. You read it and attach an Authorization header on every request yourself. And anything running on that page can read it too — there is no HttpOnly for localStorage, and there never will be. That’s not an oversight; it’s a store designed for your code to read.

CookielocalStorage
Who attaches itThe browserYour code
Readable by scriptNot with HttpOnlyAlways
Sent cross-originOnly if you allow itWherever you send it
Survives a tab closeWith Max-AgeYes
Works with fetch from another domainNeeds SameSite=None + CORSTrivially

That’s the whole trade: the browser does it and hides it, or you do it and expose it.

Three mistakes I see constantly

  1. Reaching for SameSite=None to fix a cross-domain call, and stopping there. It works, and it silently removes your CSRF protection. If you set it, add an Origin check the same afternoon.
  2. Putting a token in localStorage because a tutorial did. Ask what it bought you. If your frontend and API share a site, the answer is nothing — you took on readable-by-any-script for a header you now have to remember to attach.
  3. Treating HttpOnly as an XSS fix. It caps the blast radius. It doesn’t stop the attacker doing things as the user right now, and shipping it doesn’t mean you can stop sanitising.

What to say when you’re asked

A cookie is a name, a value, and attributes that tell the browser when to send it. Secure restricts it to https, because the session cookie is the session. HttpOnly hides it from document.cookie, so an injected script can’t steal it — though the browser still sends it. SameSite decides whether it travels on requests another site started: Lax by default, which allows a plain click-through and nothing else. I’d default to a cookie with all three, and reach for localStorage only when the token genuinely has to cross to another origin.

And when a cookie isn’t being sent: don’t read your code. Read the network tab. The browser already knows which flag stopped it.