C.W.K.
Stream
Lesson 04 of 06 · published

OAuth2 with PKCE — The Modern Canonical Flow

~12 min · auth-security, oauth2, pkce, authorization-code

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"OAuth2 was designed in 2012 for confidential server-side clients with secrets. The world filled up with mobile apps and SPAs that can't keep secrets. PKCE fixed that by adding a per-request proof. In 2026, every flow — confidential or public — uses PKCE."

What OAuth2 Actually Is

OAuth2 is a delegated authorization protocol. The user grants a third-party app permission to access resources on their behalf, without ever sharing their password with the third party. The classic example: "Allow this app to read your Google Calendar." You authenticate with Google directly; Google issues a scoped token to the app; the app uses the token to access your calendar.

Four roles:

  • Resource owner — you, the user.
  • Client — the third-party app requesting access.
  • Authorization server — the identity provider (Google, GitHub, Auth0, Anthropic) that authenticates the user and issues tokens.
  • Resource server — the API holding the protected resources (Google Calendar API, GitHub API). Often the same organization as the auth server.

Why the Authorization Code Flow Exists

The flow's job: get a token from the auth server to the client, without exposing the token to the user's browser address bar or browser history (where it could leak).

  1. Client redirects the user's browser to the auth server with ?response_type=code&client_id=...&redirect_uri=...&scope=...&state=....
  2. User authenticates with the auth server (login, MFA, whatever), reviews scopes, consents.
  3. Auth server redirects back to redirect_uri?code=...&state=....
  4. Client's BACKEND takes that code, POSTs it to the auth server's token endpoint with its client_secret, receives an access_token (and usually a refresh_token).
  5. Client uses the access_token on resource server requests as Authorization: Bearer ....

The intermediate code is a one-time, short-lived placeholder that's safe to send through the browser. The actual token never touches the browser address bar.

The Problem with Mobile and SPA Clients

Step 4 above requires the client_secret — the value that proves the request is coming from the legitimate client app, not an impostor. Server-side apps store it in env vars (safe). Mobile apps embed it in the binary (anyone with the binary can extract it). SPAs ship it in JavaScript (anyone with the URL can extract it).

Historically, the OAuth2 spec offered the Implicit Flow as a workaround: skip the code exchange entirely, return tokens directly in the redirect URI fragment. But this leaked tokens through browser history, referrer headers, and didn't support refresh tokens. It's been retired.

How PKCE Fixes It

PKCE (Proof Key for Code Exchange, RFC 7636) adds a per-request secret that doesn't need to be pre-shared:

  1. BEFORE the redirect, client generates a random code_verifier (43-128 chars) and computes code_challenge = base64url(sha256(code_verifier)).
  2. Client sends the code_challenge in the initial authorization request: ?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256.
  3. Auth server stores the challenge alongside the issued code.
  4. When the client exchanges code for token, it sends the original code_verifier along.
  5. Auth server checks sha256(code_verifier) == code_challenge. If yes, issues the token. If no, rejects.

The win: an attacker who intercepts the authorization code can't exchange it without knowing the code_verifier — which only the original client knows because they generated it locally. No client_secret required.

In 2026, every OAuth2 client should use PKCE. Originally meant for mobile and SPA clients, the IETF (RFC 9700, BCP 240) now recommends PKCE for confidential clients too. It costs almost nothing (one sha256 hash per flow) and eliminates a category of authorization-code-interception attacks. Pin it on; don't argue.

Scopes — Granular Permission Grants

The scope parameter lists what the client is asking for: scope=read:calendar write:calendar.events. The auth server shows these to the user during consent ("This app wants to: read your calendar, create calendar events"). Granted scopes become claims on the issued token; resource servers enforce them when the token is used.

Scope design is API-specific. Google APIs use URL-style scopes (https://www.googleapis.com/auth/calendar.readonly); GitHub uses short names (repo, user:email); Stripe doesn't use OAuth scopes much because most integrations are full-account. Pick a convention; document it.

OIDC — Adding Identity to OAuth2

OAuth2 is authorization, not authentication. "This token has permission to read calendar" doesn't tell you WHO the user is. OpenID Connect (OIDC) sits on top of OAuth2 and adds an id_token (a signed JWT containing the user's identity claims) to the response. Most modern "Sign in with X" buttons (Google, Apple, Microsoft) implement OIDC, not raw OAuth2. The flow shape is identical; OIDC just adds the id_token.

cwkPippa's OAuth Reality

