API Design

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.

⏱ 14 min Difficulty: core Prereq: sec-08 (keys & JWT)

By the end you'll be able to

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.

Browser cookie jar session=abc123 Server session store abc123 → user:42 ① POST /login ② Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax ③ GET /dashboard Cookie: session=abc123 browser adds cookie automatically — no code needed
The browser stores the cookie after step ② and re-sends it with every matching request (step ③). The server maps the session ID back to the authenticated user.

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.

Server-side session Cookie session=x9f2 lookup Session store x9f2 → { user:42, role:admin } + Easy revocation (delete the row) + Opaque — nothing leaks to client − Stateful: every node needs store access Token auth (JWT) Header Bearer eyJ… verify sig JWT payload sub:42, role:admin exp:1720000000 + HMAC sig ✓ + Stateless: scales horizontally + Natural for mobile / native clients − Hard revocation without a denylist
Sessions keep state on the server and hand the client a meaningless pointer. Tokens bake the state into the credential itself and rely on short expiry instead of revocation.

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).

🎯 Interview angle

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

  1. SameSite=Lax or Strict. The modern first line of defence. Lax blocks cross-site POST/fetch while allowing top-level navigations. Strict blocks 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.
  2. 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.
  3. 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.
⚠️ Common traps

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 this, not that

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:

Outgoing request for each cookie in jar… Domain matches request host? (exact or dot-prefix subdomain) No Skip Yes Path prefix matches? (cookie path is prefix of request path) No Skip Yes Secure flag set? If yes, is the request HTTPS? Secure+HTTP Skip OK SameSite check Strict: same site only Lax: same site + top-level GET nav None: always Cross-site & Strict/Lax POST Block Pass Cookie: sid=8f3a9c12d7 added to request header SameSite semantics: "same site" = same eTLD+1 (e.g. acme.com). A subdomain (api.acme.com) IS the same site as acme.com. Cross-site = a different eTLD+1 entirely (evil.example → acme.com). Lax allows cross-site GET top-level nav; Strict blocks even that.
The browser runs this check for every cookie on every outgoing request. SameSite is the last gate and the one that blocks CSRF — but only when the request originates cross-site and the method is not a safe top-level navigation.

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.

# Reproduce the exact Set-Cookie + Cookie exchange with curl $ curl -c cookies.txt -D - -X POST https://api.acme.com/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"correct-horse"}' HTTP/2 200 set-cookie: sid=8f3a9c12d7; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600 # -c cookies.txt saves the jar; -D - prints response headers $ curl -b cookies.txt -v https://api.acme.com/v1/account > Cookie: sid=8f3a9c12d7 HTTP/2 200 # -b cookies.txt replays the jar — simulates what a browser does # Inspect a cookie's attributes in DevTools (Application → Cookies): # Column "SameSite" is blank for old cookies with no attribute (treated as None). # Column "HttpOnly" shows a checkmark if set; JS document.cookie will be empty. # Verify HttpOnly is blocking JS access: $ # In browser console: document.cookie // returns "" if all auth cookies are HttpOnly "" # Check that Secure is enforced — look for this warning in DevTools console: Cookie "sid" will be rejected because it has the "Secure" attribute but was not set on a secure connection.

When a cookie exists in the jar but does not appear in a request, work through this symptom table:

SymptomLikely causeFix
Cookie visible in DevTools jar but absent from request headersDomain or Path mismatch — request URL doesn't match the cookie's stored domain/pathCheck 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 setSecure 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 callSameSite=Strict or default Lax blocking a cross-site subresource fetchIf 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 cookieHttpOnly is set — correct behavior; JS is blocked from reading itNot 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 pageSameSite=Lax — "top-level navigation" GET is allowed cross-site; subresource fetch is notExpected 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 SafariSafari's ITP (Intelligent Tracking Prevention) blocks third-party cookies and some first-party cookies on cross-site requestsServe 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 restartNo Max-Age or Expires set — cookie is a session cookie that browser discards on closeAdd Max-Age=<seconds> to persist across browser restarts; set it to your desired session lifetime

Cookie debug checklist:

  1. Open DevTools → Application → Cookies → select the origin. Confirm the cookie is stored and note every attribute column.
  2. 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.
  3. Check the Domain column. A cookie set for api.acme.com will NOT be sent to www.acme.com — they are different hosts unless a dot-prefixed domain was used.
  4. Check the SameSite column. If blank (no attribute), Chrome/Firefox now treat it as Lax; Safari may treat it differently. Set it explicitly.
  5. For CSRF debugging: can you reproduce the attack with curl? (curl -b cookies.txt -X POST https://target/transfer will succeed because curl doesn't enforce SameSite — that's expected and confirms the server needs its own CSRF check.)
  6. 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.
⚠️ Deep gotcha: SameSite and "same site" vs "same origin"

"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

Sources & further reading