Chapter 5: Authentication & Authorization
"Authentication answers: who are you?
Authorization answers: what are you allowed to do?
Most security bugs come from confusing the two."
1. Physical Reality First
Every HTTP request is stateless (Chapter 2). The server has no memory of past requests. So on every single request, the server must answer two questions before running any business logic:
- Who is making this request? (Authentication)
- Are they allowed to do what they're asking? (Authorization)
These are separate questions answered in sequence. You cannot authorize without authenticating first. But authentication alone is not enough — a valid user still might not have permission to delete another user's data.
What physically happens on every authenticated request:
Client sends:
GET /orders/99 HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyX2lkIjo0Mn0.sig
Server does (in this order):
1. Parse the Authorization header
2. Validate the credential (is this token real? not expired? not revoked?)
3. Extract identity (user_id: 42)
4. Check permission (can user 42 read order 99?)
5. Run business logic
6. Return response
Steps 1–4 happen before a single line of your route handler runs. If any step fails:
- Step 2 fails →
401 Unauthorized - Step 4 fails →
403 Forbidden
Let's go through each authentication mechanism from simplest to most powerful.
2. Mechanism 1: API Keys
The simplest possible auth. A long random string that identifies a client.
GET /charges HTTP/1.1
Authorization: Bearer sk_live_4eC39HqLyjWDarjtT1zdp7dc
Or sometimes as a query param (avoid — gets logged):
GET /data?api_key=sk_live_4eC39HqLyjWDarjtT1zdp7dc
How it works:
Client Server
│─── GET /charges ──────────► │
│ Authorization: Bearer key│
│ │── Look up key in DB ──► DB
│ │◄── {user_id: 42, scopes: ["read"]} ──
│◄── 200 OK ──────────────── │
The server hashes the key and looks it up in a database. The DB row tells you which user this key belongs to and what permissions it has.
Key properties:
- Simple to implement and use
- Server must DB-lookup on every request (unless cached)
- No expiry unless you build it
- If leaked, attacker has permanent access until revoked
- Best for: server-to-server API access, developer integrations
Stripe's approach:
sk_live_... ← secret key (full access, never expose in frontend)
pk_live_... ← publishable key (limited access, safe in browser/mobile)
rk_live_... ← restricted key (scoped to specific resources)
Stripe hashes the key and stores the hash. They can't recover your key if you lose it — you generate a new one.
How to generate a secure API key:
import secrets
key = "sk_live_" + secrets.token_urlsafe(32)
# Store hash(key) in DB, return key once to user, never store plaintext
import hashlib
key_hash = hashlib.sha256(key.encode()).hexdigest()
3. Mechanism 2: HTTP Basic Authentication
Authorization: Basic dHJpbG9jaGFuOnBhc3N3b3Jk
The value after Basic is Base64(username:password).
import base64
base64.b64encode(b"trilochan:password123").decode()
# → "dHJpbG9jaGFuOnBhc3N3b3Jk"
Critical: Base64 is not encryption. Anyone who intercepts the header can decode it instantly. Basic Auth is only safe over HTTPS, never HTTP.
Use cases: Internal tools, simple admin APIs, HTTP endpoints that don't face the public internet. Twilio uses Basic Auth — Account SID as username, Auth Token as password — for server-to-server calls.
Never use for: User-facing applications. Passwords in every request is a large attack surface.
4. Mechanism 3: Session Tokens (Cookie-Based)
Before JWT, this was the standard for web applications:
1. POST /login {"email": "t@example.com", "password": "..."}
Server: verify password → create session in DB → return session ID
2. Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax
3. All subsequent requests: browser sends cookie automatically
Cookie: session_id=abc123xyz
4. Server: look up abc123xyz in sessions table → get user_id → authorize
The DB-backed session table:
CREATE TABLE sessions (
id VARCHAR(64) PRIMARY KEY, -- random token
user_id BIGINT NOT NULL,
created_at TIMESTAMP,
expires_at TIMESTAMP,
ip_address INET,
user_agent TEXT
);
Properties:
- Server stores session state → violates REST statelessness (Chapter 2)
- Instant revocation: delete the session row
- Scales poorly: every request hits the session DB
- CSRF vulnerable: browsers send cookies automatically, including cross-site requests
- Best for: traditional web apps where the frontend and backend share a domain
Cookie flags you must set:
HttpOnly ← JavaScript cannot read this cookie (prevents XSS theft)
Secure ← Only sent over HTTPS (never HTTP)
SameSite=Lax ← Not sent on cross-site requests (CSRF protection)
5. Mechanism 4: JWT — JSON Web Tokens
JWT is the mechanism that makes stateless auth possible. Instead of looking up a session in a DB on every request, the token itself contains the user's identity. The server just validates the signature.
5.1 Structure
A JWT looks like this:
eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyX2lkIjo0MiwiZXhwIjoxNzA1MzEyMjAwfQ.signature
↑ ↑ ↑
Header Payload Signature
(Base64) (Base64) (cryptographic)
Three parts separated by dots. Each part is Base64-encoded (not encrypted — anyone can decode the header and payload).
Header:
{
"alg": "RS256", ← signing algorithm
"typ": "JWT"
}
Payload (claims):
{
"sub": "42", ← subject (user ID)
"email": "t@example.com",
"role": "admin",
"iat": 1705312200, ← issued at (Unix timestamp)
"exp": 1705315800, ← expires at (Unix timestamp)
"iss": "https://api.example.com" ← issuer
}
Signature:
RS256_sign(
base64url(header) + "." + base64url(payload),
private_key
)
5.2 How JWT Validation Works
Client sends: Authorization: Bearer <jwt>
Server:
1. Split token into header.payload.signature
2. Decode header → get algorithm (RS256)
3. Verify signature: RS256_verify(header.payload, signature, public_key)
→ if invalid: 401 (token tampered or forged)
4. Check exp claim: if current_time > exp → 401 (token expired)
5. Check iss claim: if not our issuer → 401
6. Extract user_id from sub claim → user is authenticated
The key insight: No database lookup. The signature guarantees the payload wasn't tampered with. The server only needs the public key to verify — no shared state with any other service.
5.3 Signing Algorithms: HS256 vs RS256
| Algorithm | Type | Key | Use Case |
|---|---|---|---|
| HS256 | Symmetric | Single shared secret | Single service (same service signs and verifies) |
| RS256 | Asymmetric | Private key signs, public key verifies | Multiple services (auth service signs, any service verifies) |
| ES256 | Asymmetric (ECDSA) | Smaller keys, faster | Mobile, bandwidth-constrained |
Use RS256 for microservices. Your auth service holds the private key and signs tokens. Every other service holds the public key and verifies tokens without calling the auth service. No inter-service dependency on every request.
5.4 What NOT to Put in JWT Payload
JWTs are Base64-encoded, not encrypted. Anyone with the token can read the payload.
❌ Do not include:
- Passwords
- Credit card numbers
- PII beyond what's necessary
- Secrets of any kind
- Sensitive permission details that reveal system internals
Only include what services need to make authorization decisions: user ID, role, scopes.
5.5 Access Tokens + Refresh Tokens
JWT access tokens are short-lived (15 minutes to 1 hour). When they expire, the client needs a new one without asking the user to log in again. This is where refresh tokens come in.
Login flow:
POST /auth/login {"email": "...", "password": "..."}
Response: {
"access_token": "<jwt>", ← expires in 15 min
"refresh_token": "<opaque>", ← expires in 30 days, stored in DB
"expires_in": 900
}
Normal requests:
Authorization: Bearer <access_token>
When access token expires (server returns 401):
POST /auth/refresh
{"refresh_token": "<opaque>"}
Response: {
"access_token": "<new_jwt>",
"refresh_token": "<new_opaque>" ← rotate refresh token
}
Logout:
DELETE /auth/sessions ← invalidate refresh token in DB
Why the split?
- Access token: stateless, no DB lookup, but can't be revoked instantly
- Refresh token: stored in DB, can be instantly revoked (logout, compromise response)
Refresh token rotation: Issue a new refresh token on every use. If the old refresh token is used again, detect replay attack and revoke all sessions.
6. Mechanism 5: OAuth 2.0
OAuth 2.0 is not an authentication protocol — it is an authorization framework for delegated access. "I (user) authorize this (third-party app) to access my (resource server) data."
6.1 The Problem OAuth Solves
Before OAuth: "Give your Gmail password to this third-party calendar app so it can read your contacts."
With OAuth: "Click 'Allow' and the app gets a limited token to read only your contacts — no password involved."
6.2 The Four Roles
| Role | Example |
|---|---|
| Resource Owner | The user (you) |
| Client | The third-party app wanting access |
| Authorization Server | Google's auth server (issues tokens) |
| Resource Server | Gmail API (holds the actual data) |
6.3 The Authorization Code Flow (Most Important)
Used when the client is a web app or mobile app that needs to access a user's data.
1. User clicks "Login with Google" on your app
2. Your app redirects user to Google's auth server:
GET https://accounts.google.com/o/oauth2/auth
?client_id=your_app_id
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=email profile
&state=random_csrf_token
3. User logs into Google, clicks "Allow"
4. Google redirects back to your app:
GET https://yourapp.com/callback
?code=4/0Ab12345... ← authorization code (short-lived, one-time use)
&state=random_csrf_token
5. Your server exchanges code for tokens (server-to-server, not in browser):
POST https://oauth2.googleapis.com/token
{
"code": "4/0Ab12345...",
"client_id": "your_app_id",
"client_secret": "your_app_secret", ← never goes to browser
"redirect_uri": "https://yourapp.com/callback",
"grant_type": "authorization_code"
}
Response: {
"access_token": "ya29.A0AfH...",
"refresh_token": "1//0g...",
"expires_in": 3600,
"scope": "email profile"
}
6. Use access_token to call Google APIs:
GET https://www.googleapis.com/oauth2/v1/userinfo
Authorization: Bearer ya29.A0AfH...
The state parameter: A random value your app generates before the redirect and verifies on callback. Prevents CSRF attacks on the OAuth flow.
Why not put the code exchange in the browser? The client_secret would be exposed. Server-side exchange keeps it secret.
6.4 Client Credentials Flow (Machine-to-Machine)
No user involved. Your backend service authenticating to another backend service.
POST /oauth/token
{
"grant_type": "client_credentials",
"client_id": "service-a-id",
"client_secret": "service-a-secret",
"scope": "orders:read payments:write"
}
Response:
{
"access_token": "<jwt or opaque>",
"expires_in": 3600,
"token_type": "Bearer"
}
Use this for: microservice-to-microservice auth, CI/CD pipelines calling APIs, scheduled jobs.
6.5 Scopes
Scopes limit what a token can do. They are space-separated strings that represent permissions.
GitHub scopes: repo read:user delete_repo admin:org
Google scopes: https://www.googleapis.com/auth/gmail.readonly
Stripe scopes: read_only write
Your API should define fine-grained scopes:
orders:read ← list and fetch orders
orders:write ← create and update orders
orders:delete ← delete orders
payments:read
payments:write
users:admin ← elevated admin access
A token with orders:read cannot create orders. A leaked read-only token is much less dangerous than a leaked write token.
7. Authorization: What Can You Do?
Authentication tells you who. Authorization tells you what. Two main models:
7.1 RBAC — Role-Based Access Control
Users are assigned roles. Roles have permissions.
Roles:
admin → all permissions
manager → orders:read, orders:write, users:read
viewer → orders:read, users:read
User → Role → Permissions
user_42 → manager → [orders:read, orders:write, users:read]
Implementation:
CREATE TABLE roles (id, name);
CREATE TABLE role_permissions (role_id, permission);
CREATE TABLE user_roles (user_id, role_id);
On each request:
def can(user_id, permission):
roles = get_user_roles(user_id) # usually cached in JWT
permissions = get_role_permissions(roles)
return permission in permissions
Simple, auditable, easy to understand. Works for 90% of applications.
7.2 ABAC — Attribute-Based Access Control
Permissions based on attributes of the user, resource, and environment.
Policy: "User can read order IF user.id == order.user_id OR user.role == 'admin'"
Policy: "User can approve expense IF expense.amount < user.approval_limit"
Policy: "User can access data IF request.time is between 09:00 and 18:00"
More powerful, more complex. Used in enterprises, healthcare (HIPAA), financial systems. Tools: Open Policy Agent (OPA), AWS IAM.
7.3 Resource-Level Authorization (Always Check)
RBAC tells you "this user can read orders." But can they read this specific order?
# ❌ Wrong — checks role but not ownership
def get_order(order_id, user_id):
if not can(user_id, "orders:read"):
raise Forbidden()
return db.get_order(order_id) # returns ANY order — data leak!
# ✅ Correct — checks ownership too
def get_order(order_id, user_id):
if not can(user_id, "orders:read"):
raise Forbidden()
order = db.get_order(order_id)
if order.user_id != user_id and not is_admin(user_id):
raise Forbidden() # user can read orders but not this one
return order
This is Insecure Direct Object Reference (IDOR) — the #1 API authorization bug. Always verify the requester owns or has access to the specific resource, not just the resource type.
8. The Authorization Header
Always use the Authorization header, never query params:
✅ Authorization: Bearer <token>
✅ Authorization: Basic <base64>
✅ Authorization: AWS4-HMAC-SHA256 Credential=.../Signature=...
❌ GET /orders?token=... ← appears in logs, browser history, CDN logs
❌ GET /orders?api_key=... ← same problem
Bearer token: The word "Bearer" means "whoever holds (bears) this token gets access." It's specified in RFC 6750.
For API keys specifically, some teams use a custom header:
X-API-Key: sk_live_4eC39HqLyjWD...
This is fine. The important thing is: never in the URL.
9. How Real Systems Handle Auth
Stripe:
- API keys (Bearer token) for server-side
- Restricted keys with per-resource scopes
- Idempotency-Key header for safe retries
- Webhook signatures: HMAC-SHA256 of payload with your webhook secret — verify every webhook
GitHub:
- Personal Access Tokens (Bearer)
- OAuth Apps (Authorization Code flow)
- GitHub Apps (JWT signed with app private key, exchanged for installation token)
- Fine-grained PATs: specify exact repos and permissions
Google:
- OAuth 2.0 for user data (Authorization Code)
- Service Account keys (client credentials variant) for server-to-server
- API keys for public data (Maps, YouTube) with IP/referrer restrictions
AWS:
- SigV4: sign the entire request (method, URL, headers, body hash) with HMAC-SHA256
- No Bearer token — the signature IS the auth
- Time-bound: request valid only within 5 minutes (replay protection baked in)
10. Vocabulary Map
| Term | Definition |
|---|---|
| Authentication | Verifying identity: "Who are you?" |
| Authorization | Verifying permissions: "What can you do?" |
| API Key | Long random string identifying a client. Looked up in DB. |
| JWT | JSON Web Token. Self-contained token with signed claims. No DB lookup to verify. |
| Bearer token | Auth scheme where possession of the token grants access. Authorization: Bearer <token> |
| Access token | Short-lived token (15min–1hr) used on every API request. |
| Refresh token | Long-lived token (days–months) used only to get new access tokens. Stored in DB. |
| OAuth 2.0 | Authorization framework for delegated access. Not an auth protocol itself. |
| Authorization Code flow | OAuth flow for web/mobile apps. User authorizes → code → tokens (server-side exchange). |
| Client Credentials flow | OAuth flow for machine-to-machine. No user involved. |
| Scope | A string representing a specific permission: orders:read, payments:write. |
| RBAC | Role-Based Access Control. Users → roles → permissions. |
| ABAC | Attribute-Based Access Control. Permissions based on user/resource/environment attributes. |
| IDOR | Insecure Direct Object Reference. Accessing a resource you're not authorized for via its ID. |
| PKCE | Proof Key for Code Exchange. Extension to Authorization Code flow for mobile/SPAs (no client secret). |
| Claim | A key-value pair in a JWT payload: {"sub": "42", "role": "admin"}. |
| HS256 | HMAC-SHA256. Symmetric JWT signing — same key signs and verifies. |
| RS256 | RSA-SHA256. Asymmetric JWT signing — private key signs, public key verifies. |
| SameSite | Cookie attribute preventing it from being sent on cross-site requests. CSRF mitigation. |
| HttpOnly | Cookie attribute preventing JavaScript from reading it. XSS mitigation. |
11. Practice Problems
Problem 1 — Failure Analysis: Your API uses JWTs with a 24-hour expiry. A user's account is compromised — you detect it and delete their account at 9am. At 9pm (15 hours later), the attacker still has the user's JWT and is successfully calling your API with it. Why is this happening? What are the two standard mitigations, and what is the trade-off between them?
Problem 2 — Design From Scratch:
Design the complete auth system for a multi-tenant SaaS (like Slack): users belong to organizations, organizations have admins and members, some resources are org-scoped (channels), some are user-scoped (DMs). Define: token structure (what goes in the JWT payload), scope scheme, and the authorization check logic for GET /channels/{id}/messages for three different requester types: channel member, non-member in same org, admin of org, user from different org.
Problem 3 — Synthesis:
A mobile app (iOS/Android) wants to implement "Login with Google." Walk through the exact OAuth 2.0 flow, step by step. Why can't the mobile app use the standard Authorization Code flow with a client_secret? What extension (PKCE) does it use instead, and how does PKCE prevent authorization code interception attacks?
Problem 4 — Go Deeper: Your API issues JWT access tokens (RS256, 15-minute expiry) and refresh tokens (opaque, 30-day expiry, stored in DB). A refresh token is stolen. Design the complete token rotation and compromise detection system: what happens when the stolen refresh token is used after the legitimate user already used it? Trace through the exact sequence of events and DB state.
Problem 5 — Connect to Previous:
From Chapter 4, you know DELETE /users/42 should return 204 No Content. From this chapter, you know IDOR is the most common authorization bug. Write the complete pseudocode for the DELETE /users/{id} handler that correctly: (a) authenticates the request, (b) prevents IDOR (only the user themselves or an admin can delete), (c) returns the right status codes for every case (unauthenticated, unauthorized, not found, success). Use what you know about 401 vs 403 from Chapter 1.
What's Coming Next
You know who the user is and what they can do. Now: what happens when things go wrong?
Chapter 6 covers error handling — the second most important contract your API makes after the success response. How do you communicate "the email is invalid" vs "the user doesn't exist" vs "we're down" in a way that lets clients handle errors without hardcoding string parsing? We'll cover the RFC 7807 Problem Details standard, error code design, and the exact error structures used by Stripe, GitHub, and Twilio.