S059 · AuthN & AuthZ — OAuth 2.0, OIDC, JWT
The two things you can't afford to get wrong. Authentication vs authorization untangled, OAuth 2.0's actual flows, OIDC's identity layer, and JWTs — including the specific parts of ‘just use JWTs’ that get people breached. With a working OAuth code flow you can trace end to end.
🎯 Explain OAuth 2.0's four main flows, decode a JWT by hand, pick between session cookies and JWTs for a given use case, and enumerate the top-5 auth footguns that ship to prod.
Why this session exists
Auth is the small feature that, when broken, becomes the whole story. ‘Just use JWTs’ has produced years of vulnerability reports; ‘just use OAuth’ produced a decade of scope-abuse breaches. AuthN (who are you?) and AuthZ (what may you do?) are two different problems with two different tool sets, and confusing them is the single most common architectural mistake in this space. This session gives you working mental models plus a runnable code flow you can trace end to end.
- Distinguish authentication (login) from authorization (permission) in one sentence.
- Explain the OAuth 2.0 Authorization Code + PKCE flow step by step.
- Describe what OpenID Connect adds on top of OAuth, and when you need it.
- Decode a JWT header/payload/signature by hand and name three ways JWTs get misused.
- Choose between session cookies + server storage vs stateless JWTs with a defensible reason.
Prerequisites
- S055 · HTTP fundamentals — headers, cookies, TLS.
- S056 · REST API design — you know what an endpoint is.
- S050 · Cryptography basics (optional) — helpful for signatures.
(a) Intuition · 5 min
At the airport, your passport proves who you are — issued by a government, hard to fake, valid for 10 years. That's authentication.
Your boarding pass says what you're allowed to do today — board flight 302, seat 14C, priority group 2. Issued for one trip, easy to invalidate. That's authorization.
You need both. Your passport doesn't let you board any plane; your boarding pass doesn't prove you are you. And the two systems are wisely separate — an airline can't issue passports, and a country doesn't allocate seats.
Same in software. Authentication (AuthN) is ‘log in’ — the identity provider verifies you (password, OAuth, WebAuthn) and produces proof (a session ID or an ID token).
Authorization (AuthZ) is ‘what may you do?’ — the resource server checks your token, looks up your permissions, and decides. Different token, different lifetime, different service. Mixing them (‘the JWT contains all permissions forever’) is the origin of most breaches.
The pieces you'll see in every stack
- OAuth 2.0 — delegated authorization. Lets app A get a limited-scope token to act on user's behalf at service B. Not a login protocol.
- OpenID Connect (OIDC) — a thin identity layer on top of OAuth 2.0. Adds an ID token (JWT) that says who you are. This is what ‘Sign in with Google’ actually is.
- JWT — a signed, base64-encoded JSON blob. Convenient stateless bearer token, but only as safe as your signing keys + verification logic.
- Session cookie — an opaque server-side session ID stored as an HttpOnly cookie. The traditional ‘log in and get a cookie’ pattern. Simpler and safer than JWT for classic web apps.
- Bearer token — anything a client sends in `Authorization: Bearer <token>` and the server trusts. JWTs are one shape; opaque tokens are another.
How we got here
- 2007OAuth 1.0 (Twitter, Google)First attempt. Required request signing, painful for developers. Motivating problem: apps wanted to post tweets without stealing your password.
- 2012OAuth 2.0 (RFC 6749)Simplified: TLS instead of request signing, four grant types. Rapid adoption but interpretation varied wildly.
- 2014OpenID Connect 1.0The identity spec people needed. Standardises ‘Sign in with X’ across providers.
- 2015JWT (RFC 7519)JSON Web Token spec. Signed, portable, stateless. Instant popularity — and instant misuse.
- 2020PKCE required for public clientsOAuth 2.1 draft mandates PKCE on the auth code flow to close the ‘authorization code interception’ attack.
- 2023OAuth 2.1 consolidationKills the deprecated flows (implicit, password grant). Recommends WebAuthn/passkeys as the modern login primitive.
(b) Visual walkthrough · 15 min
Authorization Code + PKCE — the flow to memorise
PKCE (‘Proof Key for Code Exchange’) closes the ‘what if someone intercepts the authorization code?’ hole: only the client that generated the original verifier can exchange the code for a token. Required for public clients (SPAs, mobile apps), recommended for all.
The four (three) OAuth grant types you'll meet
Default for humans logging in
- Web apps, SPAs, mobile — all humans
- Redirects through the browser
- Tokens delivered to a backend or verified client
- The one you should use unless you have a very specific reason not to
Machine-to-machine, no user
- Backend service calls another backend service
- No user identity involved — just app identity
- Static client_id + client_secret
- Typical for scheduled jobs, integrations
Renew an expired access token
- Long-lived credential kept server-side
- Exchanges for new short-lived access tokens
- Rotate refresh tokens on use to detect theft
- Never expose to the browser or logs
Do not use in new code
- Implicit: access token in URL fragment. Leaks everywhere.
- Password grant: app sees the user's password. Defeats the whole point of OAuth.
- OAuth 2.1 removes both
- Migrate any legacy usage to Auth Code + PKCE
JWT anatomy
Three dot-separated parts, base64url-encoded
The JWT footgun catalog
Old libraries accepted `alg:none` tokens as valid (no signature check). Attacker sets alg:none and forges any payload. Every library since ~2016 fixed this — verify yours.
‘secret123’ signing an HS256 token is crackable in seconds. Use rotated random keys ≥ 256 bits.
Server accepts HS256 with the RSA public key as the secret. Attacker signs tokens with the public key. Fix: pin one algorithm per issuer, reject others.
A JWT with `exp` = year 3000 is a bearer token that never dies. Cap to minutes, refresh via refresh token.
Token minted for service A accepted by service B. Always validate `aud` matches your service.
XSS steals it in one line. Use HttpOnly cookies (with SameSite=Lax + CSRF) or short-lived tokens with strict content-security policy.
Session cookies vs JWTs
Server keeps state, client keeps opaque ID
- Simple, revocable in one place (delete row)
- HttpOnly + Secure + SameSite = safe by default
- Requires shared session storage (Redis) for multi-node
- Not great for cross-domain or mobile APIs
Client holds signed claims, server just verifies
- Zero session storage; scales trivially
- Perfect for cross-domain, mobile, microservices
- Revocation is hard (blacklist or short TTL only)
- Every misuse is a security vuln waiting
Short-lived JWT + refresh token
- Access token: JWT, 5-15 min TTL
- Refresh token: opaque, server-tracked, rotated on use
- Revocation = delete the refresh token row
- Best-of-both: stateless most of the time, revocable when needed
"OAuth 2.0 is an authentication protocol. If I integrate 'Login with Google' via OAuth, I've authenticated the user."
OAuth 2.0 is an authorization delegation protocol. It answers "may this application access that resource on the user's behalf?" — it does not answer "who is this user?" An access token proves possession of a grant, not identity. OpenID Connect is the thin layer added on top of OAuth precisely because OAuth alone could not do authentication safely.
The myth is sticky because a successful OAuth flow feels like a login: the user is redirected to Google, types a password, and comes back. Something clearly authenticated somewhere. But the authentication happened between the user and Google — your application only received a token. Using that token as proof of identity produces a real vulnerability: a token issued for a different client can be presented to yours, and if you never check who the token was issued to, you accept it. That is the confused deputy problem, and it is why the ID token in OIDC has an aud claim your code is required to verify.
Inspect the two tokens and note what each is for:
# ID token (OIDC) - about the USER, for YOU to consume
{
"iss": "https://accounts.google.com",
"aud": "YOUR_CLIENT_ID", // must equal your client id
"sub": "1029384756", // the stable user id
"exp": 1735689600
}
# Access token - about ACCESS, opaque to you, for the API
"ya29.a0Af..." // you are not meant to parse thisIf your login code reads the access token to find the user, it is doing something the protocol never promised would be safe.
Why does the authorization code flow bounce through a redirect and then require a second back-channel call, instead of just returning the token in the redirect?
- 1The authorization server must be certain it is talking to the real user, so it needs the user's browser to visit it directly and authenticate there.forced by · credentials must never pass through the client application — that is the entire point of delegation
- 2Having authorized, the result must get back to the application, and the only channel between the authorization server and the application is the user's browser via a redirect.forced by · the authorization server has no pre-existing connection to the application's backend for this specific user
- 3But a redirect URL is exposed everywhere: browser history, the
Refererheader, proxy logs, server access logs, and the address bar over someone's shoulder.forced by · URLs are treated as non-secret by every layer of the web stack - 4Therefore whatever travels in the redirect must be useless to a thief on its own — so it is a short-lived, single-use code, not a token.forced by · the channel is not confidential, so it can only carry something that requires a second factor to redeem
- 5The application then exchanges that code for a token over a direct server-to-server TLS call, authenticating itself with a client secret the browser never sees.forced by · the exchange must prove "I am the application this code was issued to", which requires a secret the URL never carried
Therefore the two-step flow exists because exactly one of the two channels is confidential, and the token must only ever traverse that one. The code is a claim ticket; the secret proves you are entitled to redeem it.
And note what this predicts: a mobile or single-page app cannot hold a client secret — anyone can decompile the binary or read the JavaScript — so step 5 loses its protection, and a stolen code becomes redeemable. Hence PKCE: the client generates a random verifier, sends its hash up front, and presents the verifier at redemption. It reconstructs the missing proof without a stored secret. Having derived it, PKCE stops being an acronym to memorise and becomes the only thing it could have been. That is also why the implicit flow — which returned tokens directly in the redirect — was deprecated: it violated step 3.
OAuth hands out a valet key: it starts the car and opens the driver's door, but not the boot or the glovebox, and it expires. You never give the valet your house key. The user authenticates with the identity provider (their house key), and your application receives only a scoped, expiring valet key.
Four parties, always: the resource owner (user), the client (your app), the authorization server (issues tokens), and the resource server (accepts them). Every OAuth confusion is a failure to keep those four separate.
- Access token = what you may do. ID token = who the user is. Refresh token = how you get a new access token. Never substitute one for another, and never let your application parse an access token it did not issue.
- Validate a JWT properly or do not validate it at all: signature against the issuer's published keys,
iss,aud,exp, and the algorithm — explicitly pin the expected algorithm, because accepting whatever the header claims is how thealg: noneand RS256-to-HS256 confusion attacks work. - JWTs cannot be revoked before expiry; that is inherent to self-contained tokens, not a library limitation. Keep access tokens short-lived (minutes) and apply revocation at refresh time.
- Scopes are coarse permissions granted to an application, not a substitute for your own authorization logic. A valid token with the right scope still says nothing about whether this user may read that record.
Fire this model when you see: a token being decoded to identify a user for login · a JWT accepted without aud verification · a refresh token stored in localStorage · an SPA holding a client secret · "log out everywhere" that doesn't take effect for an hour.
Where does a browser-based single-page app store its tokens?
SameSite plus Secure handle the common attack shapes.SameSite=Lax or Strict plus anti-CSRF tokens for state-changing requests. Cross-origin API calls require correct CORS with credentials, which is fiddly to get right.httpOnly cookies for most applications; BFF when the blast radius of a stolen token is severe. Reject localStorage as a default: the argument for it is convenience, and the argument against it is that a single supply-chain compromise in any dependency reads every token silently.
Underneath all three options is the same principle — assume XSS will eventually happen and ask what an attacker gets when it does. If the answer is a long-lived token with broad scopes, the storage choice was not your real problem. Short lifetimes and narrow scopes limit damage regardless of where the token sat.
(c) Hands-on · 25 min
We'll build a minimal OAuth-lite flow — issue a JWT, verify it, decode it by hand — so you see every step of what real libraries do.
"""
auth_demo.py — Issue and verify a JWT, decode it by hand, and demonstrate
three common footguns.
Run:
pip install "pyjwt[crypto]"
python auth_demo.py
"""
from __future__ import annotations
import base64
import json
import time
from dataclasses import dataclass
import jwt # PyJWT
# ---------- config ----------
ISSUER = "https://auth.example.com"
AUDIENCE = "https://api.example.com"
HS_SECRET = "not-a-real-secret-please-use-256-bits-from-os.urandom" # for HS256
# ---------- issue a token ----------
def issue_token(user_id: str, scopes: list[str], ttl_seconds: int = 300) -> str:
now = int(time.time())
payload = {
"iss": ISSUER,
"sub": user_id,
"aud": AUDIENCE,
"iat": now,
"exp": now + ttl_seconds,
"scope": " ".join(scopes),
}
return jwt.encode(payload, HS_SECRET, algorithm="HS256", headers={"kid": "hs-1"})
# ---------- verify a token (the right way) ----------
def verify_token(token: str) -> dict:
return jwt.decode(
token,
HS_SECRET,
algorithms=["HS256"], # PIN the algorithm — prevents alg confusion
audience=AUDIENCE, # check aud
issuer=ISSUER, # check iss
options={"require": ["exp", "iat", "sub", "aud", "iss"]},
)
# ---------- decode without verifying (for eyeballing only) ----------
def peek(token: str) -> dict:
def _b64d(seg: str) -> dict:
pad = "=" * (-len(seg) % 4)
return json.loads(base64.urlsafe_b64decode(seg + pad))
h, p, _ = token.split(".")
return {"header": _b64d(h), "payload": _b64d(p)}
# ---------- footgun demos ----------
def footgun_alg_none(token: str) -> None:
"""Old libs accepted alg:none. Show that we don't."""
header, payload, _ = token.split(".")
payload_json = json.loads(base64.urlsafe_b64decode(payload + "==="))
payload_json["scope"] = "admin" # attacker escalates
payload_json["sub"] = "attacker"
new_payload = base64.urlsafe_b64encode(json.dumps(payload_json).encode()).rstrip(b"=").decode()
new_header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').rstrip(b"=").decode()
forged = f"{new_header}.{new_payload}."
try:
verify_token(forged)
print("❌ alg:none accepted — you have a critical vuln")
except jwt.InvalidAlgorithmError:
print("✅ alg:none rejected — algorithm is pinned")
except jwt.DecodeError as e:
print(f"✅ forged token rejected: {e}")
def footgun_expired(user_id: str) -> None:
token = issue_token(user_id, ["read"], ttl_seconds=-1) # already expired
try:
verify_token(token)
print("❌ expired token accepted")
except jwt.ExpiredSignatureError:
print("✅ expired token rejected as it should be")
def footgun_wrong_audience(user_id: str) -> None:
now = int(time.time())
token = jwt.encode(
{"iss": ISSUER, "sub": user_id, "aud": "https://other-api.example.com",
"iat": now, "exp": now + 60, "scope": "read"},
HS_SECRET, algorithm="HS256",
)
try:
verify_token(token)
print("❌ wrong-audience token accepted")
except jwt.InvalidAudienceError:
print("✅ wrong-audience token rejected")
# ---------- happy path ----------
def main() -> None:
token = issue_token("user-42", ["read", "write"])
print("=== issued token ===")
print(token)
print("\n=== decoded (header + payload) ===")
print(json.dumps(peek(token), indent=2))
claims = verify_token(token)
print("\n=== verified claims ===")
print(json.dumps(claims, indent=2))
print("\n=== footgun tests ===")
footgun_alg_none(token)
footgun_expired("user-42")
footgun_wrong_audience("user-42")
if __name__ == "__main__":
main()Run:
pip install "pyjwt[crypto]"
python auth_demo.pyExpected output (abbreviated):
=== issued token ===
eyJhbGciOi...
=== decoded (header + payload) ===
{
"header": { "alg": "HS256", "typ": "JWT", "kid": "hs-1" },
"payload": { "iss": "…", "sub": "user-42", "aud": "…", "exp": …, "scope": "read write" }
}
=== verified claims ===
{ ... }
=== footgun tests ===
✅ alg:none rejected — algorithm is pinned
✅ expired token rejected as it should be
✅ wrong-audience token rejected
Anatomy of the script
What each block teaches
Swap in RSA:
from cryptography.hazmat.primitives import serialization
with open("key.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("pub.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
token = jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "rs-1"})
claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=AUDIENCE, issuer=ISSUER)The auth server holds the private key; every resource server just fetches the public key (usually from /.well-known/jwks.json) and verifies. This is why one Google account works across a million apps.
(d) Production reality · 15 min
Auth0 published a landmark analysis: many JWT libraries in Node, Java, Python, and Go silently accepted alg:none tokens as valid. Attackers could forge any payload — including admin claims — with zero cryptography knowledge.
The RFC allowed none for unusual use cases; libraries defaulted to permissive verification. Result: years of unauthenticated escalation vulnerabilities across major products.
algorithms=[...] parameter. Auth0's post-mortem became the industry canon for JWT best practice.Two patterns that make this near-impossible:
- Store the access token in an HttpOnly, Secure, SameSite=Strict cookie. JS cannot read it; CSRF is blocked by SameSite.
- If you must use JS-accessible tokens, keep TTL <5 min and rotate on every request. Combine with a strict Content-Security-Policy that blocks unknown scripts.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these:
- What is the difference between authentication and authorization?
- Why is OAuth 2.0 not a login protocol, and what is?
- Give three specific ways JWTs get people breached.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.