How to Secure Your Cookies

Cookie hardening is a sequence of small, independently shippable changes, not one big migration. Here is the order that minimises breakage, with the code for each step and a way to verify it landed.


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.

Order matters more than completenessDoing all of this at once is how cookie hardening turns into a rollback. Secure first, then HttpOnly, then SameSite, then scope and lifetime, and prefixes last. Each step shrinks the blast radius before the next one risks anything.

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.

bash
# 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.

http
Set-Cookie: session=abc123; Path=/; Secure

Most frameworks want it tied to the environment rather than hardcoded:

javascript
// 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' }, }));
Behind a proxy, the app may not know it is on HTTPSA framework that sets 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:

html
<!-- Server renders the CSRF token into the page; the cookie copy stays HttpOnly --> <meta name="csrf-token" content="{{ csrf_token }}">
javascript
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.

CookieValueReasoning
Session / authStrictNever legitimately needed on a cross-site request
CSRF tokenStrictOnly read by your own same-site posts
Login hint / UI stateLaxLets an inbound link render the logged-in shell
Preferences, consentLaxLow value; matches the browser default
Embedded widget, federated loginNoneGenuinely crosses sites; requires Secure
Strict logs users out of the first clickA user arriving from an email or a search result sends no 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.

http
# Host-only, whole-site path, two-hour window Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=7200
Rotate the session ID at loginAttributes do not help against session fixation, where an attacker plants a known session ID before the victim signs in. Issue a fresh identifier on every privilege change — login, elevation, impersonation — and invalidate the old one server-side.

Step 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:

PrefixBrowser refuses the cookie unlessUse for
__Host-Secure is set, Path=/, and no DomainSession and CSRF cookies
__Secure-Secure is setCookies 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
# 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
# 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.

bash
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

StepRisk of breakageVerify by
Inventory every Set-CookieNoneA list with a name, a setter and a reader per cookie
Add SecureNone on an HTTPS-only siteThe flag appears on the deployed response
Add HttpOnlyLow — breaks scripts that read the cookieThe cookie is absent from document.cookie
Set SameSiteMedium — Strict affects inbound linksClick an external link into a logged-in page
Drop Domain, use Path=/Medium — breaks intentional subdomain sharingSign in and exercise every subdomain you own
Shorten Max-AgeLow — more frequent re-authenticationSession expires when expected
Rename to __Host-One-time logout for all usersLogin works after a full cookie clear
Check what your site sends todayAudit every Set-Cookie header a page returns and see which cookies are missing Secure, HttpOnly or SameSite.
Run a check

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.

Related Articles