2.2 — Authorization Code + PKCE: The Workhorse

Goal: Implement the authorization code grant from scratch (in 80 lines) and explain every redirect, every parameter, and every security check. Know what PKCE is, why it was originally for mobile/SPA, and why it’s now mandatory for ALL clients.

Prerequisites: Stage 2.1 + Stage 1 complete.


Table of Contents

  1. Why This Flow Dominates
  2. The Flow: 7 Steps in Detail
  3. The state Parameter: CSRF Defense
  4. PKCE: The Defense Against Code Interception
  5. PKCE Mechanics: code_verifier, code_challenge, S256
  6. PKCE for Confidential Clients (Yes, Even Server Apps)
  7. The Token Endpoint: Server-to-Server
  8. What the Token Response Looks Like
  9. The Refresh Token Flow
  10. Error Responses
  11. Code: A Working Implementation
  12. DevOps Analogy: The Coat Check
  13. Attacks & Pitfalls
  14. Exercises
  15. Next Step

1. Why This Flow Dominates

Five OAuth 2.0 grant types exist. One rules them all.

authorization_code         ← 90%+ of OAuth in production
client_credentials         ← machine-to-machine
device_code                ← input-constrained devices (TVs, CLI)
refresh_token              ← extension of another grant
(removed in 2.1)
implicit                   ← was used by SPAs in 2014-2020
password (ROPC)            ← was used by first-party apps in 2012-2018

Why the auth code grant wins:

✓ Works for any client type (web, mobile, native, server-side)
✓ The token is delivered server-to-server, never in the URL
✓ PKCE defends against code interception
✓ Refresh tokens for long sessions, no password sharing
✓ Compatible with FAPI 2.0, OAuth 2.1, every modern spec
✓ Industry standard, every IdP supports it

Why the other grants lose:

implicit:      token in URL fragment, vulnerable to leakage
password:      user's password goes to the client app
client_creds:  no user — only for machine identity
device_code:   UX is clunky (user has to type a code on a phone)

If you’re starting new in 2026 and you have a user involved, use authorization code + PKCE. Don’t think. Don’t debate. Use it.


2. The Flow: 7 Steps in Detail

The cast, again:

