Every login ends with a Set-Cookie header, and almost every one I review is missing an attribute that would have cost nothing to add. Secure cookies are not a later feature — they are a handful of attributes, each closing a specific hole. This is my working model for session management: what a cookie is and is not, what HttpOnly, Secure, and SameSite actually do, how session and token cookies differ for CSRF, and the mistakes — session fixation chief among them — that keep showing up in production.
To make it concrete I built a throwaway HTTPS server on two hosts (site.test, evil.test, both on 127.0.0.1) and drove it with curl and headless Chrome. Every header below is real output.
What a cookie is — and what it is not
A cookie is a small name=value pair the browser stores and attaches to matching requests automatically. Three things people get wrong:
- It is not a session. A cookie is an identifier; the session state lives server-side (or in a signed token). Deleting the cookie does not end a session unless the server forgets the record.
- It is never encrypted. The value is plain text — visible in devtools, in logs, in a proxy, in
curl’s own cookie jar. HTTPS protects it in transit only. Never put in a cookie what you would not show the user. - It is user-visible and user-editable. No magic. Here is a jar written by
curl -cfor a cookie a browser would markHttpOnly:
$ curl -sk --noproxy '*' --resolve site.test:8443:127.0.0.1 \
-c jar.txt https://site.test:8443/set-lax -o /dev/null
$ cat jar.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_site.test FALSE / TRUE 1792404531 session lax-tok-9f3a
The token sits there in cleartext. HttpOnly hides it from JavaScript only — not from the user, the disk, or a proxy. Treat the value as a bearer credential and every incoming cookie as untrusted input.
The attributes that actually matter
Each attribute closes one class of attack, at one layer:
HttpOnly and Secure are browser storage and transport rules, SameSite a request rule, and Path/Domain/Max-Age scope and expiry rules. None of them is a session.The canonical session cookie, straight off the wire:
$ curl -skv --noproxy '*' --resolve site.test:8443:127.0.0.1 \
https://site.test:8443/set-lax 2>&1 | sed -n '/^> GET/,/^< Content-Length/p'
> GET /set-lax HTTP/1.1
> Host: site.test:8443
...
< HTTP/1.1 200 OK
< Date: Mon, 12 Oct 2026 10:11:21 GMT
< Set-Cookie: session=lax-tok-9f3a; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax
< Content-Type: text/plain; charset=utf-8
HttpOnly—document.cookiewill not expose it. This does not stop XSS; it stops XSS from stealing the session, the difference between a defaced page and an account takeover.Secure— sent only over HTTPS; the same jar replayed over plain HTTP is dropped, bycurlas well as browsers.SameSite=Lax— attached to top-level navigations, withheld from cross-sitePOSTs and subresources. The right default for a session cookie.Path=/, noDomain— scoped to the exact host. Deliberate; see the prefix rules below.Max-Age=604800— lifetime in seconds (Expiresis the older absolute-date form, andMax-Agewins when both appear). Omit both and you get a browser-session cookie.__Host-/__Secure-prefixes — browser-enforced naming rules.__Secure-foomust be set withSecure;__Host-foomust beSecure, must havePath=/, and must not have aDomain. Violate the rules and the browser silently discards the cookie.
A server sending one valid and two deliberately invalid prefixed cookies, and what Chrome kept:
# Server response (three Set-Cookie headers, one valid)
< Set-Cookie: __Host-sid=host-tok; Path=/; HttpOnly; Secure; SameSite=Lax
< Set-Cookie: __Host-bad=host-bad; Path=/; Domain=site.test; Secure # __Host- + Domain → invalid
< Set-Cookie: __Secure-bad=sec-bad; Path=/; SameSite=Lax # __Secure- without Secure → invalid
# Cookies Chrome actually stored afterwards (via CDP Network.getAllCookies)
{"name":"session", "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Lax"}
{"name":"strict_sess", "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Strict"}
{"name":"__Host-sid", "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Lax"}
{"name":"embed_tok", "domain":"site.test","path":"/","secure":true, "httpOnly":false,"sameSite":"None"}
__Host-bad and __Secure-bad are absent, and so is a SameSite=None cookie sent without Secure. The prefixes make the browser prove a cookie was set with safe attributes: a mistake becomes a cookie that never exists, rather than one that silently leaks to subdomains.
Session cookies vs token cookies, and the CSRF split
Two shapes dominate, and they differ in the way that matters for CSRF:
- Server-side session cookie. The cookie holds an opaque id; the server keeps the state, so it is easy to revoke and rotate. Because the browser attaches it automatically, it is CSRF-exposed — hence
SameSiteand a CSRF token. - Token cookie (JWT in a cookie). Self-contained and stateless, but revocation is the hard part: a signed token is valid until it expires unless you keep a denylist — at which point you have a session table again. Still a cookie, so the browser still attaches it and CSRF still applies.
- Token in
localStorage. Not a cookie, so the browser never auto-attaches it and classic CSRF disappears — but any XSS can read it andHttpOnlycannot protect it: a CSRF problem traded for a larger XSS one.
The rule I hold to: session id in an HttpOnly cookie, state server-side so it can be revoked, a CSRF token for state-changing requests, and tokens in memory rather than localStorage. The mechanics are in XSS and CSRF for backend engineers.
What SameSite actually protects against
SameSite controls whether the browser attaches the cookie to a cross-site request — and “site” is looser than “origin”: the registrable domain plus scheme, so app.example.com and api.example.com are the same site despite being different origins.
Lax— sent on top-level navigations (clicking a link), withheld on cross-sitePOSTs and subresources. The current browser default.Strict— never sent cross-site, including top-level navigations. Strongest, but it logs users out when they arrive from an external link.None— always sent; requiresSecure, or the browser drops it.
The subtlety is who enforces it. SameSite is a browser policy, not a server one — a non-browser client ignores it. Here curl replays a SameSite=Strict cookie while truthfully telling the server the request is cross-site:
$ curl -skv --noproxy '*' --resolve site.test:8443:127.0.0.1 -b strictjar.txt \
-H 'Origin: https://evil.test:8444' -H 'Sec-Fetch-Site: cross-site' \
https://site.test:8443/echo 2>&1 | grep -E '^(<|>) '
> GET /echo HTTP/1.1
> Host: site.test:8443
> Cookie: strict_sess=str-tok
> Origin: https://evil.test:8444
> Sec-Fetch-Site: cross-site
The cookie went out anyway — your server cannot enforce SameSite, only the browser can. In Chrome, after visiting site.test and setting Lax, Strict, and None cookies, then loading evil.test:
# evil.test makes a cross-site fetch() to site.test, then navigates to it.
# site.test logs the Cookie header it actually received:
# cross-site subresource (fetch, credentials:'include'):
GET /echo Origin='https://evil.test:8444' Sec-Fetch-Site='cross-site' Cookie='embed_tok=none-tok' count=1
# → only the SameSite=None cookie arrived; Lax and Strict were withheld
# cross-site top-level navigation (location.href):
GET /echo Origin='None' Sec-Fetch-Site='cross-site' Cookie='session=lax-tok-9f3a; __Host-sid=host-tok; embed_tok=none-tok' count=3
# → Lax came through; SameSite=Strict was withheld even here
That is the whole model in two lines: Lax on navigations, Strict never cross-site, None everywhere.
What SameSite does not do:
- It does not span subdomains.
SameSite=Stricttreatsevil.yourdomain.exampleas same-site. If a tenant, user content, or a compromised subdomain lives under your registrable domain, a request from there is same-site and the cookie is attached. - It does not stop same-site CSRF where the attacker controls a same-site endpoint.
- It does not replace a CSRF token. The two overlap on the common cross-site
POST, but the token is what proves user intent — keep both.
The classic mistakes
- Session id in
localStorage. NotHttpOnly-able; any XSS reads it. Put it in a cookie. - No rotation on login. The pre-login id stays valid after authentication — the setup for session fixation.
- Cookie without
Secure. One HTTP request, an HSTS gap, or a redirect on public Wi-Fi and the credential is on the wire. Domaintoo broad.Domain=.example.comhands the session to every subdomain, including the stale WordPress install. Omit it to stay host-only.- No server-side invalidation on logout. Deleting the cookie clears the client; if the record is still valid, a stolen copy works.
- Storing data in the cookie instead of an id. It bloats every request and, since cookies are not encrypted, exposes the payload to the user and any log.
Session fixation, and why you rotate the id
Session fixation is the attack these attributes do not cover. The attacker arranges for the victim’s browser to use a session id the attacker already knows — via a link, a Set-Cookie from a controlled page, or a pre-auth endpoint — then waits. When the victim logs in, the server promotes that same id to an authenticated session, and the attacker holds a valid session id without ever seeing a password.
The fix is one rule: regenerate the session id on every privilege change — login, logout, password change, role escalation. Issue a new server-side id and invalidate the old one:
import secrets
def log_in(session_store, old_id, user):
# Never reuse the pre-auth id: drop it and mint a fresh one.
session_store.delete(old_id)
new_id = secrets.token_urlsafe(32) # 256 bits, unpredictable
session_store.create(new_id, user_id=user.id, csrf=secrets.token_urlsafe(32))
return new_id
Storing the id server-side makes rotation cheap and revocation possible. A stateless token has no delete() — the real cost of statelessness.
A secure-cookie checklist
The defaults I set unless there is a specific reason not to:
# Session cookie: the attributes matter as much as the value.
response.set_cookie(
"__Host-sid", # prefix → browser enforces Secure + Path=/ + no Domain
new_id,
httponly=True, # JavaScript cannot read it
secure=True, # never sent over plain HTTP
samesite="Lax", # withheld from cross-site POSTs and subresources
path="/", # required by the __Host- prefix
max_age=60 * 60 * 24 * 7, # an explicit lifetime
)
- Name it
__Host-sidand let the browser reject a misconfiguration for you. HttpOnly+Secure+SameSite=Laxon the session cookie; useStrictonly where the login flow tolerates it.- Omit
Domainso the cookie is host-only, never shared across subdomains. - Rotate the id on login, logout, and privilege change; store sessions server-side so you can invalidate them.
- Set an explicit
Max-Ageand refresh by rotating, not extending forever. - Add a CSRF token for state-changing requests, verified in constant time —
SameSiteis a strong default, not a complete defence. - Serve everything over HTTPS with HSTS, and pair it with a strict Content-Security-Policy and the rest of the security headers.
# The Secure flag in action: same jar, plain HTTP — curl refuses to attach it.
> GET /echo HTTP/1.1
> Host: site.test:8099
> User-Agent: curl/8.22.0
> Accept: */*
# no Cookie header was sent; the server reports an empty cookie string
received=''
count=0
Browsers refuse a Secure cookie over http:// too — the flag protects the credential only if every request that needs it is already on HTTPS.
HttpOnly, Secure, SameSite, the prefixes, and server-side rotation are cheap and independent. Set them by default, rotate on privilege change, and stop sending your weakest link on every login.