cwkPippa's brain adapters use OAuth2 against three providers: Anthropic OAuth for Claude (confidential client, server-side), ChatGPT OAuth for Codex (confidential client), Cloud Code Assist OAuth for Gemini (confidential client). All three run on the backend with stored client_secrets — no PKCE needed in those specific cases because no browser-side public client is involved. cwk-site's user auth goes through Supabase, which itself runs PKCE-flavored OAuth under the hood. The full PKCE flow comes into play whenever there's a browser holding the redirect.

Code

Client: generate verifier + challenge before redirect·python
# Client side — generate PKCE verifier and challenge
import secrets, hashlib, base64

# Step 1: generate verifier (random URL-safe string, 43-128 chars)
code_verifier = secrets.token_urlsafe(64)

# Step 2: derive challenge = base64url(sha256(verifier))
challenge_bytes = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge  = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=').decode('ascii')

# Step 3: construct the authorization URL
from urllib.parse import urlencode
auth_url = 'https://auth.example.com/authorize?' + urlencode({
    'response_type':         'code',
    'client_id':             'my-app-id',
    'redirect_uri':          'https://my-app.example.com/callback',
    'scope':                 'read:user write:repo',
    'state':                 secrets.token_urlsafe(16),  # CSRF protection
    'code_challenge':        code_challenge,
    'code_challenge_method': 'S256',
})
print('Open this URL in browser:', auth_url)
# Save the code_verifier — you'll need it for the exchange step
Exchange code for access_token using PKCE verifier·python
# Client side — exchange authorization code for tokens
import httpx

# After the auth server redirects to redirect_uri?code=...&state=...
# (verify state matches what you sent!)

resp = httpx.post(
    'https://auth.example.com/token',
    data={
        'grant_type':   'authorization_code',
        'client_id':    'my-app-id',
        'redirect_uri': 'https://my-app.example.com/callback',
        'code':         'received_code_from_redirect',
        'code_verifier': code_verifier,    # <-- proves we started this flow
        # confidential clients also send client_secret here; public clients don't
    },
)
tokens = resp.json()
print(tokens)
# {
#   "access_token":  "eyJhbGciOi...",
#   "token_type":    "Bearer",
#   "expires_in":    3600,
#   "refresh_token": "def50200...",
#   "scope":         "read:user write:repo"
# }
Auth server: validate PKCE verifier during token exchange·python
# Server side — auth server validating PKCE during token exchange
from fastapi import FastAPI, Form, HTTPException
import hashlib, base64, secrets, time

app = FastAPI()
_codes: dict[str, dict] = {}  # code -> {client_id, code_challenge, redirect_uri, user_id, exp}

@app.post('/token')
async def token(
    grant_type:   str = Form(...),
    client_id:    str = Form(...),
    redirect_uri: str = Form(...),
    code:         str = Form(...),
    code_verifier: str = Form(...),
):
    if grant_type != 'authorization_code':
        raise HTTPException(400, detail='unsupported grant_type')
    record = _codes.pop(code, None)
    if not record or record['exp'] < time.time():
        raise HTTPException(400, detail='invalid or expired code')
    if record['client_id'] != client_id or record['redirect_uri'] != redirect_uri:
        raise HTTPException(400, detail='client_id or redirect_uri mismatch')

    # The key check: did the client know the verifier?
    derived_challenge = base64.urlsafe_b64encode(
        hashlib.sha256(code_verifier.encode('ascii')).digest()
    ).rstrip(b'=').decode('ascii')
    if derived_challenge != record['code_challenge']:
        raise HTTPException(400, detail='PKCE verifier mismatch')

    # All good — issue the access token (JWT or opaque)
    return {
        'access_token':  issue_access_token(record['user_id']),
        'token_type':    'Bearer',
        'expires_in':    3600,
        'refresh_token': issue_refresh_token(record['user_id']),
    }

External links

Exercise

Implement a tiny OAuth2 auth server in FastAPI with the Authorization Code + PKCE flow: /authorize (renders a fake consent page, accepts code_challenge), /token (validates code_verifier, issues access+refresh). Then write a client that walks through the whole flow: generate verifier+challenge, open authorize URL, paste the redirect URL back, exchange code+verifier for tokens, use the access token on a protected /me endpoint. Bonus: deliberately submit the wrong code_verifier and verify the auth server returns 400.
Hint
The verifier generation is just secrets.token_urlsafe(64); challenge is base64url(sha256(verifier)) with = padding stripped. Auth server stores the code_challenge with each issued code; on /token, computes the derived challenge from the submitted verifier and compares. The whole PKCE addition is maybe 20 lines of code — its value isn't complexity, it's eliminating the 'public clients can't safely use OAuth' problem the spec used to have.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.