Cookies, Sessions and Auth
HTTP is stateless: the server treats every request as if it has never seen you. Every login system on the web is a way of re-sending proof of identity with each request. Cookies were the first answer and remain the most common.
Why cookies exist
Section titled “Why cookies exist”A server cannot associate two requests with each other. Cookies fix that with the smallest possible mechanism: the server hands the client an opaque string, and the client sends it back on every subsequent request to that host.
# 1. Server sets a cookiecurl -i https://httpbin.org/cookies/set/session/abc123HTTP/1.1 302 FoundSet-Cookie: session=abc123; Path=/Location: /cookies# 2. Browser (or curl with a jar) sends it backcurl -b 'session=abc123' https://httpbin.org/cookies# => {"cookies": {"session": "abc123"}}That is the entire protocol. Everything else is attributes controlling when the client sends it.
The two headers
Section titled “The two headers”Set-Cookie— a response header. One cookie per header line; a response may contain many.Cookie— a request header. All applicable cookies in one line, separated by;, names and values only — the attributes are never sent back.
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSet-Cookie: theme=dark; Path=/; Max-Age=31536000Cookie: session=abc123; theme=darkThe server cannot tell which cookie had which attributes, or even which host set it. That asymmetry is the root of several classes of bug — a cookie set on .example.com and one set on app.example.com arrive as indistinguishable name/value pairs.
Cookie attributes
Section titled “Cookie attributes”| Attribute | Effect |
|---|---|
Domain |
Which hosts receive the cookie. Omitted → only the exact host that set it. Set to example.com → that host and all subdomains. You cannot set a cookie for a parent you do not control, or for a public suffix like .com. |
Path |
Only sent for URLs whose path is at or below this prefix. Path=/admin is not sent to /. Weak isolation — same-origin JS can read any path’s cookies. |
Expires |
An absolute HTTP date after which the cookie is discarded. |
Max-Age |
Lifetime in seconds. Takes precedence over Expires. Max-Age=0 deletes the cookie. |
Secure |
Only sent over HTTPS. |
HttpOnly |
Invisible to document.cookie. Blocks XSS from exfiltrating it. |
SameSite |
Controls whether the cookie is sent on cross-site requests. See below. |
Partitioned |
Opts into partitioned storage (CHIPS): a separate cookie jar per top-level site. Used for embedded third-party contexts as third-party cookies are phased out. |
A cookie with neither Expires nor Max-Age is a session cookie — it lives in memory and dies when the browser closes (though “restore previous tabs” often resurrects it).
SameSite
Section titled “SameSite”The most security-relevant attribute. It decides whether the cookie rides along on requests initiated by another site.
| Value | Sent on same-site requests | Sent on cross-site top-level navigation (a link, GET) | Sent on other cross-site requests (form POST, iframe, fetch, image) |
|---|---|---|---|
Strict |
Yes | No | No |
Lax |
Yes | Yes | No |
None |
Yes | Yes | Yes — requires Secure |
Lax is the modern browser default when the attribute is absent. It is the pragmatic setting: clicking a link from another site into your app keeps you logged in, but a cross-site form POST or fetch does not carry the cookie — which kills classic CSRF.
Strict is stronger but means a user arriving from an email link lands logged out. A common pattern is two cookies: a Strict one for state-changing operations and a Lax one for read-only session recognition.
Cookie prefixes
Section titled “Cookie prefixes”Two magic name prefixes browsers enforce:
__Secure-— the cookie is only accepted if it hasSecureand came over HTTPS.__Host-— requiresSecure, requiresPath=/, and forbidsDomain. The cookie is therefore locked to one exact host and cannot be planted by a subdomain.
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=LaxUse __Host- for session cookies whenever you can. It is free protection against subdomain cookie-injection.
A sane default
Section titled “A sane default”Set-Cookie: __Host-sid=<random-256-bit-value>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600Working with cookies in the shell
Section titled “Working with cookies in the shell”# Save cookies to a jar, then reuse themcurl -c jar.txt -d 'user=ada&pass=secret' https://example.com/logincurl -b jar.txt https://example.com/dashboard
# Inspect the jar (Netscape format: domain, tailmatch, path, secure, expiry, name, value)cat jar.txt
# Send a one-off cookie without a jarcurl -b 'session=abc123; theme=dark' https://example.com/Sessions vs tokens
Section titled “Sessions vs tokens”Two ways to turn “I have a cookie” into “I am user 42”.
Server-side sessions
Section titled “Server-side sessions”The cookie holds a random, meaningless ID. All real data lives server-side, keyed by that ID.
Cookie: __Host-sid=8f14e45fceea167a5a36dedd4bea2543 │ └─► Redis: { userId: 42, roles: ['admin'], csrf: '...' }- Revocation is instant — delete the server record and the session is dead everywhere.
- The cookie leaks nothing — it is a random string.
- You can change what a session means without reissuing anything.
- Costs a lookup per request and requires shared storage across your servers.
Client-side tokens
Section titled “Client-side tokens”The token itself carries the claims, signed so the server can verify it without a lookup.
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.<signature> │ └─► { sub: '42', role: 'admin', exp: ... }- Stateless verification — any server with the key can validate it. Nice for many services.
- You cannot revoke it before it expires without adding server state, which gives up the main benefit.
- Anything in it is readable by the holder (signed ≠ encrypted).
A JSON Web Token is three base64url-encoded parts joined by dots.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc5NTAwMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk└──────── header ────────┘ └──────────────── payload ────────────────┘ └─────────── signature ───────────┘Header — the algorithm and type:
{ "alg": "HS256", "typ": "JWT" }Payload — the claims. Registered claim names are three letters:
{ "iss": "https://auth.example.com", "sub": "42", "aud": "https://api.example.com", "exp": 1795000000, "iat": 1794996400, "jti": "b1a7c9e2", "role": "admin"}iss issuer, sub subject (the user), aud audience (who it is for), exp expiry, nbf not-before, iat issued-at, jti a unique token ID.
Signature — computed over base64url(header) + "." + base64url(payload), using either a shared secret (HMAC, HS256) or a private key (RS256, ES256). With asymmetric signing, anyone can verify with the public key but only the issuer can mint.
base64url is base64 with + → -, / → _, and padding = stripped, so the token is URL- and header-safe.
# Decode a payload — no key needed, this is not encryptionecho 'eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9' | base64 -d 2>/dev/null; echo# => {"sub":"42","role":"admin"}Base64url may need padding restored before base64 -d accepts it; add = until the length is a multiple of 4.
Pitfalls
Section titled “Pitfalls”alg: none. The spec includes an unsigned mode. A library that honours the token’s ownalgfield lets an attacker strip the signature. Always pin the accepted algorithm server-side; never trust the header.- Algorithm confusion. If a server verifies with a key-agnostic call, an attacker can re-sign an
RS256token asHS256using the public key as the HMAC secret. Pin the algorithm. - Unverified decoding.
jwt.decode()in most libraries does not verify. The verifying call is a different function. Confusing them is a complete auth bypass. - No revocation. A stolen token is valid until
exp. Mitigate with short expiries (5–15 minutes) plus a long-lived refresh token that is stored server-side and can be revoked. - Missing
aud/isschecks. A token minted for service A is happily accepted by service B unless B validatesaud. - Storing JWTs in
localStorage. Readable by any XSS on the page. AHttpOnlycookie is not.
Authorization schemes
Section titled “Authorization schemes”The Authorization request header is <scheme> <credentials>.
base64(username:password), sent on every request. Base64 is encoding, not encryption — this is plaintext credentials, usable only over HTTPS.
curl -v -u ada:secret https://httpbin.org/basic-auth/ada/secret# > authorization: Basic YWRhOnNlY3JldA==A server requesting it responds:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Basic realm="Restricted Area"Browsers show a native login dialog and then cache the credentials for the realm. Fine for internal tools and machine-to-machine calls; unsuitable for user-facing apps (no logout, no password reset flow, credentials replayed constantly).
Bearer
Section titled “Bearer”“Whoever bears this token gets access.” The token is opaque to HTTP — it can be a JWT, a random string, an OAuth access token.
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...' https://api.example.com/meHTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="expired"Bearer tokens carry no proof of possession — interception is total compromise. Always HTTPS, always short-lived.
API keys
Section titled “API keys”A long random string identifying an application rather than a user. There is no standard; conventions vary: Authorization: Bearer <key>, a custom header like X-API-Key, or a query parameter.
Avoid the query parameter form — URLs land in server logs, proxy logs, browser history and Referer headers. A header does not.
curl -H 'X-API-Key: sk_live_9f8e7d6c5b4a' https://api.example.com/v1/chargesTreat keys as passwords: store hashed, scope them, prefix them so they are recognisable in leaks (sk_live_), and make rotation possible without downtime by supporting two active keys.
OAuth 2.0 and OIDC
Section titled “OAuth 2.0 and OIDC”Two different jobs that are constantly conflated:
- OAuth 2.0 is authorization (RFC 6749). It lets an app get limited access to an API on a user’s behalf without the user handing over their password.
- OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. It adds an ID token (a JWT describing who the user is) and a standard
/userinfoendpoint. “Sign in with Google” is OIDC.
The roles:
| Role | Who |
|---|---|
| Resource owner | The user. |
| Client | Your application. |
| Authorization server | Issues tokens (Google, Auth0, your identity provider). |
| Resource server | The API holding the data. |
Authorization code flow with PKCE
Section titled “Authorization code flow with PKCE”The only flow you should use for web and mobile apps. The implicit flow and the password grant are both discouraged in current guidance.
1. Client generates code_verifier (random) and code_challenge = SHA256(verifier). Redirects the browser to the authorization server:
GET https://auth.example.com/authorize ?response_type=code &client_id=abc123 &redirect_uri=https://app.example.com/callback &scope=openid%20profile%20email &state=<random-anti-CSRF-value> &code_challenge=<challenge> &code_challenge_method=S256
2. User authenticates and consents ON the authorization server. Your app never sees the password.
3. Redirect back with a short-lived, single-use code:
https://app.example.com/callback?code=xyz789&state=<same-value>
4. Client verifies `state` matches, then exchanges the code server-to-server (back channel, never the browser):
POST /token grant_type=authorization_code&code=xyz789 &redirect_uri=...&client_id=abc123&code_verifier=<verifier>
5. Response: { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "id_token": "<JWT>" } ← id_token only with OIDCWhy each piece exists:
- The code, not the token, comes back through the browser. Codes are single-use and useless without the second leg, so a leaked redirect URL is far less damaging.
stateis an anti-CSRF nonce. Without it, an attacker can force your callback with their code and link their account to your session.- PKCE (
code_verifier/code_challenge) binds the code to the client that started the flow. Originally for mobile apps that cannot keep a secret; now recommended for all clients, since it neutralises a stolen code. scopelimits what the access token can do. Ask for the minimum.
Using the tokens
Section titled “Using the tokens”# Access token → the resource server's APIcurl -H 'Authorization: Bearer <access_token>' https://api.example.com/v1/photos
# Refresh when it expirescurl -X POST https://auth.example.com/token \ -d 'grant_type=refresh_token' \ -d 'refresh_token=<refresh_token>' \ -d 'client_id=abc123'The access token goes to APIs. The ID token is for your client only — verify its signature, iss, aud and exp, read the user’s identity from it, and never send it to a resource server as credentials.
Client credentials grant
Section titled “Client credentials grant”For machine-to-machine calls with no user involved:
curl -X POST https://auth.example.com/token \ -u 'client_id:client_secret' \ -d 'grant_type=client_credentials' \ -d 'scope=reports:read'Key points
Section titled “Key points”- Cookies exist solely because HTTP is stateless; attributes control when the browser sends them.
- Default to
__Host-+Secure+HttpOnly+SameSite=Laxfor session cookies. SameSite=Laxis the browser default and blocks most CSRF;NonerequiresSecure.- Server sessions give instant revocation; tokens give stateless verification. Pick per architecture, not per fashion.
- A JWT is signed, not encrypted — pin the algorithm, validate
aud/iss/exp, keep expiries short. - Use the authorization code flow with PKCE and
state; OAuth authorizes, OIDC authenticates.