Cookies and Sessions for APIs
Stateful sessions over stateless HTTP, the SameSite / Secure / HttpOnly trio, when JWTs replace cookies and when they shouldn't.
What it is#
HTTP is stateless — each request stands alone, the server has no built-in memory of the last one. Cookies are HTTP’s mechanism for adding state: the server sets a Set-Cookie header in a response, the browser stores it, the browser attaches it as a Cookie header on every subsequent request to that origin. Sessions are what the server does with the cookie value — typically, the cookie holds a session ID, the server holds the session state, and the ID is the key.
Cookies are a wire-format primitive defined by RFC 6265. The interesting design surface is the attributes a cookie carries:
Secure— only send over HTTPS. Without this, an HTTP downgrade leaks the cookie to a network attacker.HttpOnly—document.cookieJavaScript can’t read it. Defeats most XSS-based session theft.SameSite—Strict,Lax, orNone. Controls whether the cookie is sent on cross-site requests; the CSRF mitigation layer.DomainandPath— scope of the cookie. Defaults to the issuing origin, narrowed if set.Expires/Max-Age— lifetime. Without either, the cookie is a session cookie (browser-process lifetime).
The full triad — Secure, HttpOnly, SameSite=Lax|Strict — is the senior baseline for any session cookie on a modern API. Anything less is the legacy default that gets your auth flow on someone’s CVE list.
The alternative — bearer tokens (JWTs) in Authorization: Bearer headers — replaces cookies for some API styles. Each has security trade-offs neither fully wins; the senior signal is knowing when each fits.
When to use it#
Reach for cookies and session storage when:
- The client is a browser, same-origin or first-party (your SPA on
app.example.comtalking toapi.example.comof the same etld+1). The browser handles cookie attachment automatically; the server enforces freshness; revocation is a database write. - You need revocation. A logout, an admin-forced sign-out, a “log out all devices” — flip the row in the session table and the next request fails. JWTs without a revocation list can’t do this.
- You need a tight CSRF story.
SameSite=Lax(orStrict) on the cookie blocks cross-site form submissions andfetch()calls. Pair with a CSRF token for state-changing requests underLaxif you want belt-and-braces. - Server-side rendering with first-paint personalisation. The server reads the session cookie on the inbound request, looks up the user, and renders the page personalised. JWT-in-header doesn’t work for the initial HTML request because there’s no JavaScript to attach the header.
Reach for bearer tokens (typically JWT) instead when:
- The client is not a browser — mobile apps, CLI tools, server-to-server. Cookies are a browser idiom; bearer tokens compose with everything.
- Cross-domain access is intrinsic —
Authorization: Bearerworks across origins where third-party cookies are now mostly blocked. - You want stateless validation — a microservice that doesn’t want to call a session store on every request. JWTs are self-validating with the public key.
- Tokens are short-lived and you’re OK without revocation — a 5-minute access token’s revocation window is the expiry. For longer-lived tokens, you need either a revocation list (which negates statelessness) or you accept the window.
How it works#
Setting and reading a session cookie#
A login endpoint validates credentials, creates a session, and sets the cookie:
HTTP/1.1 200 OKContent-Type: application/jsonSet-Cookie: sid=eyJzZXNzaW9uX2lkIjoiYWJjMTIzIn0; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400
{"user": {"id": 42, "name": "Alice"}}Subsequent requests from the browser:
GET /api/me HTTP/1.1Host: api.example.comCookie: sid=eyJzZXNzaW9uX2lkIjoiYWJjMTIzIn0The server reads the sid cookie, looks it up in the session store (Redis, a SQL table, a signed-cookie scheme), and resolves it to user 42.
The session-store options#
Three storage shapes for the session state:
- Opaque ID + server-side store. Cookie holds a random 256-bit ID; server stores
{sid → {user_id, expires_at, ...}}in Redis or a database. Revocation is a delete. The default for any serious app. - Signed cookie (no server-side store). Cookie holds
{user_id, expires_at, ...}+ an HMAC over it. Server validates the HMAC and trusts the payload. No revocation without a blocklist. Cookie size grows with payload. Used by Django sessions, Flask sessions (default), Rails encrypted cookies. - JWT in a cookie. Same as signed cookie, but the format is JWT. Trades familiarity for verbosity (header + payload + signature, base64-encoded).
For anything authentication-related, the opaque-ID + Redis pattern is the senior choice — fast, revocable, small wire size.
The Secure / HttpOnly / SameSite triad#
Three attributes that turn a cookie from “a session-theft vector” into “a defensible auth primitive”:
| Attribute | What it does | What it defends |
|---|---|---|
Secure | Cookie only sent over HTTPS. | Network-eavesdropping attacks, downgrade attacks. |
HttpOnly | document.cookie can’t read it. | XSS exfiltration of the session cookie. |
SameSite=Strict | Never sent on cross-site requests. | CSRF, third-party tracking. Breaks “log in via email link” flows. |
SameSite=Lax (default) | Sent on top-level cross-site GET, not on cross-site POST / fetch / iframe. | CSRF on state-changing requests; preserves OAuth callbacks and email-link logins. |
SameSite=None | Sent on all cross-site requests. Requires Secure. | Necessary for cross-origin embedding (third-party widgets); higher CSRF surface. |
CSRF — what cookies expose#
The Cross-Site Request Forgery threat: an attacker’s page (evil.com) makes a request to bank.com while the user is logged in to the bank. The browser dutifully attaches the bank’s session cookie. Without defence, the attacker’s request runs with the user’s session.
Two defences that compose:
SameSite=LaxorStrict. The browser refuses to attach the cookie on cross-site state-changing requests. Modern, simple, effective. Lax is the right default; Strict breaks too many legitimate flows (incoming email links).- CSRF tokens. A per-session random token, returned by the server, attached by the client to state-changing requests (header or hidden form field). The attacker’s page can’t read the token (same-origin policy on the response that contained it), so the forged request fails the token check.
The senior posture: SameSite=Lax + CSRF tokens on state-changing requests. The combination handles browsers that don’t fully implement SameSite and protects against subdomain-attacker variants of CSRF.
Setting + reading a session cookie — three languages#
The login endpoint sets the cookie; a downstream endpoint reads it:
from fastapi import FastAPI, Cookie, Response, HTTPExceptionimport secrets, redis
app = FastAPI()r = redis.Redis()
@app.post("/login")def login(resp: Response, email: str, password: str): user = check_credentials(email, password) if not user: raise HTTPException(401) sid = secrets.token_urlsafe(32) r.setex(f"sid:{sid}", 86400, str(user.id)) resp.set_cookie( key="sid", value=sid, max_age=86400, httponly=True, secure=True, samesite="lax", path="/", ) return {"user": user.dict()}
@app.get("/me")def me(sid: str | None = Cookie(default=None)): if not sid: raise HTTPException(401) user_id = r.get(f"sid:{sid}") if not user_id: raise HTTPException(401) return {"user_id": int(user_id)}package main
import ( "crypto/rand" "encoding/base64" "net/http" "time")
func login(w http.ResponseWriter, req *http.Request) { user, ok := checkCredentials(req) if !ok { http.Error(w, "unauthorized", 401); return } buf := make([]byte, 32) rand.Read(buf) sid := base64.RawURLEncoding.EncodeToString(buf) redis.SetEx("sid:"+sid, 86400, user.ID) http.SetCookie(w, &http.Cookie{ Name: "sid", Value: sid, MaxAge: 86400, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Path: "/", }) w.WriteHeader(200)}
func me(w http.ResponseWriter, req *http.Request) { c, err := req.Cookie("sid") if err != nil { http.Error(w, "unauthorized", 401); return } userID, ok := redis.Get("sid:" + c.Value) if !ok { http.Error(w, "unauthorized", 401); return } _ = userID; _ = time.Now() w.WriteHeader(200)}import express from "express";import cookieParser from "cookie-parser";import crypto from "crypto";import { createClient } from "redis";
const app = express();app.use(express.json());app.use(cookieParser());const r = createClient();await r.connect();
app.post("/login", async (req, res) => { const user = await checkCredentials(req.body); if (!user) return res.status(401).end(); const sid = crypto.randomBytes(32).toString("base64url"); await r.setEx(`sid:${sid}`, 86400, String(user.id)); res.cookie("sid", sid, { maxAge: 86_400_000, httpOnly: true, secure: true, sameSite: "lax", path: "/", }); res.json({ user });});
app.get("/me", async (req, res) => { const sid = req.cookies.sid; if (!sid) return res.status(401).end(); const userId = await r.get(`sid:${sid}`); if (!userId) return res.status(401).end(); res.json({ user_id: Number(userId) });});Session rotation and absolute timeouts#
Two policies that come up in every audit:
- Rotate on privilege change. After login, after MFA challenge passes, after a role grant — issue a new session ID and invalidate the old one. Defeats session-fixation attacks where the attacker plants a known sid before login.
- Absolute timeout. Sessions expire after N hours regardless of activity. Pair with a sliding timeout (
Max-Agerefreshed on each request) up to an absolute cap. The default at Google, Facebook, and most banks.
Cookies in cross-domain CORS calls#
CORS and cookies are particular about each other. Three rules:
- The browser only sends cookies on cross-origin requests if the client sets
fetch(..., { credentials: "include" }). - The server must respond with
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originmust be a specific origin —*does not work with credentials.- The cookie must be
SameSite=None; Securefor cross-site sending in modern browsers.
If any of those four fails, the cookie is dropped silently. Half of “my cookie isn’t being sent” debugging is this.
Variants#
| Approach | Storage | Revocation | Cross-domain | Best for |
|---|---|---|---|---|
| Opaque session ID + Redis | Server-side (Redis/SQL) | Instant (delete row) | Awkward (CORS + SameSite) | First-party web apps |
| Signed cookie (no server store) | Client-side (cookie) | Blocklist required | Same as opaque | Small payloads, no revocation need |
| JWT in a cookie | Client-side (cookie) | Blocklist required | Same as opaque | When JWT shape is wanted but cookie attachment is desired |
JWT in Authorization: Bearer | Client-side (memory / localStorage) | Blocklist required | Trivial | APIs serving non-browser clients, microservices |
Opaque token in Authorization: Bearer | Server-side | Instant | Trivial | OAuth 2 access tokens |
Trade-offs#
What cookies and sessions give you:
- Automatic attachment. Browser does the work; no client code to thread the credential through every fetch.
HttpOnlyprotection. XSS can’t steal the credential the way it can steal a token fromlocalStorage.- Instant revocation (with a server-side store). Logout writes to Redis; the next request fails.
- CSRF mitigation built into modern browsers (
SameSite).
What cookies and sessions cost you:
- CSRF risk (mitigated but not zero; tokens still recommended for state-changing requests).
- Cross-domain awkwardness. Cookies are an origin-scoped idiom; cross-origin work needs
SameSite=None; Secure+ CORS choreography. - Session-store dependency. Every authenticated request hits Redis or the database. JWTs don’t.
- Server-side state to operate. Backups, replication, expiry sweeps.
Common pitfalls#
- Missing
Secureon a session cookie. A network attacker on the same coffee-shop Wi-Fi captures the cookie. Modern browsers refuse to setSecurecookies on plain HTTP, which catches some cases; not all. - Missing
HttpOnly. XSS readsdocument.cookieand exfiltrates the session. AlwaysHttpOnlyon auth cookies. SameSite=NonewithoutSecure. Modern browsers refuse the combination. Set both or neither.- Storing JWTs in
localStoragefor the “browser app” use case. Any XSS exfiltrates the token. The cookie pattern (HttpOnly+ refresh-token rotation) is safer. - No session rotation on login. Session fixation: attacker sets a known sid via a crafted link, victim logs in, attacker uses the same sid. Rotate on auth-state change.
- No absolute expiry. A sliding-window session that never expires is a session that survives until the user clears cookies — which they don’t.
- Forgetting
Path=/. A cookie set on/loginwon’t be sent on/api/me. AlwaysPath=/for global session cookies. - CORS
Access-Control-Allow-Origin: *with credentials. Browsers reject this; the cookie silently doesn’t attach.
Related building blocks#
- OAuth 2 — The Authorization Framework — bearer-token alternative to cookies; OAuth + cookie storage is a valid hybrid for SPAs.
- Authentication vs Authorization — the two-word vocabulary; cookies typically carry the authentication state.
- CORS — Cross-Origin Resource Sharing — cookies plus CORS plus
SameSiteis the trifecta to get right for cross-origin auth. - Transport Layer Security (TLS) —
Securecookies require TLS; the whole story falls apart without it. - API Security — An Overview — where session management sits in the API threat model.