S059 · AuthN & AuthZ — OAuth 2.0, OIDC, JWT
The two things you can't afford to get wrong.
Module M06: Backend & APIs · Session 59 of 130 · Track: SYS 🔐
What you'll be able to do after this session
- Explain AuthN / AuthZ / OAuth / OIDC / JWT to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · OAuth 2 explained in 3 minutes — OktaDev — clearest short intro; the "hotel keycard" analogy sticks.
- 🎥 Deep dive (30–60 min) · OAuth 2.0 and OpenID Connect in plain English — OktaDev — 1-hour talk by Nate Barbettini; the industry-standard tutorial.
- 🎥 Hands-on demo (10–20 min) · JWT authentication tutorial — Web Dev Simplified — build a JWT flow end-to-end in 15 minutes.
- 📖 Canonical article · RFC 6749 — The OAuth 2.0 Authorization Framework — skim sections 1 and 4; the whole industry cites this.
- 📖 Book chapter / tutorial · Auth0 — What is OAuth 2.0? — long-form, opinionated, correct.
- 📖 Engineering blog · Stripe — Account takeover prevention — real-world hard-won lessons.
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: The two things you can't afford to get wrong.
Two different questions, always. AuthN (authentication) asks "who are you?" — proving identity with a password, biometric, or hardware key. AuthZ (authorization) asks "what are you allowed to do?" — checking permissions once identity is established. Mixing these up is the #1 root cause of account-takeover and privilege-escalation bugs. Rule: authenticate at the edge, authorize at every operation.
Real-life analogy: at a hotel, the front desk authenticates you by checking your passport (login). They then issue a keycard that says "room 412, gym, pool, breakfast until 10 AM." Every door you walk up to authorizes by reading the keycard, not your passport. The keycard has an expiry, a scope, and can be revoked at the desk. That keycard is a token — OAuth issues them, JWT is one popular way to encode them, OIDC adds "and who is this person" on top of OAuth.
The names you must know:
- OAuth 2.0 — the framework for issuing tokens that delegate access ("this app can read your GitHub repos"). It does NOT do login; it does authorization delegation.
- OpenID Connect (OIDC) — a thin layer on OAuth that adds identity ("this user is
sub=user_123, email=alice@x.com"). That is what "Sign in with Google" actually does. - JWT (JSON Web Token) — a format for tokens: a signed, base64-encoded JSON blob.
header.payload.signature. Self-contained (server does not need to hit a DB to verify), signed (tamper-evident). - API keys — the simplest form. Long random string in a header. Zero standardisation but everyone uses them for service-to-service.
Forever gotcha: never put secrets or sensitive data in a JWT payload. JWTs are base64-encoded, not encrypted — anyone with the token can read the payload. Sign it; do not trust it not to be seen. If you need secrecy, use JWE (encrypted JWT) or do not put it in the token at all.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
The OAuth 2.0 authorization-code flow with PKCE (the modern default for web + mobile):
The four OAuth grant types — pick by client type:
| Grant | Client type | Notes |
|---|---|---|
| Authorization Code + PKCE | Web app, mobile app, SPA | THE default in 2025 |
| Client Credentials | Server-to-server, no user | Machine-to-machine only |
| Refresh Token | Anything holding a refresh token | Get new access tokens without re-login |
| Device Code | TVs, CLI tools, no browser | "Go to example.com/device and enter ABCD-1234" |
Deprecated / dangerous (do not use in new code):
- Implicit flow (tokens in URL) — replaced by Auth Code + PKCE.
- Password grant (send username+password to third-party) — security disaster.
JWT anatomy: header.payload.signature
Three dot-separated base64 strings. The header names the algorithm (RS256, HS256, ES256), the payload holds the claims (JSON), the signature is over base64(header).base64(payload) using the algorithm's key.
Standard JWT claims you should always set:
| Claim | Meaning |
|---|---|
iss | Issuer — who signed this |
sub | Subject — the user id |
aud | Audience — which API is this for |
exp | Expiry (Unix seconds) — MUST check |
iat | Issued at |
nbf | Not valid before |
jti | Unique token id (for revocation) |
scope | Space-separated permissions |
Access token vs refresh token vs ID token:
- Access token — short-lived (5–60 min), sent on every API call. Loss = short window of damage.
- Refresh token — long-lived (days to months), exchanged for new access tokens. Stored server-side or in httpOnly cookies. Loss = full account access.
- ID token (OIDC only) — a JWT describing the user. NOT for API calls — just to know who logged in.
Authorization models (the AuthZ half):
- RBAC (role-based): user has role
admin→ can do X. Simple, coarse. - ABAC (attribute-based): "can edit doc if user.department == doc.department". Rich policies.
- ReBAC (relationship-based): "can view doc if user is a friend of the owner" — Google Zanzibar, SpiceDB, OpenFGA.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Build a JWT-signed API with proper verification, expiry, and scope check.
python -m venv .venv && source .venv/bin/activate
pip install "fastapi[standard]==0.115.0" "pyjwt[crypto]==2.9.0" uvicorn# auth_demo.py
from fastapi import FastAPI, Header, HTTPException, Depends
from pydantic import BaseModel
import jwt, time, uuid, secrets
SECRET = "super-secret-change-me-and-put-in-a-KMS"
ISSUER = "https://auth.example.com"
AUDIENCE = "orders-api"
app = FastAPI()
class LoginIn(BaseModel):
username: str
password: str
USERS = {
"alice": {"password": "wonderland", "scopes": ["read:orders", "write:orders"]},
"bob": {"password": "builder", "scopes": ["read:orders"]},
}
@app.post("/login")
def login(body: LoginIn):
u = USERS.get(body.username)
if not u or not secrets.compare_digest(u["password"], body.password):
raise HTTPException(401, "bad credentials")
now = int(time.time())
payload = {
"iss": ISSUER, "sub": body.username, "aud": AUDIENCE,
"iat": now, "exp": now + 15 * 60,
"jti": str(uuid.uuid4()),
"scope": " ".join(u["scopes"]),
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
return {"access_token": token, "token_type": "Bearer", "expires_in": 900}
def verify_token(authorization: str = Header(...)):
if not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
raw = authorization.removeprefix("Bearer ")
try:
claims = jwt.decode(raw, SECRET, algorithms=["HS256"],
audience=AUDIENCE, issuer=ISSUER,
options={"require": ["exp", "iat", "iss", "aud", "sub"]})
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token expired")
except jwt.InvalidTokenError as e:
raise HTTPException(401, f"invalid token: {e}")
return claims
def require_scope(needed: str):
def dep(claims: dict = Depends(verify_token)):
if needed not in claims.get("scope", "").split():
raise HTTPException(403, f"missing scope: {needed}")
return claims
return dep
@app.get("/me")
def me(claims: dict = Depends(verify_token)):
return {"user": claims["sub"], "scopes": claims["scope"].split()}
@app.get("/orders")
def list_orders(claims: dict = Depends(require_scope("read:orders"))):
return [{"id": 1, "amount": 25}, {"id": 2, "amount": 42}]
@app.post("/orders")
def create_order(claims: dict = Depends(require_scope("write:orders"))):
return {"id": 3, "status": "created"}Run: uvicorn auth_demo:app --reload --port 8000
# 1. Login as bob (read-only)
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/login \
-H 'Content-Type: application/json' \
-d '{"username":"bob","password":"builder"}' \
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# 2. Decode payload with NO verification — anyone can do this
python -c "import jwt;print(jwt.decode('$TOKEN', options={'verify_signature': False}))"
# 3. Bob reads → 200
curl -s http://127.0.0.1:8000/orders -H "Authorization: Bearer $TOKEN"
# 4. Bob writes → 403 (no write scope)
curl -s -X POST http://127.0.0.1:8000/orders -H "Authorization: Bearer $TOKEN"
# 5. Tampered token → 401
BAD="${TOKEN%.*}.tampered"
curl -s http://127.0.0.1:8000/orders -H "Authorization: Bearer $BAD"
# 6. No token → 401
curl -s http://127.0.0.1:8000/meWhat to observe:
- Step 2 proves the JWT payload is READABLE by anyone — never put secrets in it.
- Step 4 shows AuthZ: bob is authenticated but not authorized for writes → 403 (not 401).
- Step 5: tampering the signature triggers
InvalidSignatureError→ 401. - Step 6: missing token → 401. The distinction (401 = "who are you?", 403 = "you can't do that") matters.
- Every endpoint declares its required scope inline — that is how you enforce least-privilege consistently.
Try this modification: implement token revocation. Add an in-memory revoked_jti set, an /logout endpoint that adds the current token's jti, and modify verify_token to reject revoked tokens. This is why refresh tokens are usually server-side even when access tokens are JWTs — you need the ability to revoke.
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
Every modern auth stack ends up on some combination of: an identity provider (Auth0, Okta, Cognito, Keycloak, Firebase, Clerk) that speaks OIDC; short-lived access tokens (JWT or opaque) validated at every API; refresh tokens rotated on use and stored in httpOnly cookies or secure storage; hardware-key or authenticator-app 2FA; audit logging of every auth event; an authorization engine (Casbin, OpenFGA, Cerbos, or handrolled) that decides who can do what.
War stories:
- The
alg: nonebypass. Old JWT libraries acceptedalg: nonein the header — no signature check. Attackers setalg=none, changedsubto another user, walked in as admin. Fix: explicitly passalgorithms=["HS256"](or your chosen alg) tojwt.decode. NEVER trust the header'salg. - The forever token. Team issued JWTs with
exp = now + 100 years. Someone stole one → full account access, no way to revoke. Fix: short access tokens (5-60 min) + refresh tokens with server-side rotation and revocation. - The refresh-token replay. Attacker got a copy of a refresh token, used it → server issued fresh access token → real user used the same refresh token later → also got a new access token → nobody noticed. Fix: rotate refresh tokens on every use, invalidate the old one, detect "old refresh token reused" as an attack and revoke the whole family.
- The IDOR (insecure direct object reference).
GET /invoices/12345— no check that the invoice belongs to the caller. Anyone could enumerate. AuthN was fine (logged in), AuthZ was missing. Fix: every resource fetch must check ownership/permission. Middleware doesn't help — you need per-object checks. - The confused deputy. OAuth app was allowed
scope=read:orders. Application code ignored scopes and let any authenticated user hit any endpoint. Fix: check both authentication AND declared scope on every endpoint. - The Sign-in-With-Google bypass. Team accepted any Google-signed JWT as proof of identity but did not verify the
audclaim was their own client_id → attacker used a token issued for a different Google-integrated app to log into theirs. Fix: verifyiss,aud,exp, and signature every single time.
How top teams handle it: all authentication goes through one IdP; libraries like Auth0 SDK / NextAuth / Passport handle the OIDC dance; access tokens are validated with the IdP's JWKS endpoint (public keys, rotated); rate-limit login attempts + captcha after N failures; alert on impossible-travel logins and new-device logins; force step-up MFA for sensitive actions (money movement, password change, account deletion).
Gotcha to memorise: write auth code from scratch only if you must. OAuth/OIDC has 200+ pages of spec and a decade of CVEs. Use battle-tested libraries. The cost of getting it right yourself dwarfs the cost of the SaaS.
(e) Quiz + exercise · 10 min
- Distinguish AuthN from AuthZ in one sentence each, and say which HTTP status maps to each failure.
- Why must you NEVER put secrets in a JWT payload?
- What role does PKCE play in the authorization-code flow, and why is it recommended even for confidential server clients?
- Explain refresh-token rotation and what "old refresh token reused" should trigger.
- Name three JWT claims a server MUST verify on every request.
Stretch (connects to S056 — REST): Design the auth layer for a public REST API that supports both first-party mobile apps (users log in) and third-party developer integrations (server-to-server). Which OAuth grant does each use? How do you scope + rate-limit them differently?
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is OAuth / OIDC / JWT? (one sentence each, no jargon)
- When would you use them? (one concrete example)
- When would you NOT use them? (one anti-pattern)
What comes next
- Next session (S060): OS Basics — Processes, Threads, Memory, FDs
- Previous (S058): gRPC & Protobuf — When RPC Wins
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M06 · Backend & APIs
all modules →