A session cookie is a bearer token: whoever holds it is the user. The attributes on the Set-Cookie header are the only thing standing between that token and a network observer, an injected script, or another site's form. This guide ships those attributes in the order that produces the least breakage — each step is independently deployable, and none of them changes the cookie value or your session store.
Step 1 — Inventory What You Actually Set
You cannot secure a cookie you do not know about, and almost every site sets more than its developers think. Load the application in a fresh browser profile, walk the main flows — landing page, login, checkout — and record every Set-Cookie header, including the ones written by tag managers, chat widgets, video embeds and analytics.
# Everything the server sets on a single response
curl -sI https://example.com | grep -i '^set-cookie:'
# Follow redirects — login flows often set the session on a 302
curl -sIL https://example.com/login | grep -i '^set-cookie:'Build a short table as you go: cookie name, what sets it, what reads it, and whether it identifies a user. That last column decides everything that follows. A cookie audit does the same sweep from the outside and catches the ones your local environment does not set.
Step 2 — Add Secure Everywhere
Secure keeps the cookie off plain HTTP. On an HTTPS-only site it has no functional effect whatsoever, which makes it the safest change in this guide and the right one to ship first — it shrinks the exposure while you work through the rest.
Set-Cookie: session=abc123; Path=/; SecureMost frameworks want it tied to the environment rather than hardcoded:
// Express — trust the proxy so req.secure is accurate behind a load balancer
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { secure: process.env.NODE_ENV === 'production' },
}));Secure "only on HTTPS" decides that from the request it sees, which is plain HTTP when TLS terminates at the load balancer. Configure the trusted-proxy setting (trust proxy, ForwardedHeaders, SECURE_PROXY_SSL_HEADER) or the flag will silently never be set in production.Step 3 — Add HttpOnly to Anything Sensitive
HttpOnly removes the cookie from document.cookie, so an XSS payload cannot read it and replay the session from elsewhere. It does not fix the XSS bug — a script running in your page can still act as the user — but it stops the credential from leaving the browser.
Before shipping, grep the front end for reads of the cookie you are about to hide. If something does read it, the fix is to deliver that value in the page rather than to leave the cookie exposed:
<!-- Server renders the CSRF token into the page; the cookie copy stays HttpOnly -->
<meta name="csrf-token" content="{{ csrf_token }}">const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': token },
body: JSON.stringify(payload),
});The server compares the header against the HttpOnly cookie. Both halves are needed, only one is reachable from script, and the double-submit check still works.
Step 4 — Set SameSite Deliberately
Browsers already default to Lax, so this step is about intent and about the cookies that need something other than the default. Write the value out even when it matches — an absent attribute is indistinguishable from an unconsidered one.
| Cookie | Value | Reasoning |
|---|---|---|
| Session / auth | Strict | Never legitimately needed on a cross-site request |
| CSRF token | Strict | Only read by your own same-site posts |
| Login hint / UI state | Lax | Lets an inbound link render the logged-in shell |
| Preferences, consent | Lax | Low value; matches the browser default |
| Embedded widget, federated login | None | Genuinely crosses sites; requires Secure |
Strict cookie, so the landing page renders logged out. Do not weaken the session cookie to fix this — add a small, non-sensitive Lax cookie that says only "a session exists", and render the correct shell from that. SameSite explained covers the trade-off in full.Step 5 — Tighten Scope and Lifetime
Two attributes decide who receives the cookie and for how long, and both default to the safest answer if you leave them alone.
Omitting Domain produces a host-only cookie sent to exactly the host that set it. Adding Domain=example.com hands it to every subdomain — a marketing site on a third-party platform, a status page, a forgotten staging host. Any one of those can then read a live session. Do not set Domain without a concrete reason.
Path looks like a boundary and is not one: the same-origin policy does not partition by path, so any script on the origin can reach a cookie scoped to /admin. Use Path=/ and rely on the other attributes.
For lifetime, the question is how long a stolen value stays useful. Max-Age takes seconds and wins over Expires wherever both are present. A couple of hours for an authenticated session, backed by a server-side idle timeout, is a reasonable default. A year-long analytics cookie is a persistent identifier with consent obligations attached.
# Host-only, whole-site path, two-hour window
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=7200Step 6 — Adopt the __Host- Prefix
A prefix moves enforcement out of your code and into the browser. It is part of the cookie name, so nothing else changes:
| Prefix | Browser refuses the cookie unless | Use for |
|---|---|---|
__Host- | Secure is set, Path=/, and no Domain | Session and CSRF cookies |
__Secure- | Secure is set | Cookies that must span subdomains |
The value of a prefix is that it fails loudly. If a later refactor drops Secure or bolts on a Domain, the browser rejects the cookie and login breaks in testing — instead of quietly continuing to work while being weaker than you think. The one cost is that renaming logs everyone out once, so ship it in a quiet window or read both names for a single release.
Step 7 — Handle the Cookies You Do Not Control
Third-party scripts write cookies on your domain, and those cookies are yours as far as any audit is concerned. When you cannot change the code that sets them, rewrite the header at the edge:
# nginx 1.19.3+ — stamp attributes onto every cookie from the upstream
proxy_cookie_flags ~ secure httponly samesite=lax;
# Older nginx, or when the path also needs rewriting
proxy_cookie_path / "/; Secure; HttpOnly; SameSite=Lax";# Apache — append attributes to any Set-Cookie that lacks them
Header always edit Set-Cookie ^((?!.*;\s*Secure).*)$ "$1; Secure; HttpOnly; SameSite=Lax"Treat proxy rewriting as a stopgap that buys time, not as the fix. It applies bluntly to every cookie from that upstream, so a widget that genuinely needs SameSite=None will break, and the rule is easy to forget when the upstream is later replaced.
Step 8 — Verify, Then Keep Verifying
A change is not shipped until you have seen the header from outside your own machine. Check the deployed site, not localhost — the proxy configuration, the environment variable and the TLS termination all differ.
curl -sIL https://example.com/login | grep -i '^set-cookie:'Attributes drift. A new dependency lands, a framework upgrade changes a default, someone adds a Domain to fix a subdomain bug. A check that runs once at rollout tells you nothing about the state six months later, so re-run the cookie audit on each release and treat a regression as a blocker.
Rollout Checklist
| Step | Risk of breakage | Verify by |
|---|---|---|
| Inventory every Set-Cookie | None | A list with a name, a setter and a reader per cookie |
Add Secure | None on an HTTPS-only site | The flag appears on the deployed response |
Add HttpOnly | Low — breaks scripts that read the cookie | The cookie is absent from document.cookie |
Set SameSite | Medium — Strict affects inbound links | Click an external link into a logged-in page |
Drop Domain, use Path=/ | Medium — breaks intentional subdomain sharing | Sign in and exercise every subdomain you own |
Shorten Max-Age | Low — more frequent re-authentication | Session expires when expected |
Rename to __Host- | One-time logout for all users | Login works after a full cookie clear |
Frequently Asked Questions
1. Which cookies actually need securing?
Anything that identifies a user or authorises an action: session cookies, CSRF tokens, remember-me tokens, impersonation and admin flags. A theme preference does not need HttpOnly — its whole purpose is to be read by script — but it still gets Secure and an explicit SameSite. Decide per cookie, not per site.
2. Will adding Secure break anything?
Only on plain HTTP, where the cookie will simply not be sent. On an HTTPS-only site there is no functional side effect at all, which is why it is the first change to ship. If part of your site still answers on HTTP, fix that first — a session cookie travelling in cleartext is the larger problem.
3. I set HttpOnly and my front end broke. Now what?
Something in the browser was reading that cookie through document.cookie. Move the value into the page instead — a meta tag, a hidden field, or a JSON blob the server renders — and leave the cookie copy HttpOnly. For CSRF specifically the double-submit pattern still works, because the comparison happens server-side.
4. Do I have to fix cookies set by third-party scripts?
They are on your domain, so they are your responsibility and they appear in any audit of your site. You usually cannot change the attributes directly, but you can update or remove the script, restrict it with CSP, or rewrite the Set-Cookie header at your reverse proxy. Start by inventorying them — most teams do not know they are there.
5. Is renaming a cookie to __Host- safe to deploy?
It logs everyone out once, because the old cookie name is no longer read. Deploy it during a low-traffic window, or read both names for one release and write only the new one. After that the browser enforces Secure, Path=/ and no Domain for you, permanently.
6. How do I verify the change actually shipped?
Run the page through the Cookie Security Checker — it reads every Set-Cookie header the server returns and grades Secure, HttpOnly, SameSite, prefix and lifetime per cookie, including cookies you did not know you were setting.