Skip to content

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.

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.

Terminal window
# 1. Server sets a cookie
curl -i https://httpbin.org/cookies/set/session/abc123
HTTP/1.1 302 Found
Set-Cookie: session=abc123; Path=/
Location: /cookies
Terminal window
# 2. Browser (or curl with a jar) sends it back
curl -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.

  • 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=Lax
Set-Cookie: theme=dark; Path=/; Max-Age=31536000
Cookie: session=abc123; theme=dark

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

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

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.

Two magic name prefixes browsers enforce:

  • __Secure- — the cookie is only accepted if it has Secure and came over HTTPS.
  • __Host- — requires Secure, requires Path=/, and forbids Domain. 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=Lax

Use __Host- for session cookies whenever you can. It is free protection against subdomain cookie-injection.

Set-Cookie: __Host-sid=<random-256-bit-value>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600
Terminal window
# Save cookies to a jar, then reuse them
curl -c jar.txt -d 'user=ada&pass=secret' https://example.com/login
curl -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 jar
curl -b 'session=abc123; theme=dark' https://example.com/

Two ways to turn “I have a cookie” into “I am user 42”.

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.

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.

Terminal window
# Decode a payload — no key needed, this is not encryption
echo '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.

  • alg: none. The spec includes an unsigned mode. A library that honours the token’s own alg field 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 RS256 token as HS256 using 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/iss checks. A token minted for service A is happily accepted by service B unless B validates aud.
  • Storing JWTs in localStorage. Readable by any XSS on the page. A HttpOnly cookie is not.

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.

Terminal window
curl -v -u ada:secret https://httpbin.org/basic-auth/ada/secret
# > authorization: Basic YWRhOnNlY3JldA==

A server requesting it responds:

HTTP/1.1 401 Unauthorized
WWW-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).

“Whoever bears this token gets access.” The token is opaque to HTTP — it can be a JWT, a random string, an OAuth access token.

Terminal window
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...' https://api.example.com/me
HTTP/1.1 401 Unauthorized
WWW-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.

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.

Terminal window
curl -H 'X-API-Key: sk_live_9f8e7d6c5b4a' https://api.example.com/v1/charges

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

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 /userinfo endpoint. “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.

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 OIDC

Why 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.
  • state is 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.
  • scope limits what the access token can do. Ask for the minimum.
Terminal window
# Access token → the resource server's API
curl -H 'Authorization: Bearer <access_token>' https://api.example.com/v1/photos
# Refresh when it expires
curl -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.

For machine-to-machine calls with no user involved:

Terminal window
curl -X POST https://auth.example.com/token \
-u 'client_id:client_secret' \
-d 'grant_type=client_credentials' \
-d 'scope=reports:read'
  • Cookies exist solely because HTTP is stateless; attributes control when the browser sends them.
  • Default to __Host- + Secure + HttpOnly + SameSite=Lax for session cookies.
  • SameSite=Lax is the browser default and blocks most CSRF; None requires Secure.
  • 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.