RO  = the user (logged in at the IdP, e.g., Google)
C   = the client app (e.g., a Node.js server)
AS  = the authorization server (e.g., Google's OAuth)
RS  = the resource server (e.g., Google's API)

The flow, in detail:

Step 1: User initiates

The user is at the client app and clicks "Sign in with IdP" or
"Connect my account."

Client decides to start the OAuth flow.

Step 2: Client generates state + PKCE values, redirects

The client generates:
  - state = random nonce (32+ bytes, base64url)
  - code_verifier = random string (43-128 chars, base64url-safe)
  - code_challenge = base64url(sha256(code_verifier))
  - code_challenge_method = "S256"

The client stores state and code_verifier in the user's session
(must survive the redirect round trip).

The client redirects the user's browser to the AS's authorization endpoint:
  HTTP 302 Found
  Location: https://idp.example.com/authorize
    ?response_type=code
    &client_id=CLIENT_ID
    &redirect_uri=https://app.example.com/callback
    &scope=openid+profile+email+https://api.example.com/read
    &state=STATE_VALUE
    &code_challenge=CODE_CHALLENGE_VALUE
    &code_challenge_method=S256

What each parameter means:

ParameterRequiredMeaning
response_type=codeYes”I want the authorization code flow”
client_idYesThe IdP’s identifier for this app
redirect_uriYesWhere the IdP sends the user back. MUST be pre-registered.
scopeYesWhat the client wants. Space-separated.
stateRecommended (effectively required)CSRF defense. Client-generated nonce.
code_challengePKCE required (per OAuth 2.1)Hashed secret, ties the request to the token exchange
code_challenge_methodRequired if code_challenge presentAlmost always S256 (SHA-256)

Step 3: User authenticates at the IdP

The IdP checks if the user has an active session. If not, prompts
for credentials (username + password, MFA, passkey, etc.).

This step is entirely up to the IdP. OAuth 2.0 doesn't specify
how authentication works — that's the IdP's job.

Step 4: User grants (or denies) the requested scopes

The IdP shows a consent screen:
  "App X wants to:
   ✓ Read your email
   ✓ Read your calendar

   [Allow]  [Deny]"

If the user clicks Deny, the IdP redirects back with an error.
If Allow, the IdP generates a short-lived authorization code.

Step 5: IdP redirects back to the client

HTTP 302 Found
Location: https://app.example.com/callback
  ?code=AUTHORIZATION_CODE
  &state=STATE_VALUE   ← the SAME value the client sent

The authorization code is:
  - Short-lived (typically 30 seconds to 10 minutes)
  - Single-use (consumed at the token endpoint)
  - Bound to the client_id, redirect_uri, code_challenge
  - Tied to the user who granted permission

Step 6: Client exchanges code for tokens (server-to-server)

The client's BACKEND (not the browser) POSTs to the IdP's token endpoint:

POST https://idp.example.com/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>  ← for confidential clients

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://app.example.com/callback   ← SAME as in step 2
&client_id=CLIENT_ID
&code_verifier=CODE_VERIFIER_VALUE               ← the ORIGINAL value, not the hash

For public clients (no secret):

POST https://idp.example.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://app.example.com/callback
&client_id=CLIENT_ID
&code_verifier=CODE_VERIFIER_VALUE

No Authorization: Basic header. The code_verifier proves client identity (PKCE is the auth mechanism for public clients).

Step 7: IdP returns tokens

HTTP 200 OK
Content-Type: application/json

{
  "access_token": "ya29.a0AfH6SMB...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "1//09GxvXH...",
  "scope": "openid profile email https://api.example.com/read",
  "id_token": "eyJhbGciOiJSUzI1NiIs..."   ← only present if openid scope was requested
}

The client now has:

  • access_token — for API calls
  • refresh_token — for getting new access tokens
  • id_token (if OIDC) — for user identity

The complete sequence diagram:

     User              Client App            IdP (AS)              API (RS)
      │                     │                    │                     │
      │ 1. Click login      │                    │                     │
      ├────────────────────►│                    │                     │
      │                     │ 2. Generate state+PKCE                   │
      │                     │ 3. Redirect to /authorize                │
      │◄────────────────────┤                    │                     │
      │                                              │                 │
      │ 4. User authenticates, grants permission     │                 │
      ├─────────────────────────────────────────────►│                 │
      │                                              │                 │
      │ 5. Redirect back to /callback?code=...&state=...              │
      │◄─────────────────────────────────────────────┤                 │
      │                     │                    │                     │
      │ 6. Browser follows redirect                │                     │
      ├────────────────────►│                    │                     │
      │                     │ 7. POST /token (code + verifier)         │
      │                     ├───────────────────►│                     │
      │                     │                    │                     │
      │                     │ 8. {access_token, refresh_token, id_token}│
      │                     │◄───────────────────┤                     │
      │                     │                                            │
      │ 9. App uses access_token                                            │
      │                     ├──────────────────────────────────────────►│
      │                     │                                            │
      │                     │ 10. {user data}                            │
      │                     │◄───────────────────────────────────────────┤

3. The state Parameter: CSRF Defense

The threat: an attacker tricks the user into completing an OAuth flow with the attacker’s account.

Attack without state (CSRF on the callback):
  1. Alice is logged into IdP
  2. Attacker starts an OAuth flow, gets an auth code bound to attacker's account
  3. Attacker tricks Alice's browser into visiting:
     https://app.example.com/callback?code=ATTACKER_CODE
  4. Alice's app receives the code, exchanges it for a token bound to ATTACKER
  5. Alice's app now thinks Alice is logged in... as Attacker
  6. Alice adds her data to what is now Attacker's account
  7. Attacker reads Alice's data through their own account

The defense (state):

Client (before redirect):
  state = base64url(random_bytes(32))   ← unique per request
  store state in user's session

Client (on callback):
  expected_state = session.get("oauth_state")
  received_state = request.args.get("state")
  if not hmac.compare_digest(expected_state, received_state):
      raise InvalidStateError

  if state matches:
      session.pop("oauth_state")
      proceed with token exchange

  if state doesn't match:
      abort. probably a CSRF attempt.

Why it works:

Attacker needs to predict the state value the client will use.
State is 32+ random bytes. Unpredictable.
Attacker can't complete the attack without knowing the state.

Modern libraries do this for you. But verify they do.

Code: the state handling

import secrets
import hmac
 
def generate_state() -> str:
    return secrets.token_urlsafe(32)
 
def verify_state(received: str, expected: str) -> bool:
    return hmac.compare_digest(received, expected)

What if I skip state?

You become vulnerable to OAuth CSRF. An attacker can log you in
to their account, see your data get added to it, and effectively
hijack your session.

This is not theoretical. It's been used in real attacks.

4. PKCE: The Defense Against Code Interception

The threat: an attacker steals the authorization code from the redirect.

When can this happen?

1. Mobile app with a custom URL scheme (e.g., myapp://callback)
   - Another app on the device can register the same scheme
   - Or a malicious app intercepts the redirect

2. Native app using a loopback redirect
   - Localhost listener on the device
   - Other apps on the device can listen on the same port

3. Single-page app with a wildcard redirect
   - The redirect_uri is too permissive
   - Attacker can register a similar URI

4. Network MITM
   - If the redirect happens over HTTP (it shouldn't, but bugs happen)

5. Log injection
   - If the callback URL ends up in server logs
   - Attacker reads the logs, extracts the code

The defense (PKCE):

Before redirecting, the client:
  1. Generates code_verifier (random string, 43-128 chars)
  2. Computes code_challenge = base64url(sha256(code_verifier))
  3. Sends code_challenge to the IdP (in the auth request)
  4. Stores code_verifier in the session

When exchanging the code for tokens, the client:
  5. Sends code_verifier to the IdP (in the token request)
  6. IdP computes sha256(code_verifier) and compares to code_challenge
  7. If they match, the IdP knows the request came from the same client
  8. If they don't match, the code is rejected

Result: an attacker who steals the code (without the verifier) can’t exchange it for tokens.

Why this works

Attacker scenario: steals the code from the redirect.

Attacker tries to exchange it:
  POST /token
    code=STOLEN_CODE
    code_verifier=?  ← attacker doesn't know this

IdP:
  received verifier: (empty or wrong)
  stored challenge: hash(some_real_verifier)
  hash(received) != stored challenge
  REJECT.

The code is useless without the verifier.

The code_verifier is 43-128 characters of base64url-safe random data. That’s 256+ bits of entropy. Unguessable.

Why this is better than just a client_secret

Client secret: known to the client. If the client is malicious or
               compromised, the secret is exposed. Can't tell the
               difference between "real client" and "attacker who
               got the secret."

PKCE: bound to ONE specific auth flow. Even if the attacker gets
      a PKCE code_challenge from one request, they can't reuse it
      for a different request. Each request has its own verifier.
      No "secret" in the traditional sense — it's a per-request
      proof.

5. PKCE Mechanics: code_verifier, code_challenge, S256

The verifier

import secrets
 
# 43-128 characters from the unreserved set
# [A-Z] [a-z] [0-9] - . _ ~
code_verifier = secrets.token_urlsafe(64)
print(code_verifier)
# 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
# (43 characters, ~256 bits of entropy)

Length requirements (RFC 7636):

Minimum length: 43 characters
Maximum length: 128 characters
Allowed chars:  [A-Z] [a-z] [0-9] - . _ ~
Recommended:    64 random bytes → base64url = 86 chars (well within limits)

The challenge

import hashlib
import base64
 
def make_code_challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
 
code_challenge = make_code_challenge(code_verifier)
print(code_challenge)
# 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'

The challenge method

S256 (recommended):    code_challenge = base64url(sha256(code_verifier))
                       Strong. Use this. The default for OAuth 2.1.

plain (don't use):     code_challenge = code_verifier
                       Legacy. The verifier IS the challenge.
                       Some old servers still support it.
                       Vulnerable to verifier leakage in logs.
                       NEVER use if you control the client.

The math

You have:
  code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

You compute:
  sha256(code_verifier) = 0x7c1d3... (32 bytes)

You base64url-encode (no padding):
  base64url(0x7c1d3...) = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

You send the challenge to the IdP at auth time.
You send the verifier to the IdP at token time.
The IdP hashes the verifier and checks it matches the challenge.

Why S256 is the only safe choice

The verifier is the secret. If you use "plain", the verifier is
also the challenge, which means the challenge (sent in the
auth URL) IS the verifier. Anyone who sees the auth URL has
the verifier.

If the auth URL ends up in:
  - Browser history
  - Server logs at the IdP
  - Browser Referer header
  - Network capture
... the attacker has the verifier. The PKCE is broken.

S256: the challenge is the HASH of the verifier. The verifier
is never sent in the auth URL. The attacker has the hash.
Hashes are one-way — can't recover the verifier from the hash.

6. PKCE for Confidential Clients (Yes, Even Server Apps)

OAuth 2.1 says: PKCE is REQUIRED for ALL clients using the authorization code grant. Not just public clients.

Why?

For confidential clients (server-side apps with a client_secret):
  - The client_secret authenticates the client
  - PKCE additionally binds the auth request to the token exchange
  - If the client_secret leaks, PKCE still defends against code interception

For public clients (SPAs, mobile apps):
  - No client_secret
  - PKCE IS the client authentication
  - The client proves "I started this flow" via the verifier

Either way: PKCE always provides defense-in-depth.

The “PKCE is for mobile” misconception:

In 2015, PKCE was introduced as RFC 7636. It was recommended for
public clients (mobile, native) because they can't keep secrets.

Around 2019-2022, the OAuth working group realized:
  1. PKCE is also good for confidential clients
  2. The cost is low (32 bytes of randomness + a SHA-256)
  3. The benefit is high (defense against code interception)
  4. Implementing it once for all clients is simpler than
     maintaining two paths

OAuth 2.1: PKCE is REQUIRED for the authorization code grant.
No exceptions. Even for confidential clients with a client_secret.

Implementation for a confidential client:

# Step 2: Auth request
auth_url = (
    f"https://idp.example.com/authorize"
    f"?response_type=code"
    f"&client_id={CLIENT_ID}"
    f"&redirect_uri={REDIRECT_URI}"
    f"&scope={SCOPES}"
    f"&state={state}"
    f"&code_challenge={code_challenge}"         ← PKCE
    f"&code_challenge_method=S256"
)
 
# Step 6: Token request
token_request = {
    "grant_type": "authorization_code",
    "code": code,
    "redirect_uri": REDIRECT_URI,
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,             ← confidential client
    "code_verifier": code_verifier,             ← PKCE
}

You have BOTH client_secret AND PKCE. Defense in depth.


7. The Token Endpoint: Server-to-Server

The token endpoint is the only place tokens are issued (and the only place the client_secret is used). It’s a server-to-server POST — never from the browser.

Request

POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>
 
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=CODE_VERIFIER_VALUE

For public clients (no Authorization header):

POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
 
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=CLIENT_ID
&code_verifier=CODE_VERIFIER_VALUE

Authentication methods at the token endpoint

1. client_secret_basic (most common for confidential clients)
   - HTTP Basic auth: Authorization: Basic base64(client_id:client_secret)

2. client_secret_post (sometimes required by old IdPs)
   - In the form body: client_id=...&client_secret=...

3. client_secret_jwt (signed JWT, more secure)
   - Client signs a JWT with the secret as the key
   - Sends the JWT as client_assertion

4. private_key_jwt (signed with the client's RSA/ECDSA private key)
   - Most secure. No shared secret. Client has a key pair.
   - Used in FAPI 2.0, B2B scenarios.

5. none (public clients)
   - No client authentication. PKCE is the auth.

For confidential clients, prefer (1) or (4). (2) puts the secret in the request body, which is more likely to end up in logs. (3) is fine but obscure.

Code: the token request

import requests
import base64
 
def exchange_code_for_token(code: str, code_verifier: str,
                            redirect_uri: str,
                            client_id: str, client_secret: str,
                            token_endpoint: str) -> dict:
    """
    Exchange an authorization code for tokens.
    Returns: { access_token, refresh_token, id_token, expires_in, ... }
    """
    # For confidential clients: HTTP Basic auth
    auth = (client_id, client_secret)
 
    response = requests.post(
        token_endpoint,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": redirect_uri,
            "code_verifier": code_verifier,
        },
        auth=auth,
        timeout=10,
    )
 
    if response.status_code != 200:
        # Try to extract error
        try:
            err = response.json()
            raise TokenExchangeError(
                f"token endpoint error: {err.get('error')}",
                err.get("error_description", ""),
            )
        except ValueError:
            raise TokenExchangeError(
                f"token endpoint HTTP {response.status_code}: {response.text}"
            )
 
    return response.json()

8. What the Token Response Looks Like

Success (HTTP 200)

{
  "access_token": "ya29.a0AfH6SMBx-...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "1//09GxvXHXv...",
  "scope": "openid profile email https://api.example.com/read",
  "id_token": "eyJhbGciOiJSUzI1NiIs..." // only if openid scope requested
}

Field meanings:

FieldRequired?Meaning
access_tokenYesThe token to use at the API
token_typeYesAlmost always “Bearer”
expires_inRecommendedSeconds until access_token expires
refresh_tokenOptionalFor getting a new access_token. Some flows don’t issue.
scopeRecommendedThe scopes actually granted (may be a subset of requested)
id_tokenOIDC onlyThe user’s identity (covered in Stage 3)

Variations

- Some IdPs return the access_token in a JWT format (with alg, kid, etc.)
  You can decode it, but you don't have to.

- Some IdPs return opaque tokens (random strings)
  You can't decode them. You use introspection (2.5) to validate.

- Some IdPs use a "scope" claim (space-separated string)
  Some use a "scp" claim (array of strings)
  This is normal. Both are common.

The expires_in trap

If the IdP returns expires_in=3600:
  - The access_token expires 3600 SECONDS from now
  - Some clients compute absolute exp: now() + 3600
  - But the server's clock might be different
  - In practice: refresh slightly EARLY (e.g., 5 min before exp)
  - To handle clock skew: refresh at expires_in - 60 seconds

9. The Refresh Token Flow

The client has an access_token. It expires. Now what?

Client: "I need a new access token. Here's my refresh token."

POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

grant_type=refresh_token
&refresh_token=REFRESH_TOKEN_VALUE
&scope=openid+profile+email  ← optional, request a subset

Response (success):

{
  "access_token": "ya29.a0AfH6SMBx-...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "1//09GxvXHXv..."   ← NEW one (rotation)
}

Important details:

1. The IdP may issue a NEW refresh_token. The old one is now invalid.
   This is "refresh token rotation" — best practice.

2. The scope parameter is OPTIONAL. If present, it must be a subset
   of the originally granted scopes. The IdP may reject if you ask
   for more than originally granted.

3. Some IdPs return the same refresh_token (no rotation). Less secure.
   Always prefer rotation.

4. The new access_token may have a different expires_in than the
   original. Some IdPs shorten the lifetime with each refresh.

Reuse detection (covered in detail in 1.4):

If the old refresh_token is presented again (after rotation):
  → The IdP revokes the entire token family
  → User is logged out everywhere
  → Re-auth required

This is the "refresh token theft detection" pattern.

Code: refresh

def refresh_access_token(refresh_token: str, client_id: str,
                          client_secret: str, token_endpoint: str) -> dict:
    response = requests.post(
        token_endpoint,
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
        },
        auth=(client_id, client_secret),
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

10. Error Responses

The IdP redirects back with an error

HTTP 302 Found
Location: https://app.example.com/callback
  ?error=access_denied
  &error_description=The+user+denied+the+request
  &state=STATE_VALUE

Common error codes (RFC 6749 Section 4.1.2.1):

ErrorMeaningWhen
invalid_requestMalformed requestMissing required parameter, invalid value, etc.
invalid_clientClient auth failedBad client_id/client_secret, unregistered client
invalid_grantThe grant is invalidCode expired, code already used, PKCE failed, etc.
unauthorized_clientClient not authorized for this grante.g., trying to use auth code when only client_creds is allowed
unsupported_grant_typeGrant type not supportede.g., device code on an IdP that doesn’t support it
invalid_scopeRequested scope is invalid/unknownTypo in scope name
access_deniedUser denied consentUser clicked “Deny” on the consent screen
server_errorIdP internal errorSomething broke on the IdP side
temporarily_unavailableIdP overloadedTry again later

The token endpoint returns an error (HTTP 400 or 401)

{
  "error": "invalid_grant",
  "error_description": "Authorization code expired"
}

Error handling in the client

def handle_callback(request):
    # Check for error first
    if "error" in request.args:
        error = request.args["error"]
        description = request.args.get("error_description", "")
        # Don't auto-retry on access_denied
        if error == "access_denied":
            return redirect("/login?msg=user_denied_consent")
        if error == "invalid_request":
            return redirect("/login?msg=bad_request")
        # ... handle other errors
        log.warning(f"OAuth error: {error} - {description}")
        return redirect("/login?msg=auth_failed")
 
    code = request.args.get("code")
    state = request.args.get("state")
 
    # Verify state
    if not verify_state(state, session.pop("oauth_state")):
        log.warning("State mismatch — possible CSRF")
        return redirect("/login?msg=state_mismatch")
 
    # Exchange code for tokens
    try:
        tokens = exchange_code_for_token(...)
    except TokenExchangeError as e:
        log.error(f"Token exchange failed: {e}")
        return redirect("/login?msg=token_exchange_failed")
 
    # Store tokens
    session["access_token"] = tokens["access_token"]
    session["refresh_token"] = tokens.get("refresh_token")
    # ... etc

11. Code: A Working Implementation

A minimal but complete authorization code + PKCE flow in 80 lines.

"""
Minimal OAuth 2.0 authorization code + PKCE flow.
For a real app, use a library (authlib, oauthlib, etc.).
This is the educational version.
"""
import secrets
import hashlib
import base64
import requests
import hmac
from flask import Flask, request, redirect, session, jsonify
 
app = Flask(__name__)
app.secret_key = secrets.token_bytes(32)
 
# --- Configuration ---
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"  # confidential client
REDIRECT_URI = "http://localhost:5000/callback"
AUTH_ENDPOINT = "https://idp.example.com/authorize"
TOKEN_ENDPOINT = "https://idp.example.com/token"
SCOPES = "openid profile email"
 
# --- PKCE helpers ---
def generate_code_verifier() -> str:
    return secrets.token_urlsafe(64)  # 86 chars
 
def derive_code_challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
 
def generate_state() -> str:
    return secrets.token_urlsafe(32)
 
# --- Step 1: Initiate the flow ---
@app.route("/login")
def login():
    # Generate PKCE pair
    code_verifier = generate_code_verifier()
    code_challenge = derive_code_challenge(code_verifier)
    state = generate_state()
 
    # Store in session (must survive the redirect round trip)
    session["code_verifier"] = code_verifier
    session["oauth_state"] = state
 
    # Build authorization URL
    auth_url = (
        f"{AUTH_ENDPOINT}"
        f"?response_type=code"
        f"&client_id={CLIENT_ID}"
        f"&redirect_uri={REDIRECT_URI}"
        f"&scope={SCOPES}"
        f"&state={state}"
        f"&code_challenge={code_challenge}"
        f"&code_challenge_method=S256"
    )
    return redirect(auth_url)
 
# --- Step 2: Handle the callback ---
@app.route("/callback")
def callback():
    # Check for error
    if "error" in request.args:
        return jsonify({
            "error": request.args["error"],
            "description": request.args.get("error_description", "")
        }), 400
 
    code = request.args.get("code")
    state = request.args.get("state")
 
    # Verify state (CSRF defense)
    expected_state = session.pop("oauth_state", None)
    if not state or not hmac.compare_digest(state, expected_state or ""):
        return jsonify({"error": "state_mismatch"}), 400
 
    # Exchange code for tokens (server-to-server)
    code_verifier = session.pop("code_verifier")
    try:
        resp = requests.post(
            TOKEN_ENDPOINT,
            data={
                "grant_type": "authorization_code",
                "code": code,
                "redirect_uri": REDIRECT_URI,
                "code_verifier": code_verifier,
            },
            auth=(CLIENT_ID, CLIENT_SECRET),
            timeout=10,
        )
        resp.raise_for_status()
        tokens = resp.json()
    except requests.RequestException as e:
        return jsonify({"error": "token_exchange_failed", "detail": str(e)}), 500
 
    # Store tokens (in real app, securely — encrypted, HttpOnly cookie, etc.)
    session["access_token"] = tokens["access_token"]
    session["refresh_token"] = tokens.get("refresh_token")
 
    return redirect("/me")
 
# --- Step 3: Use the access token ---
@app.route("/me")
def me():
    access_token = session.get("access_token")
    if not access_token:
        return redirect("/login")
 
    # Call the resource server
    resp = requests.get(
        "https://api.example.com/me",
        headers={"Authorization": f"Bearer {access_token}"},
        timeout=10,
    )
    return jsonify(resp.json())
 
# --- Step 4: Refresh the access token ---
@app.route("/refresh")
def refresh():
    refresh_token = session.get("refresh_token")
    if not refresh_token:
        return jsonify({"error": "no_refresh_token"}), 400
 
    try:
        resp = requests.post(
            TOKEN_ENDPOINT,
            data={
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
            },
            auth=(CLIENT_ID, CLIENT_SECRET),
            timeout=10,
        )
        resp.raise_for_status()
        tokens = resp.json()
    except requests.RequestException as e:
        return jsonify({"error": "refresh_failed", "detail": str(e)}), 500
 
    session["access_token"] = tokens["access_token"]
    if "refresh_token" in tokens:
        session["refresh_token"] = tokens["refresh_token"]  # rotated
 
    return jsonify({"status": "refreshed"})
 
if __name__ == "__main__":
    app.run(debug=True, port=5000)

That’s the entire flow in 80 lines. A real library does it better (handles errors, retries, JWKS, validation, etc.) but these 80 lines are the core.


12. DevOps Analogy: The Coat Check

Imagine an opera. You arrive in a fur coat. You check it at the coat check.

You (RO)        = the opera-goer with the coat
Coat check (AS) = the coat check counter
Theater (C)     = the venue giving you the check ticket
Fur coat (RS)   = your actual coat (the resource you own)

Without OAuth (the bad old way):
  1. You give the coat to the theater, hoping they hold it for you
  2. The theater has your coat, can do anything with it
  3. To get it back, you have to identify yourself and ask

With OAuth:
  1. The coat check gives you a numbered ticket (the access token)
  2. The ticket has your coat number on it, plus the time you'll
     pick it up (the scope and lifetime)
  3. The coat check can verify the ticket at any time
  4. If you lose the ticket, you can call the coat check, prove who
     you are, and have the ticket canceled (revocation)
  5. The theater never touches your coat directly

Where PKCE fits:

The ticket has a special mark on it that only the coat check knows
about (the code_challenge). When you present the ticket, you also
have to say a secret phrase (the code_verifier) that matches the
mark. If someone steals the ticket, they don't know the phrase —
the ticket is useless.

Without PKCE: stolen ticket = thief gets your coat
With PKCE: stolen ticket = thief has the ticket but can't prove
           they have the secret phrase = no coat

Where the state parameter fits:

The ticket has a number. But the number was generated when YOU
arrived. The coat check remembers: "this number was issued for
this person at this time." If the thief tries to use a ticket
they got from somewhere else, the number doesn't match the arrival
record. Rejected.

13. Attacks & Pitfalls

A1. Missing state (CSRF on the callback)

Threat: covered in Section 3. Without state, an attacker can
log a victim into the attacker's account, then harvest data
the victim adds.

Fix: always generate a random state, store it, verify on callback.
Most libraries do this. Verify yours.

A2. Missing PKCE (code interception)

Threat: covered in Section 4. An attacker intercepts the auth
code in the redirect and exchanges it for tokens.

Fix: always use PKCE. Even for confidential clients. Always S256.

A3. Open redirect via redirect_uri mismatch

The client app passes redirect_uri=... in the auth request.
The IdP must validate this against the pre-registered list.
EXACT match. Not prefix. Not pattern.

Common bug: the IdP uses prefix matching
  Allowed: https://app.example.com/*
  Request: https://app.example.com.evil.com/callback
  Match: yes (prefix)
  Result: open redirect, phishing opportunity.

Fix: exact string match. No wildcards. No prefixes.

A4. Storing the code_verifier insecurely

The code_verifier is sensitive. If the attacker has it AND the
code, they can exchange the code for tokens.

Where to store:
  ✓ Server-side session (cookie + server state)
  ✓ Encrypted local storage (less ideal, but OK if encrypted)
  ✗ localStorage (XSS-stealable)
  ✗ URL (logged everywhere)
  ✗ Client-side sessionStorage (lost on tab close — bad for the flow)

A5. Token endpoint hitting a wrong URL

The IdP has multiple environments: dev, staging, prod.
If the client is configured for staging but the user logged into
prod (or vice versa), the token exchange fails.

Mitigation:
  - Use the same environment for auth + token endpoints
  - Validate the iss claim in the response (Stage 3 covers this for OIDC)
  - Use distinct client_ids per environment

A6. Refresh token without rotation

Threat: refresh token is stolen. Attacker can keep using it
because it's not invalidated on use.

Fix: rotate refresh tokens (covered in 1.4). Reuse detection.
The IdP issues a new refresh_token on every refresh. The old one
is now invalid. Reuse → revoke the family.

A7. Using code parameter in production URLs

The auth code in the URL (?code=ABC123) is a one-time use credential.
If the user reloads the page, hits back, or shares the URL, the
code is exposed.

Mitigation:
  - Code expires fast (30 sec to 10 min)
  - Code is single-use
  - Don't log the URL with the code
  - Don't share the URL
  - Bind to the client_id and redirect_uri

A8. Mismatched redirect_uri between auth and token requests

Auth request: redirect_uri=https://app.example.com/callback
Token request: redirect_uri=https://APP.example.com/CALLBACK  ← case differs

The IdP checks: do these match? No. Reject.

Common bugs:
  - Trailing slash: /callback vs /callback/
  - HTTP vs HTTPS
  - Path case sensitivity
  - Query parameters

Fix: use a constant in your code. Compare exactly. Don't recompute.

A9. Not validating the iss in the response

If the IdP supports multiple environments (dev, staging, prod) and
your client gets a token from the wrong one, you've got a problem.

The token response doesn't have a JWT, so no iss claim. For opaque
tokens, the only defense is to validate the token endpoint URL.
For JWT access tokens, check the iss claim. (RFC 9068.)

A10. Implicit grant in legacy code

Old code: response_type=token
The token comes back in the URL fragment. The whole grant is
deprecated. Migrate to authorization code + PKCE.

If you find this in your codebase:
  1. Mark it as deprecated
  2. Migrate to authorization code + PKCE
  3. Test in staging
  4. Roll out
  5. Remove the implicit code path

A11. The “secret in mobile app” footgun

Mobile app developers sometimes embed the client_secret in the
mobile app's code. Then the secret is extractable by anyone with
the APK or IPA.

This is the "confidential client" pattern — but mobile apps can't
keep secrets. They're public clients.

Fix: don't use client_secret in mobile. Use PKCE only. Configure
the IdP to allow this client as "public" (no secret).

A12. Scope downgrade attack

The client requests scope=read.
The IdP issues a token with scope=read.
The client (in a bug or compromise) uses the token for write.
The RS checks scope: token has "read", request needs "write", reject.

But: what if the client requests scope=read and the IdP
mistrustingly grants scope=admin? Or what if the client manipulates
the token? Or what if the RS doesn't check scopes?

This isn't really an attack vector if scopes are checked. But:
  - The IdP should not grant MORE than requested
  - The RS should check the scope on every request
  - Auditing scope grants catches weird patterns

14. Exercises

Exercise 1: Trace a real flow

Take a “Sign in with Google” or “Sign in with GitHub” on a real site. Trace every redirect, every parameter, every cookie. Document the flow with screenshots.

Exercise 2: The state without state

Take the code from Section 11. Remove the state check. Try to CSRF yourself (start a flow, get the code, send it to a different browser session). Verify the attack works.

Exercise 3: PKCE without S256

Modify the code to use code_challenge_method=plain (challenge = verifier). Identify the attack. Then restore S256.

Exercise 4: Build the verifier

Hand-write the PKCE generation in 10 lines. Match the output of secrets.token_urlsafe(64) and base64.urlsafe_b64encode(sha256(...).digest()).rstrip(b'=').

Exercise 5: The redirect_uri footgun

Configure an IdP with https://app.example.com/callback as the allowed URI. Then try to auth with https://app.example.com/callback/, https://app.example.com/callback?foo=bar, https://APP.example.com/callback. Document which work and which don’t.

Exercise 6: Token exchange with curl

Use curl to do a complete auth code flow manually (no library). Generate PKCE values, build the URL, do the redirect (use —data-urlencode and the IdP’s test endpoint), exchange the code, get the token. Save as a shell script.

Exercise 7: The client_secret_in_app footgun

Find a mobile app that uses OAuth. Check if it has a client_secret. If yes, that secret is in the app’s binary. Anyone with the binary has the secret. Document the de-anonymization risk.

Exercise 8: Token response variations

Hit 5 different IdPs (Google, GitHub, Auth0, Okta, Keycloak). Compare their token responses. What fields differ? What about expires_in values?

Exercise 9: Refresh token rotation

Take the Section 11 code, add refresh logic. Test: refresh once, get new tokens. Try to use the old refresh token — should fail (rotation). Try to use the OLD access token — should still work until exp.

Exercise 10: PKCE on confidential client

Configure your IdP to require PKCE for a confidential client. Verify: with PKCE works, without PKCE fails. Document the response when PKCE is missing.


15. Next Step

You can now implement the authorization code grant from scratch, with state and PKCE. Next, we cover the other grants — and why most of them shouldn’t be used.

→ Stage 2.3 — Client Credentials, ROPC, Implicit (and Why to Avoid)

Before you move on, verify you can answer these:

  1. What are the 7 steps of the authorization code flow?
  2. What does the state parameter defend against, and how?
  3. What does PKCE defend against, and why is S256 the only safe choice?
  4. Why does OAuth 2.1 require PKCE for confidential clients too?
  5. What’s the difference between the code and the token, and why is the code short-lived?
  6. What error do you get if you try to use the same code twice? Why?