14 min read

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:

  1. Who is making this request? (Authentication)
  2. 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:

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

code
GET /charges HTTP/1.1
Authorization: Bearer sk_live_4eC39HqLyjWDarjtT1zdp7dc

Or sometimes as a query param (avoid — gets logged):

code
GET /data?api_key=sk_live_4eC39HqLyjWDarjtT1zdp7dc

How it works:

code
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:

code
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:

python
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

code
Authorization: Basic dHJpbG9jaGFuOnBhc3N3b3Jk

The value after Basic is Base64(username:password).

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


Before JWT, this was the standard for web applications:

code
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:

sql
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:

code
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:

code
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:

json
{
  "alg": "RS256",   ← signing algorithm
  "typ": "JWT"
}

Payload (claims):

json
{
  "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:

code
RS256_sign(
  base64url(header) + "." + base64url(payload),
  private_key
)

5.2 How JWT Validation Works

code
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

AlgorithmTypeKeyUse Case
HS256SymmetricSingle shared secretSingle service (same service signs and verifies)
RS256AsymmetricPrivate key signs, public key verifiesMultiple services (auth service signs, any service verifies)
ES256Asymmetric (ECDSA)Smaller keys, fasterMobile, 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.

code
❌ 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.

code
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

RoleExample
Resource OwnerThe user (you)
ClientThe third-party app wanting access
Authorization ServerGoogle's auth server (issues tokens)
Resource ServerGmail 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.

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

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

code
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:

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

code
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:

sql
CREATE TABLE roles (id, name);
CREATE TABLE role_permissions (role_id, permission);
CREATE TABLE user_roles (user_id, role_id);

On each request:

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

code
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?

python
# ❌ 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:

code
✅ 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:

code
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

TermDefinition
AuthenticationVerifying identity: "Who are you?"
AuthorizationVerifying permissions: "What can you do?"
API KeyLong random string identifying a client. Looked up in DB.
JWTJSON Web Token. Self-contained token with signed claims. No DB lookup to verify.
Bearer tokenAuth scheme where possession of the token grants access. Authorization: Bearer <token>
Access tokenShort-lived token (15min–1hr) used on every API request.
Refresh tokenLong-lived token (days–months) used only to get new access tokens. Stored in DB.
OAuth 2.0Authorization framework for delegated access. Not an auth protocol itself.
Authorization Code flowOAuth flow for web/mobile apps. User authorizes → code → tokens (server-side exchange).
Client Credentials flowOAuth flow for machine-to-machine. No user involved.
ScopeA string representing a specific permission: orders:read, payments:write.
RBACRole-Based Access Control. Users → roles → permissions.
ABACAttribute-Based Access Control. Permissions based on user/resource/environment attributes.
IDORInsecure Direct Object Reference. Accessing a resource you're not authorized for via its ID.
PKCEProof Key for Code Exchange. Extension to Authorization Code flow for mobile/SPAs (no client secret).
ClaimA key-value pair in a JWT payload: {"sub": "42", "role": "admin"}.
HS256HMAC-SHA256. Symmetric JWT signing — same key signs and verifies.
RS256RSA-SHA256. Asymmetric JWT signing — private key signs, public key verifies.
SameSiteCookie attribute preventing it from being sent on cross-site requests. CSRF mitigation.
HttpOnlyCookie 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.