Authentication: Sessions, JWT and OAuth 2.0
Sessions keep login state on the server, JWTs carry signed claims, OAuth 2.0 delegates API access. Tested: a pinned-algorithm verifier rejected an unsigned alg:none admin token.
A server session stores login state on the server and gives the browser only a random ID in a cookie; a JWT carries signed claims any service can verify locally; OAuth 2.0 delegates limited API access without sharing a password. Interviewers expect the trade-off: a session ends instantly when its row is deleted, while a JWT stays valid until exp.
A server session keeps login state in a server-side record and gives the browser only a random ID in a locked-down cookie. A JWT carries signed claims that any service can verify locally but nobody can easily revoke, and OAuth 2.0 lets a user grant an app limited API access without sharing a password. In the tests below, a verifier that pinned its algorithm rejected an unsigned admin token that a naive one accepted, and PKCE turned an intercepted code into invalid_grant.
- OWASP asks for session IDs with at least 64 bits of entropy from a secure generator; Python's secrets.token_hex(16) gives 128 bits as 32 hex characters.
- A JWT is three base64url parts, header, payload and signature, joined by dots; base64url is an encoding, so anyone can read the claims without a key.
- A JWT verifier that let the token header choose the algorithm accepted an unsigned alg:none admin token, while a verifier pinned to HS256 rejected it.
- RFC 8725 requires JWT callers to choose the allowed algorithms and to use each key with exactly one algorithm, and says to validate the aud claim.
- PKCE (RFC 7636) binds an OAuth authorization code to a one-time code_verifier; an intercepted code redeemed without the verifier returned invalid_grant.
- RFC 9700 (January 2025) requires PKCE for public OAuth clients, exact-string redirect URI matching, and forbids the password grant.
01Why a login needs memory
Every HTTP request arrives on its own. The server that checked your password a second ago has no built-in way to link that check to your next request. So after login the server hands the browser some proof, and the browser sends it back every time. Authentication establishes who you are; authorization decides what that identity may do. The design question is what form the proof takes.
This page follows one imaginary notes app through three stages: a single web server using a session cookie, then a mobile app and several APIs using signed tokens (JWTs), then a third-party calendar reading notes through OAuth 2.0. Each stage fixes a limit of the one before and brings its own way to fail.
The code uses only the Python standard library so every check is visible; in production, use a maintained library. Every cookie and token here is a bearer credential (whoever holds it is treated as the user), so it must only travel over HTTPS.
02Stage 1: a session ID in a cookie
import secrets
from http.cookies import SimpleCookie
IDLE, ABSOLUTE = 15 * 60, 8 * 3600 # seconds
class SessionStore:
def __init__(self):
self.rows = {} # session id -> record
def create(self, user, now):
sid = secrets.token_hex(16) # 128 bits from the OS CSPRNG
self.rows[sid] = {"user": user, "created": now, "seen": now}
return sid
def get(self, sid, now):
r = self.rows.get(sid)
if r is None:
return None
if now - r["seen"] > IDLE or now - r["created"] > ABSOLUTE:
del self.rows[sid] # expired: delete on the server
return None
r["seen"] = now
return r["user"]
def rotate(self, old, now, user): # call on login and privilege change
r = self.rows.pop(old)
new = secrets.token_hex(16)
self.rows[new] = dict(r, user=user, seen=now)
return new
def destroy(self, sid):
self.rows.pop(sid, None)
def set_cookie(sid):
c = SimpleCookie()
c["__Host-sid"] = sid
m = c["__Host-sid"]
m["path"], m["secure"], m["httponly"], m["samesite"] = "/", True, True, "Lax"
return c.output()
s = SessionStore()
sid = s.create("asha", now=0)
print("id length:", len(sid), "hex chars =", len(sid) * 4, "bits")
print(set_cookie(sid))
print("t=600 ", s.get(sid, 600))
print("t=1500", s.get(sid, 1500)) # 900 s after last use: at the idle limit
print("t=2401", s.get(sid, 2401)) # 901 s idle -> expired
sid = s.create("asha", now=0)
for t in range(600, 28801, 600): # active every 10 minutes for 8 hours
u = s.get(sid, t)
print("active every 10 min, at t=28800:", u)
print("same session at t=28801:", s.get(sid, 28801))
pre = s.create("anon", now=0)
post = s.rotate(pre, now=5, user="asha")
print("old id after login:", s.get(pre, 6), "| new id:", s.get(post, 6))
s.destroy(post)
print("after logout:", s.get(post, 7))After a correct password, the server stores a record (user, created, last seen) and gives the browser only a random key to it. The key means nothing by itself, so guessing is the only way to forge one. OWASP's session cheat sheet asks for at least 64 bits of entropy from a cryptographically secure generator; secrets.token_hex(16) gives 128. The script printed:
id length: 32 hex chars = 128 bits
Set-Cookie: __Host-sid=7c84c4e3f2cab7f90ae5d6a840ddcca3; HttpOnly; Path=/; SameSite=Lax; Secure
t=600 asha
t=1500 asha
t=2401 None
active every 10 min, at t=28800: asha
same session at t=28801: None
old id after login: None | new id: asha
after logout: NoneThe idle timeout is 15 minutes and the absolute cap 8 hours, within the ranges OWASP gives as examples for lower-risk apps. The request at t=1500 reset the idle clock, so the session died at t=2401, after 901 silent seconds. The second user, active every ten minutes, was still logged out once the 8-hour cap passed. Both limits are enforced on the server.
Each cookie flag stops a different problem. Secure keeps it off plain HTTP. HttpOnly hides it from page JavaScript, so an XSS bug cannot copy it out. SameSite=Lax keeps it off cross-site fetch calls and form POSTs, an extra layer on top of CSRF tokens rather than a replacement. The __Host- prefix makes the browser refuse the cookie unless it is Secure, has Path=/ and no Domain, so a sibling subdomain cannot overwrite it.
rotate issues a fresh ID at login, so an ID an attacker planted beforehand (session fixation) now returns None. Logout calls destroy and deletes the server record; clearing only the browser cookie would leave a stolen copy working.
03Stage 2: what a JWT contains
With a mobile client and three APIs, a central session store sits on the hot path of every call. A self-contained token avoids that: the claims travel inside it, and any service with the right key checks them locally. The usual format is the JSON Web Token (JWT).
A JWT is three base64url strings joined by dots. The header names the algorithm, for example {"alg":"HS256","typ":"JWT"}. The payload holds claims; RFC 7519 defines iss (issuer), sub (subject, usually a user ID), aud (intended recipients), iat (issue time) and exp (expiry, on or after which the token must be refused). The third part is a signature over the first two.
Base64url is an encoding, not encryption. In the next section's test, decoding the middle part with no key printed the claims in plain text, so never put passwords or other secrets in a JWT.
With HS256 the signature is an HMAC-SHA256 under a shared secret, so anyone who can verify can also mint tokens. With an asymmetric algorithm such as RS256, only the issuer holds the private key and services verify with the public one. Either way, a valid signature proves only that the claims were not edited. Whether the token is meant for this service, still in date, or held by a user banned since is the verifier's job.
04Verifying a JWT, tested
This script builds HS256 tokens by hand and feeds them to two verifiers. naive_verify has a deliberate bug of a kind RFC 8725, the JWT best current practice, warns about: it lets the token's header pick the algorithm, including none. verify follows the RFC.
import base64, hashlib, hmac, json, secrets
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
unb64u = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
KEY = secrets.token_bytes(32) # 256-bit random HMAC key
def mac(h, p):
return hmac.new(KEY, f"{h}.{p}".encode(), hashlib.sha256).digest()
def sign(claims):
h = b64u(b'{"alg":"HS256","typ":"JWT"}')
p = b64u(json.dumps(claims).encode())
return f"{h}.{p}.{b64u(mac(h, p))}"
def naive_verify(tok, **_): # BUG on purpose: the token picks its own alg
h, p, s = tok.split(".")
alg = json.loads(unb64u(h))["alg"]
if alg != "none" and not hmac.compare_digest(mac(h, p), unb64u(s)):
raise ValueError("bad signature")
return json.loads(unb64u(p))
def verify(tok, aud, iss, now, leeway=30):
h, p, s = tok.split(".")
if json.loads(unb64u(h)).get("alg") != "HS256":
raise ValueError("alg not allowed") # the server decides, not the token
if not hmac.compare_digest(mac(h, p), unb64u(s)):
raise ValueError("bad signature")
c = json.loads(unb64u(p))
if c.get("iss") != iss: raise ValueError("wrong issuer")
if c.get("aud") != aud: raise ValueError("wrong audience")
if now >= c["exp"] + leeway: raise ValueError("expired")
return c
T0 = 1_760_000_000
good = sign({"sub": "u42", "role": "user", "iss": "auth.example",
"aud": "api", "iat": T0, "exp": T0 + 300})
h, p, s = good.split(".")
print("length:", len(good), "| payload, no key needed:", unb64u(p).decode()[:45], "...")
admin = b64u(json.dumps(dict(json.loads(unb64u(p)), role="admin")).encode())
edited = f"{h}.{admin}.{s}" # changed claim, old signature
unsigned = b64u(b'{"alg":"none","typ":"JWT"}') + "." + admin + "."
cases = [("valid, strict", verify, good, "api", T0 + 60),
("role edited, naive", naive_verify, edited, "api", T0 + 60),
("role edited, strict", verify, edited, "api", T0 + 60),
("alg none, naive", naive_verify, unsigned, "api", T0 + 60),
("alg none, strict", verify, unsigned, "api", T0 + 60),
("aud=billing, strict", verify, good, "billing", T0 + 60),
("10 min later, strict", verify, good, "api", T0 + 600)]
for name, fn, tok, aud, now in cases:
try:
print(f"{name:<22} ACCEPTED role={fn(tok, aud=aud, iss='auth.example', now=now)['role']}")
except ValueError as e:
print(f"{name:<22} rejected: {e}")Output:
length: 221 | payload, no key needed: {"sub": "u42", "role": "user", "iss": "auth.e ...
valid, strict ACCEPTED role=user
role edited, naive rejected: bad signature
role edited, strict rejected: bad signature
alg none, naive ACCEPTED role=admin
alg none, strict rejected: alg not allowed
aud=billing, strict rejected: wrong audience
10 min later, strict rejected: expiredChanging role to admin under the old signature failed in both verifiers, because the HMAC no longer matched. The unsigned token is the instructive case. The naive verifier accepted it as an admin, since the token itself said no signature was needed. The strict verifier never asks the token which algorithm to use; it accepts HS256 only, so the same token failed with "alg not allowed". RFC 8725 makes this a library requirement: callers choose the allowed algorithms, and each key is used with exactly one. That rule also blocks a known confusion where a server expecting RS256 gets an HS256 token keyed with the server's own public key.
The last two lines are checks only the verifier can make. Billing refused a token minted for the API, which is why RFC 8725 says to validate aud. The five-minute token was refused ten minutes after issue, despite the 30-second clock-skew leeway RFC 7519 permits. Also check the issuer, use a long random key (OWASP's JWT cheat sheet warns that weak HMAC secrets can be guessed offline), and treat header fields such as kid as untrusted input. In real code, hand the allowed algorithm, audience and issuer to a maintained library.
05Sessions or tokens: revocation and storage
Deleting a row ends a session at once. A JWT has no such switch: every service that checks it locally will accept it until exp. For a stolen phone or a suspended account, three fixes are common.
First, keep access tokens short-lived, minutes rather than days. Second, pair each with a refresh token: a longer-lived credential the client sends only to the authorization server to get a new access token, which the server can look up, replace on every use and revoke. Third, keep a denylist of revoked token IDs until they expire, as the OWASP JWT cheat sheet describes. Both bring a server-side lookup back, so a "stateless" design ends up partly stateful; MDN's session guide prefers plain session IDs where the architecture allows.
Storage is the other trade. Any script on the page can read localStorage, so one XSS bug can steal a token kept there. An HttpOnly cookie hides it from script, but the browser attaches it automatically, which brings CSRF back into scope.
A rule of thumb: one web app with one backend should use server sessions. Many independent services or native mobile clients point to short-lived signed access tokens backed by revocable refresh tokens. In both, logout has to end something on the server.
06Stage 3: OAuth 2.0 and the authorization code flow
A calendar service now wants to read a user's notes, ideally without being handed the user's password. OAuth 2.0 (RFC 6749) does this by delegation and names four roles: the resource owner (the user), the client (the calendar app), the authorization server (signs the user in, issues tokens) and the resource server (the notes API).
The diagram traces the authorization code grant. The client sends the browser to the authorization server with its client_id, a redirect_uri, the scope it wants (say notes.read) and a random state. The user signs in on the authorization server's page and approves the scope, so the calendar never sees the password. The server redirects back with a one-time code. The client checks that state matches, which RFC 6749 recommends against cross-site request forgery. The client then swaps the code at the token endpoint for an access token (plus an optional refresh token) and calls the API with an Authorization: Bearer header.
Why a code and not the token? Redirects pass through the browser and its history. A code is short-lived and useless alone, while step 6 runs directly between client and server. A confidential client, such as a server-side web app, also presents a client secret there; public clients, such as single-page and mobile apps, cannot keep one.
OAuth does not tell the client who the user is. An access token is meant for the API. OpenID Connect adds identity on top of OAuth 2.0: the client asks for the openid scope and receives an ID token, a signed JWT addressed to the client and tied to this login by a nonce. For login, use OpenID Connect, not an access token.
07PKCE and what current guidance rules out
Public clients leave a gap. RFC 7636 describes a malicious phone app registering the same custom URI scheme as the real one, receiving the code and, with no client secret in play, redeeming it. PKCE (Proof Key for Code Exchange, RFC 7636) closes the gap with a one-time secret per login. The client creates a random code_verifier, sends only its SHA-256 hash (the code_challenge, method S256) in step 2, and reveals the verifier in step 6. The server hashes it and compares. Someone who saw only the redirect has the code but not the verifier.
This simulation runs both sides in one process:
import base64, hashlib, hmac, secrets
def b64u(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
# Client side, before redirecting the browser
verifier = b64u(secrets.token_bytes(32))
challenge = b64u(hashlib.sha256(verifier.encode()).digest())
state = secrets.token_urlsafe(16)
print("verifier :", len(verifier), "chars")
print("challenge:", len(challenge), "chars (sent in the redirect, method S256)")
print("state :", len(state), "chars")
# Authorization server: stores challenge with the code it issues
issued = {"code": secrets.token_urlsafe(16), "challenge": challenge}
def token_endpoint(code, code_verifier):
if code != issued["code"]:
return "invalid_grant"
calc = b64u(hashlib.sha256(code_verifier.encode()).digest())
if not hmac.compare_digest(calc, issued["challenge"]):
return "invalid_grant"
return "access_token issued"
# Simulation: the real app and an app that only intercepted the code
print("real app :", token_endpoint(issued["code"], verifier))
print("code, no proof:", token_endpoint(issued["code"], b64u(secrets.token_bytes(32))))verifier : 43 chars
challenge: 43 chars (sent in the redirect, method S256)
state : 22 chars
real app : access_token issued
code, no proof: invalid_grantThe 43-character verifier is what RFC 7636 recommends: 32 random bytes in base64url (the allowed range is 43 to 128). The intercepted code alone got invalid_grant. Prefer S256 over the RFC's plain method, which sends the verifier unhashed.
RFC 9700, the OAuth 2.0 security best practice from January 2025, sets today's rules. Public clients must use PKCE; confidential clients should. Redirect URIs are matched by exact string, never by prefix or wildcard. The implicit grant, which put tokens straight into the redirect, gives way to the code flow. The password grant, where the client collects the user's password itself, must not be used. Refresh tokens for public clients must be rotated on each use or bound to the client that received them.
08Common interview questions
Q: Sessions or JWTs for a new web app?
A: Server-side sessions for one web app with one backend: simple, with instant logout. JWTs fit many services that must verify users without a shared store, given short expiry and a revocation plan.
Q: How do you revoke a JWT before it expires?
A: You cannot change an issued token. Keep access tokens short, revoke the refresh token so no new ones are issued, and for immediate effect keep a denylist of token IDs until expiry. That lookup brings back server state.
Q: What is the alg none problem?
A: A verifier that trusts the token's header can be told no signature is needed; the naive verifier above accepted an unsigned admin token. Pin allowed algorithms on the server, one per key, as RFC 8725 requires.
Q: Is a JWT encrypted?
A: A signed JWT is not. The test decoded its claims without a key. Signing gives integrity, not secrecy.
Q: Is OAuth 2.0 an authentication protocol?
A: No. It delegates limited API access, and the access token is meant for the resource server. For login, use OpenID Connect and its ID token.
Q: Why do single-page and mobile apps need PKCE?
A: They are public clients with no secret, so an intercepted code could be redeemed by someone else. PKCE binds the code to a verifier only the real client holds; a wrong one gets invalid_grant. RFC 9700 requires it.