Security · Lesson 09
Cookies, sessions & CSRF
HTTP is born without memory — every request stands alone. Cookies are the mechanism that bolts memory on top, and that mechanism, when misused, creates one of the most persistent attack classes in web security: Cross-Site Request Forgery.
By the end you'll be able to
- Explain what cookies are and what each Set-Cookie attribute does to the security posture.
- Compare server-side sessions and token auth (JWT in Authorization header) and choose the right fit for an API.
- Describe CSRF — why cookies trigger it, why tokens-in-headers largely don't, and what defenses close the gap.
The statelessness problem
Imagine a hotel that forgets you exist the moment you step out of the lobby. Every time you return you'd have to introduce yourself from scratch. HTTP is that hotel. By design, each request carries no memory of earlier ones. That property keeps servers simple and scalable, but it means every HTTP request that needs context — "who are you?", "what's in your basket?" — must carry that context explicitly.
Cookies are the envelope the browser uses to carry that context automatically. They're small key-value strings the server plants in the browser via a response header, and the browser re-attaches to every subsequent matching request without the developer writing a single line of code for it.
How a cookie is born and reused
The server sends a Set-Cookie header in a response. From that moment the browser stores the cookie and echoes it back on every request to the same origin — until it expires or the server deletes it.
Set-Cookie attributes that matter for security
A naked Set-Cookie: session=abc123 is a security hole with a ribbon on it. The following attributes close those holes one by one:
| Attribute | What it prevents | Set it when |
|---|---|---|
HttpOnly |
JavaScript (document.cookie) cannot read the value — limits XSS exfiltration. |
Always, for session/auth cookies. |
Secure |
Cookie is only sent over HTTPS — prevents sniffing on plaintext networks. | Always in production. |
SameSite=Strict |
Cookie is never sent on cross-site requests — strongest CSRF defence. | Pure APIs; downsides for OAuth flows. |
SameSite=Lax |
Cookie is sent on top-level navigations (GET) but not cross-site POST/fetch — good balance. | Most web apps. Browser default since ~2021. |
SameSite=None; Secure |
No restriction — required for third-party embed flows (e.g. federated login iframes). | Only when cross-site sending is intentional. |
Domain=.example.com |
Scopes the cookie to a domain (and subdomains if dot-prefixed). | Multi-subdomain apps; keep as narrow as possible. |
Path=/api |
Restricts the paths that receive the cookie. | When your app has multiple unrelated paths. |
Max-Age=3600 / Expires |
Sets a hard expiry — prevents cookies that live forever. | All auth cookies; shorter for sensitive sessions. |
Session auth vs. token auth
The session ID is just a pointer — a random string that maps to the real user data stored server-side. Token auth (most commonly a JWT in the Authorization: Bearer header) inverts this: the token itself carries the claims, and the server validates the signature without querying a store.
The right choice depends on the access pattern. For a browser-based web app where revocation on logout matters, sessions fit well. For a distributed API serving mobile and third-party clients, stateless JWT in a header removes the coordination overhead — but you must keep expiry short and maintain a token denylist for critical revocation scenarios (leaked credential, account deletion).
"Should this API use cookies or tokens?" is a staple design question. The honest answer is "it depends on who the client is." Cookies make sense for server-rendered browser apps because the browser manages them automatically and HttpOnly keeps JavaScript out. Tokens in headers are better for native apps, CLIs, and cross-origin APIs because they don't have the automatic-sending behavior that CSRF exploits. Name the tradeoff explicitly — state management and revocation vs. simplicity and cross-origin flexibility — and you'll stand out.
CSRF: when the browser's helpfulness becomes a weapon
Cross-Site Request Forgery exploits the one thing that makes cookies convenient: the browser sends them automatically, even on requests triggered by a different website. Imagine you log into your bank at bank.example. Your session cookie sits in the browser's jar. Now you visit a malicious page at evil.example that contains:
<!-- On evil.example — the user never sees this form -->
<form method="POST" action="https://bank.example/transfer">
<input name="to" value="attacker_account" />
<input name="amount" value="5000" />
</form>
<script>document.forms[0].submit();</script>
The browser fires a POST to bank.example/transfer and, because the session cookie's Domain matches, it attaches it. The bank server sees a valid authenticated request. The transfer goes through. The victim never clicked anything.
Notice why a JWT in Authorization: Bearer doesn't have this problem: the browser has no built-in mechanism to add a custom header to a cross-site form submission. Only cookies travel automatically.
CSRF defences
Three approaches, layered or used individually:
- SameSite=Lax or Strict. The modern first line of defence.
Laxblocks cross-site POST/fetch while allowing top-level navigations.Strictblocks everything cross-site. Either kills the hidden-form attack above. All browsers since 2021 default to Lax even when you don't ask — but set it explicitly; never rely on browser defaults. - CSRF tokens (synchroniser token pattern). The server generates a secret random value when it issues the session, stores it, and embeds it in every form as a hidden field and/or a cookie. On each state-changing request, the server checks that the submitted token matches. The attacker's page cannot read the token (same-origin policy blocks it), so forged requests fail even if they carry the session cookie.
- Double-submit cookie. Server sets a second cookie with a random value; the client copies it into a custom header (e.g.
X-CSRF-Token). Server verifies they match. Because evil.example can't read or set cookies on bank.example, it can't forge the header value. Simpler for SPAs than the synchroniser pattern, but weaker if there's a subdomain compromise.
State-changing GETs. If GET /delete-account?confirm=true actually deletes the account, SameSite=Lax won't help — Lax allows GET requests from cross-site navigation. State changes must always be POST, PUT, PATCH, or DELETE. This is also a plain REST violation, but the security consequence is concrete: anyone can embed an <img src="https://yourapi/nuke-data"> and trigger it on load.
Missing HttpOnly/Secure. Skipping HttpOnly means any XSS payload can steal the session token with one line. Skipping Secure means the cookie travels over HTTP on a coffee-shop network. Both are trivially exploited. Add them unconditionally on auth cookies.
Worked example: a secure login flow
Here's a complete login sequence with a hardened Set-Cookie and a CSRF token for a subsequent state-changing call:
# Step 1 — client authenticates
POST /v1/auth/login HTTP/1.1
Host: api.acme.com
Content-Type: application/json
{ "email": "ada@example.com", "password": "correct-horse-battery" }
# Step 2 — server responds with a hardened session cookie
HTTP/1.1 200 OK
Set-Cookie: sid=8f3a9c12d7; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Content-Type: application/json
{ "csrf_token": "v2:nonce:HmacSha256(...)" }
# Step 3 — client makes a state-changing request
POST /v1/transfers HTTP/1.1
Host: api.acme.com
Cookie: sid=8f3a9c12d7 # browser sends automatically
X-CSRF-Token: v2:nonce:HmacSha256(...) # client must add this explicitly
Content-Type: application/json
{ "to_account": "acc_7890", "amount_cents": 1500 }
# Step 4 — server validates both the session AND the CSRF token before acting
HTTP/1.1 201 Created
Do set HttpOnly; Secure; SameSite=Lax on every auth cookie. Don't rely on just one layer: SameSite alone is sabotaged by misconfigured subdomains; CSRF tokens alone don't protect against XSS. Think of them as independent locks on the same door — each one fails differently, so use both. For token-based APIs, keep the JWT in the Authorization header rather than a cookie; if you must cookie a JWT, all of the above still applies.
Under the hood: how it actually works
Understanding the exact decision tree the browser runs when it receives and re-sends a cookie lets you predict the behavior and debug without guessing.
Step 1 — parsing Set-Cookie
When the browser receives a Set-Cookie response header it extracts these fields and stores them together as one cookie record:
# Full Set-Cookie header the browser parses:
Set-Cookie: sid=8f3a9c12d7; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=api.acme.com; Max-Age=3600
# What the browser stores internally (conceptual record):
{
name: "sid",
value: "8f3a9c12d7",
domain: "api.acme.com", // no leading dot → only exact host
path: "/",
expiry: now + 3600s,
http_only: true,
secure: true,
same_site: "Lax"
}
Step 2 — the browser's "should I send this cookie?" decision tree
Before each outgoing request the browser runs this algorithm for every cookie in its jar:
Step 3 — server-side session lookup vs. JWT on the wire: a concrete trace
Here is the exact wire exchange for the same authenticated GET, once with session auth and once with JWT, so you can see what differs at each hop:
── Session auth (cookie) ────────────────────────────────────────────
# Browser → Server
GET /v1/account HTTP/1.1
Host: api.acme.com
Cookie: sid=8f3a9c12d7 ← opaque pointer; browser added automatically
# Server does:
1. Extract "8f3a9c12d7" from Cookie header.
2. SELECT * FROM sessions WHERE id = '8f3a9c12d7' AND expiry > NOW();
3. Row found → read user_id = 42, role = 'editor'.
4. Proceed with request. ← requires DB/Redis round-trip on every request
── JWT auth (Authorization header) ──────────────────────────────────
# Browser/client → Server
GET /v1/account HTTP/1.1
Host: api.acme.com
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJlZGl0b3IiLCJleHAiOjE3NTAwMDM2MDB9.<sig>
# Server does:
1. Split token at dots → header / payload / signature.
2. Decode header: { "alg": "RS256", "kid": "key-2025-06" }
3. Fetch public key for "key-2025-06" from in-memory JWKS cache.
4. Verify: RSA_verify(header+"."+payload, signature, public_key) → OK
5. Decode payload: { "sub": "42", "role": "editor", "exp": 1750003600 }
6. Check exp > now() → valid.
7. Proceed. ← no DB round-trip; claims are self-contained in token
Concrete CSRF execution trace — and why SameSite blocks it
Walk through the attack step by step to see exactly where SameSite intervenes:
── Without SameSite (pre-2021 behaviour or SameSite=None) ──────────
1. User logs into bank.example → receives:
Set-Cookie: sid=USER_SESSION; Path=/; Secure ← no SameSite
Browser stores cookie: domain=bank.example
2. User visits evil.example. Page contains hidden form:
<form method="POST" action="https://bank.example/transfer">
<input name="amount" value="5000"><input name="to" value="attacker">
</form>
<script>document.forms[0].submit()</script>
3. Browser fires POST to bank.example/transfer.
Domain match: YES. Path: YES. Secure+HTTPS: YES.
SameSite check: cookie has no SameSite → no restriction → SENT
4. bank.example sees:
POST /transfer Cookie: sid=USER_SESSION ← attacker's transfer executes
── With SameSite=Lax (modern default) ──────────────────────────────
3. Browser fires POST to bank.example/transfer from evil.example.
SameSite check: request is cross-site (evil.example ≠ bank.example)
AND method is POST (not a top-level GET navigation)
→ cookie WITHHELD
4. bank.example sees:
POST /transfer (no Cookie header) ← server returns 401; transfer blocked
── With SameSite=Strict ─────────────────────────────────────────────
3. Even a cross-site GET navigation (clicking a link from evil.example
to bank.example) would withhold the cookie.
Any cross-site origin → cookie withheld.
Note: this breaks "return to site after OAuth redirect" flows.
How to debug & inspect it
The fastest path is DevTools → Application → Cookies. Cookies that exist but are not being sent are the classic symptom — you can see them in the jar but they do not appear in the request headers.
When a cookie exists in the jar but does not appear in a request, work through this symptom table:
| Symptom | Likely cause | Fix |
|---|---|---|
| Cookie visible in DevTools jar but absent from request headers | Domain or Path mismatch — request URL doesn't match the cookie's stored domain/path | Check the Domain and Path columns in DevTools → Application → Cookies; align with the request origin or widen Path to / |
| Cookie missing on HTTPS request despite being set | Secure flag is set but the cookie was originally received over HTTP (localhost http://) | During local dev, set Secure only in prod config, or run localhost over HTTPS (mkcert); in DevTools look for the "will be rejected" warning |
| Cookie present on same-origin requests, absent on cross-origin API call | SameSite=Strict or default Lax blocking a cross-site subresource fetch | If cross-site sending is intentional, switch to SameSite=None; Secure; otherwise reconsider whether the call should be same-origin |
document.cookie is empty but DevTools shows the cookie | HttpOnly is set — correct behavior; JS is blocked from reading it | Not a bug; if you need a value JS can read (e.g. CSRF token), set a second non-HttpOnly cookie for that purpose only |
| Cookie sent on GET navigation but not on fetch/XHR from same page | SameSite=Lax — "top-level navigation" GET is allowed cross-site; subresource fetch is not | Expected per spec; for an API that must receive cookies from cross-origin fetch you need SameSite=None; Secure plus credentials: "include" on the fetch call |
| Server sets cookie but it vanishes immediately in Safari | Safari's ITP (Intelligent Tracking Prevention) blocks third-party cookies and some first-party cookies on cross-site requests | Serve the API from the same eTLD+1 as the frontend, or use a CNAME; avoid relying on cross-site cookies for Safari users |
| Session cookie disappears after browser restart | No Max-Age or Expires set — cookie is a session cookie that browser discards on close | Add Max-Age=<seconds> to persist across browser restarts; set it to your desired session lifetime |
Cookie debug checklist:
- Open DevTools → Application → Cookies → select the origin. Confirm the cookie is stored and note every attribute column.
- Open DevTools → Network → select the failing request → Headers tab. Is the Cookie header present? If not, start at row 1 of the symptom table above.
- Check the Domain column. A cookie set for
api.acme.comwill NOT be sent towww.acme.com— they are different hosts unless a dot-prefixed domain was used. - Check the SameSite column. If blank (no attribute), Chrome/Firefox now treat it as
Lax; Safari may treat it differently. Set it explicitly. - For CSRF debugging: can you reproduce the attack with curl? (
curl -b cookies.txt -X POST https://target/transferwill succeed because curl doesn't enforce SameSite — that's expected and confirms the server needs its own CSRF check.) - Confirm the server validates the CSRF token on every state-changing request — add a breakpoint or log on the server-side CSRF check and watch it fire.
"Same site" and "same origin" are not the same thing. Same origin requires scheme + host + port to match exactly. Same site is looser: it uses the eTLD+1 (registered domain) — so api.acme.com and www.acme.com are the same site (both eTLD+1 = acme.com). That means a cookie set on api.acme.com with SameSite=Lax WILL be sent to www.acme.com (same-site request). This also means that if a subdomain is compromised (XSS on static.acme.com), SameSite does not protect against attacks from that subdomain — the attacker is "same site." Strict per-host domain scoping (Domain=api.acme.com) and subdomain isolation are separate controls needed alongside SameSite.
🧠 Quick check
1. What does the HttpOnly cookie attribute specifically prevent?
HttpOnly hides the cookie from document.cookie and any JavaScript API — its job is limiting XSS damage. Secure handles encryption in transit; SameSite handles cross-site sending.
2. A mobile app calls your REST API using Authorization: Bearer <JWT>. Is it vulnerable to CSRF?
CSRF works by abusing the browser's automatic cookie attachment. Custom headers like Authorization are never sent automatically on cross-site requests, so bearer token auth is not CSRF-vulnerable. That said, if the JWT is stored in a cookie rather than a header, the vulnerability returns.
3. Your API has GET /admin/users/42/disable that deactivates an account. Which problem does this create?
SameSite=Lax (the modern browser default) blocks cross-site POST but allows GET requests triggered by top-level navigation. An attacker can embed <img src="https://yourapi/admin/users/42/disable"> and browsers will fire it with the session cookie. State changes belong on POST/PUT/PATCH/DELETE.
4. Which storage approach makes revocation easiest?
A server-side session can be revoked instantly — delete the row and the next request fails. A JWT is valid until it expires; you can only "revoke" it by maintaining a denylist, which reintroduces server state anyway.
✍️ Exercise: harden a leaky Set-Cookie header
You inherit an authentication service whose login response looks like this:
HTTP/1.1 200 OK
Set-Cookie: auth=USER_42_ADMIN; Domain=.acme.com; Path=/
List every security problem with this header and write the corrected version. Then explain which attack each fix prevents.
Model answer:
# Problems in the original:
# 1. auth=USER_42_ADMIN — embeds user ID and role in plain text in the cookie
# value. Anyone who can read the cookie (or intercept it) sees this data.
# 2. No HttpOnly — JavaScript can steal it via XSS.
# 3. No Secure — cookie travels over HTTP in cleartext.
# 4. No SameSite — cookie is sent on all cross-site requests (CSRF risk).
# 5. Domain=.acme.com is broad — applies to all subdomains; a compromised
# subdomain can read/set this cookie.
# 6. No Max-Age/Expires — cookie lives forever in the browser.
# Corrected header:
HTTP/1.1 200 OK
Set-Cookie: sid=<opaque-random-128-bit-token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
Rubric: ✓ Opaque token (not user data in the value) ✓ HttpOnly named with XSS mitigation justification ✓ Secure named with cleartext-sniff justification ✓ SameSite=Lax (or Strict) named with CSRF justification ✓ Max-Age with session lifetime reasoning ✓ Domain narrowed or removed. All six = full marks; missing two or more = revisit the attribute table above.
Key takeaways
- HTTP is stateless; cookies are the browser's mechanism for carrying state automatically across requests.
HttpOnlyblocks JavaScript access;Secureblocks cleartext transmission;SameSite=Laxblocks most cross-site CSRF — set all three on every auth cookie.- Server-side sessions store state on the server (easy revocation, stateful); token auth (JWT in
Authorizationheader) stores state in the credential (hard revocation, stateless). Choose based on your client type and revocation requirements. - CSRF exploits automatic cookie attachment. Tokens in
Authorizationheaders are immune; cookie-based auth needs SameSite + explicit CSRF tokens for state-changing endpoints. - State-changing GETs are a CSRF trap that
SameSite=Laxdoesn't close — keep mutations on POST/PUT/PATCH/DELETE.
Sources & further reading
- MDN — Using HTTP cookies — complete Set-Cookie attribute reference
- OWASP — CSRF Prevention Cheat Sheet — synchroniser token, double-submit, SameSite in depth
- MDN — Same-origin policy — why cross-site JS can't read your cookie but form POSTs still carry it
- web.dev — SameSite cookies explained — Google's guide to the attribute and the 2021 browser default change
- PortSwigger Web Security Academy — CSRF — interactive labs for hands-on practice