Cookies and Sessions for APIs

Stateful sessions over stateless HTTP, the SameSite / Secure / HttpOnly trio, when JWTs replace cookies and when they shouldn't.

Building Block Foundational
10 min read
cookies sessions jwt csrf authentication

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.
  • HttpOnlydocument.cookie JavaScript can’t read it. Defeats most XSS-based session theft.
  • SameSiteStrict, Lax, or None. Controls whether the cookie is sent on cross-site requests; the CSRF mitigation layer.
  • Domain and Path — 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.com talking to api.example.com of 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 (or Strict) on the cookie blocks cross-site form submissions and fetch() calls. Pair with a CSRF token for state-changing requests under Lax if 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 intrinsicAuthorization: Bearer works 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#

A login endpoint validates credentials, creates a session, and sets the cookie:

HTTP/1.1 200 OK
Content-Type: application/json
Set-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.1
Host: api.example.com
Cookie: sid=eyJzZXNzaW9uX2lkIjoiYWJjMTIzIn0

The 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”:

AttributeWhat it doesWhat it defends
SecureCookie only sent over HTTPS.Network-eavesdropping attacks, downgrade attacks.
HttpOnlydocument.cookie can’t read it.XSS exfiltration of the session cookie.
SameSite=StrictNever 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=NoneSent 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=Lax or Strict. 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.

The login endpoint sets the cookie; a downstream endpoint reads it:

Session cookie — Python (FastAPI)
from fastapi import FastAPI, Cookie, Response, HTTPException
import 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)}

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-Age refreshed 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-Origin must be a specific origin — * does not work with credentials.
  • The cookie must be SameSite=None; Secure for 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#

ApproachStorageRevocationCross-domainBest for
Opaque session ID + RedisServer-side (Redis/SQL)Instant (delete row)Awkward (CORS + SameSite)First-party web apps
Signed cookie (no server store)Client-side (cookie)Blocklist requiredSame as opaqueSmall payloads, no revocation need
JWT in a cookieClient-side (cookie)Blocklist requiredSame as opaqueWhen JWT shape is wanted but cookie attachment is desired
JWT in Authorization: BearerClient-side (memory / localStorage)Blocklist requiredTrivialAPIs serving non-browser clients, microservices
Opaque token in Authorization: BearerServer-sideInstantTrivialOAuth 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.
  • HttpOnly protection. XSS can’t steal the credential the way it can steal a token from localStorage.
  • 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 Secure on a session cookie. A network attacker on the same coffee-shop Wi-Fi captures the cookie. Modern browsers refuse to set Secure cookies on plain HTTP, which catches some cases; not all.
  • Missing HttpOnly. XSS reads document.cookie and exfiltrates the session. Always HttpOnly on auth cookies.
  • SameSite=None without Secure. Modern browsers refuse the combination. Set both or neither.
  • Storing JWTs in localStorage for 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 /login won’t be sent on /api/me. Always Path=/ for global session cookies.
  • CORS Access-Control-Allow-Origin: * with credentials. Browsers reject this; the cookie silently doesn’t attach.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.