caduh

The OAuth2 Flow, Decoded — roles, tokens, and the PKCE dance

4 min read

A simple, diagram-first walkthrough of OAuth 2’s Authorization Code + PKCE flow—who does what (client, resource owner, authorization server, resource server), how the redirects work, and where tokens live.

TL;DR

  • OAuth 2 is delegated authorization: a client app gets permission (a token) from an Authorization Server (AS) to call a Resource Server (API) on behalf of the Resource Owner (user).
  • Use the Authorization Code + PKCE flow for browser SPAs, mobile, and desktop. Avoid the implicit flow.
  • The client never sees the user’s password; it only handles a short‑lived authorization code, then swaps it (with PKCE) for access/refresh tokens via a back‑channel.

Cast of roles (who’s who)

[Resource Owner]  The user granting access
[Client]          Your app (SPA/mobile/server) asking for access
[Authorization Server]  Login + consent + token issuer (IdP)
[Resource Server] API that holds the data (accepts access tokens)

OAuth 2 is about access to APIs. If you also need “who is the user?” for login, that’s OpenID Connect (OIDC): it adds an ID Token alongside access tokens.


The flow at a glance (Authorization Code + PKCE)

User/Browser                 Client App                 Authorization Server                 Resource Server
    │                            │                                  │                                  │
    │ 1. Click "Sign in"         │                                  │                                  │
    │──────────────────────────▶ │                                  │                                  │
    │                            │ 2. Redirect to /authorize with   │                                  │
    │                            │    client_id, redirect_uri,      │                                  │
    │                            │    scope, state, code_challenge  │                                  │
    │                            └────────────────────────────────▶ │                                  │
    │                            ◀──────────────────────────────────┘                                  │
    │ 3. User authenticates & consents                               │                                  │
    │                                                                │                                  │
    │                            ┌──────────────────────────────────▶│                                  │
    │                            │ 4. AS redirects back to client    │                                  │
    │ ◀──────────────────────────┘    redirect_uri?code=...&state=...│                                  │
    │                            │                                  │                                  │
    │ 5. Client exchanges code + code_verifier for tokens            │                                  │
    │                            ───────────────────────────────────▶│ /token                           │
    │                            ◀───────────────────────────────────┘  access_token (+ refresh, IDT)   │
    │                            │                                  │                                  │
    │ 6. Call API with access token (Authorization: Bearer …)        │                                  │
    │                            ──────────────────────────────────────────────────────────────────────▶│
    │                            ◀──────────────────────────────────────────────────────────────────────┘ data

PKCE (Proof Key for Code Exchange)

  • Client creates a random code_verifier and sends its hash (code_challenge) in step 2.
  • In step 5 the client must present the original code_verifier. This prevents a stolen code (via redirect interception) from being redeemed by an attacker.

What’s in the requests (minimal fields)

/authorize (front‑channel redirect)

response_type=code
client_id=YOUR_CLIENT_ID
redirect_uri=https://app.example.com/callback
scope=openid profile email api.read
state=RANDOM_CSRF_TOKEN
code_challenge=BASE64URL(SHA256(code_verifier))
code_challenge_method=S256

/token (back‑channel POST)

grant_type=authorization_code
code=AUTH_CODE_FROM_CALLBACK
redirect_uri=https://app.example.com/callback
client_id=YOUR_CLIENT_ID         # public clients use PKCE; confidential also send client_secret
code_verifier=ORIGINAL_RANDOM_STRING

API call

Authorization: Bearer ACCESS_TOKEN

Tokens (and where they live)

  • Access Token: presented to the API (audience = API). Short‑lived (minutes). Opaque or JWT.
  • Refresh Token: optionally returned; used to get a new access token without user interaction. Keep server‑side or in a secure store. Rotate on use.
  • ID Token (OIDC): JWT that tells the client who logged in. Not used by the API for access control.

Storage tips

  • Browsers: prefer a BFF pattern (server mediates tokens; store session in HTTP‑only cookies). Avoid long‑lived tokens in localStorage.
  • Native apps: use OS keystore (Keychain/Keystore).
  • Backends: store minimal session, validate tokens on each request.

Why state and PKCE matter

  • state binds the callback to the initiating request to prevent CSRF and mix‑up attacks. Validate it on return.
  • PKCE protects public clients that can’t safely keep a client secret (SPAs/mobile). It makes code theft useless without the verifier.

Which flow when (super short)

| Scenario | Use this grant | |---|---| | Browser SPA, mobile, desktop | Authorization Code + PKCE | | Server‑to‑server (no user) | Client Credentials (no refresh tokens) | | TV/Console devices | Device Code | | Long‑lived sessions | Authorization Code + Refresh Token (rotation) |


Minimal pseudo‑code (client + server)

Client (SPA)

// 1) Build authorize URL with state + code_challenge; redirect the browser
location.href = authUrl({
  response_type: "code",
  client_id, redirect_uri, scope, state, code_challenge, code_challenge_method: "S256"
});
// 2) On /callback, send ?code to your backend (BFF) along with state

Backend (BFF)

// POST /callback
assert(req.body.state === session.state);
const tokenSet = await POST("/token", {
  grant_type: "authorization_code",
  code: req.body.code,
  redirect_uri,
  client_id,
  code_verifier: session.code_verifier
});
// Set HTTP-only session cookie; do NOT expose refresh tokens to JS

API call (backend → resource server)

const res = await fetch(API, { headers: { Authorization: `Bearer ${accessToken}` } })

Common pitfalls & fast fixes

| Problem | Why it happens | Fix | |---|---|---| | Using implicit flow | Legacy; exposes tokens in URLs | Use Auth Code + PKCE | | Missing state / not validated | CSRF/mix‑up possible | Generate random state; validate on callback | | Tokens in localStorage | XSS exfiltration | Use HTTP‑only cookies / BFF; keep tokens server‑side | | Unvalidated JWTs at API | Accepts forged/foreign tokens | Verify signature, issuer (iss), audience (aud), expiry | | Over‑broad scopes | Excess privileges | Request minimal scopes per feature | | Long‑lived access tokens | Bigger blast radius | Short TTL + refresh rotation + revocation | | Replaying 0‑RTT requests | QUIC/TLS 1.3 nuance | Keep state‑changing endpoints idempotent or disallow 0‑RTT |


Quick checklist

  • [ ] Use Authorization Code + PKCE (no implicit).
  • [ ] Include state and validate it on return.
  • [ ] Keep tokens server‑side (BFF) or in secure stores; never expose refresh tokens to JS.
  • [ ] Validate tokens at APIs (iss/aud/exp/sig).
  • [ ] Request least‑privilege scopes; rotate refresh tokens.
  • [ ] Use HTTPS end‑to‑end; set redirect URIs exactly (no wildcards).

One‑minute adoption plan

  1. Register your app with exact redirect URI(s) and required scopes.
  2. Implement Auth Code + PKCE with state; use a well‑supported SDK.
  3. Put a BFF in front of SPAs to keep tokens in HTTP‑only cookies.
  4. Validate tokens at the API; log sub, scope, jti for audits.
  5. Turn on refresh rotation and revoke on suspicious use.