TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Build an OAuth 2.0 Authorization Code Flow With PKCE in Python
Learning Hub

How to Build an OAuth 2.0 Authorization Code Flow With PKCE in Python

Build your own OAuth 2.0 authorization server, resource server, and client in Python, then deliberately attack your PKCE implementation to prove it works.

July 31, 2026 22 Min Read
55

You have clicked “Continue with Google” or “Sign in with GitHub” hundreds of times. In that moment, an application you barely trust gets permission to read your calendar, or open a pull request on your behalf, and yet you never typed your Google or GitHub password into that application. That handoff, a third-party app getting limited, revocable access to your data on another service without ever seeing your password, is OAuth 2.0. It is arguably the single most-used security protocol on the internet, and most backend developers can describe the “click a button, get redirected, come back logged in” experience without being able to explain what actually travels over the wire or why the protocol is shaped the way it is.

Table Of Content

  • What Problem Does OAuth 2.0 Actually Solve?
  • Authentication vs. Authorization (They Are Not the Same Thing)
  • The Four Roles and the Vocabulary You Need
  • Why Build Your Own Authorization Server Instead of Registering With Google?
  • What PKCE Adds, and Why It Exists
  • Prerequisites
  • The Architecture You Are Building
  • Step 1: Set Up the Project and Install Dependencies
  • Step 2: Build the Authorization Server
  • The /authorize Endpoint: Where the User Sees a Consent Screen
  • The /approve Endpoint: Issuing a Single-Use Authorization Code
  • The /token Endpoint: Where PKCE Is Actually Checked
  • Step 3: Build the Resource Server
  • Step 4: Build the Client Application
  • Step 5: Run All Three Services
  • Step 6: Walk Through the Full Login in a Real Browser
  • Step 7: Inspect the Raw Protocol Yourself With curl
  • Step 8: Verify the Security Properties by Deliberately Breaking Them
  • Tampered or Unknown State (CSRF Protection)
  • Wrong PKCE Verifier
  • Replaying an Already-Used Authorization Code
  • Wrong Client Secret
  • Unregistered Redirect URI
  • Requesting a Scope the Token Was Never Granted
  • Access Token Expiry and the Refresh Token Flow
  • Common Mistakes and Gotchas
  • How to Confirm Everything Works End-to-End
  • Next Steps

This tutorial fixes that by having you build the whole thing yourself. Instead of registering a real application with Google or GitHub and treating their servers as a black box, you will write, run, and personally attack a complete OAuth 2.0 system: an authorization server, a resource server, and a client application, all in Python, all running on your own machine. You will implement the Authorization Code flow, the one real production systems use, together with PKCE (Proof Key for Code Exchange), the extension that closes a specific interception attack against that flow. Every command, every code sample, and every piece of output shown below was run on a real machine while writing this guide; nothing here is hypothetical.

What Problem Does OAuth 2.0 Actually Solve?

Imagine you are building TravelBuddy, an app that helps people plan trips. TravelBuddy wants to read a user’s Google Calendar and add trip itineraries to it automatically. Before OAuth existed, there was exactly one way to do this: ask the user for their actual Google username and password, store it, and log in to Google as them whenever TravelBuddy needed calendar access.

That approach creates problems that have nothing to do with bad intentions and everything to do with how credentials work:

  • Over-privileged access. TravelBuddy only needs calendar events, but a Google password unlocks Gmail, Drive, photos, and the ability to change the account’s own password. There is no way to hand over “just the calendar part.”
  • No granular revocation. If the user wants to cut off TravelBuddy, their only option is to change their Google password everywhere, which breaks every other app they had authorized too.
  • Storage liability. TravelBuddy is now a second place, beyond Google itself, where a database breach exposes real Google passwords for thousands of accounts.
  • Phishing normalization. Training users to type their primary password into random third-party apps teaches them exactly the habit that phishing pages rely on.

OAuth 2.0 exists to give the user a way to grant TravelBuddy limited, scoped, revocable access to their calendar, and nothing else, without TravelBuddy ever seeing their Google password. That capability has a name: delegated authorization.

Authentication vs. Authorization (They Are Not the Same Thing)

Before going further, pin down two words developers use interchangeably despite them meaning different things. Authentication answers “who are you?” It is about identity. Authorization answers “what are you allowed to do?” It is about permissions. OAuth 2.0 is strictly an authorization framework. The specification, RFC 6749, The OAuth 2.0 Authorization Framework (October 2012), never talks about verifying a user’s identity to the client application. It only talks about issuing scoped, time-limited permission tokens. When you see “Log in with Google” on a website, that experience is built on top of OAuth using an extension called OpenID Connect, which adds a proper identity token. Plain OAuth 2.0, the part you are about to build, is not that. Keep this distinction in mind, because conflating the two is one of the most common production mistakes, covered later in this guide.

The Four Roles and the Vocabulary You Need

RFC 6749 defines exactly four roles in every OAuth exchange. Mapped onto the TravelBuddy example:

  • Resource Owner: the user. Alice, who owns her Google Calendar and is the only one who can grant access to it.
  • Client: the application requesting access on the user’s behalf. TravelBuddy’s backend.
  • Authorization Server (AS): the server that authenticates the resource owner, shows the consent screen, and issues tokens. Google’s login and consent infrastructure.
  • Resource Server (RS): the server hosting the actual protected data, which accepts a token as proof of permission. The Google Calendar API itself.

You will also need a handful of terms that show up constantly in the code you are about to write. An access token is, per RFC 6749, “a string representing an authorization issued to the client,” representing specific scopes and a specific duration of access. It is the thing TravelBuddy actually presents to the Calendar API; it is not a password and it is not (by itself) proof of who the user is. A refresh token is a longer-lived credential the client can exchange for a new access token once the old one expires, without bothering the user to log in again. A scope is a named, granular permission, like “read your calendar” versus “read your email,” that the user consents to individually. An authorization code is a short-lived, single-use value the authorization server hands the client through the user’s browser, which the client then exchanges, server-to-server, for the actual tokens. Finally, every registered client gets a client ID (public, safe to expose) and a client secret (confidential, proves the client’s own identity to the authorization server when redeeming a code).

Why Build Your Own Authorization Server Instead of Registering With Google?

You could register a real OAuth app with Google or GitHub and wire this tutorial up against their production servers. Plenty of guides do exactly that. The problem is that doing so hides the one thing you are actually trying to learn: what happens on the server side. You would see a redirect and a consent screen, but the logic that generates an authorization code, checks it has not been reused, verifies a PKCE challenge, and decides whether to honor a refresh token would all be invisible, running on Google’s infrastructure, not yours.

So this tutorial has you build a small but completely real authorization server, resource server, and client, all in Python, all on localhost. None of this code belongs in production (a production AS needs real user accounts, persistent storage, rate limiting, and far more hardening than a teaching example), but every line of it implements the actual RFC-defined behavior, not a simplification of it.

What PKCE Adds, and Why It Exists

The plain Authorization Code flow has a known weak point: the authorization code travels through the user’s browser as a URL query parameter on its way back to the client. On a phone, that redirect can be intercepted by a malicious app registered for the same custom URL scheme; on the web, it can leak through browser history, a referrer header, or a misconfigured proxy log. If an attacker gets the code before the legitimate client does, and the client’s only other secret is one baked into a public mobile app or JavaScript bundle (easy to extract), the attacker can redeem that stolen code for real tokens.

RFC 7636, Proof Key for Code Exchange by OAuth Public Clients (September 2015), closes this gap. Before redirecting the user anywhere, the client generates a random secret called the code_verifier, then sends the authorization server a code_challenge, which per the RFC is BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) when using the recommended S256 method (the RFC also defines a weaker plain method, where the challenge equals the verifier itself, kept only for compatibility and explicitly not recommended for new implementations). The authorization server stores that hashed challenge alongside the code it issues. Later, when the client redeems the code, it must also present the original, unhashed code_verifier. The authorization server hashes it again and checks the result matches the stored challenge. An attacker who only intercepted the authorization code from the redirect never saw the original verifier, so they cannot complete the exchange even with the stolen code in hand. PKCE was originally aimed at “public clients,” apps that cannot keep a secret (mobile and single-page apps), but the direction of the protocol has since shifted: the in-progress OAuth 2.1 consolidation now mandates PKCE for every client using the Authorization Code flow, public or not, and drops the implicit grant type entirely. You will implement PKCE from the start, the way any new OAuth integration should.

Prerequisites

  • A Linux or macOS terminal, or WSL on Windows. This guide was written and tested on Ubuntu with Python 3.11.15; any Python 3.10 or newer works identically.
  • Basic comfort with the command line and reading Python. No prior OAuth knowledge is assumed; every term is defined before it is used.
  • A basic sense of how HTTP redirects work (a server responding with status 302 and a Location header telling the browser where to go next). If that is new to you, it will make complete sense by the time you finish Step 6.
  • No external accounts, API keys, or app registrations of any kind. Everything in this tutorial runs entirely on your own machine across three local ports.

The Architecture You Are Building

You will run three independent processes, plus your own web browser standing in for the resource owner:

  • An Authorization Server on port 9000, playing Google’s role: it authenticates the user (in our simplified version, there is a single hardcoded demo user), shows a consent screen, issues authorization codes, and exchanges them for access and refresh tokens.
  • A Resource Server on port 9001, playing the Google Calendar API’s role: it holds the protected data and validates every incoming request by asking the authorization server whether the presented token is real and active.
  • A Client on port 8000, playing TravelBuddy’s role: it never touches the user’s password, only a token it receives after the user approves access.

At a high level, a login attempt flows like this: the user visits the client and clicks login; the client redirects the browser to the authorization server with a request describing what access it wants; the authorization server shows a consent screen and, on approval, redirects the browser back to the client with a one-time code; the client, talking directly to the authorization server over a server-to-server connection the browser never sees, exchanges that code (plus its PKCE verifier) for an access token and a refresh token; the client then uses the access token to call the resource server on the user’s behalf. You are about to build every piece of that paragraph.

Step 1: Set Up the Project and Install Dependencies

Create an isolated virtual environment so these packages do not collide with anything else already on your machine:

# Context: any directory, Python 3.10+ already installed.
# Purpose: isolate dependencies, then install the web framework, ASGI server, and HTTP client used by all three services.
mkdir oauth-demo && cd oauth-demo
python3 -m venv venv
source venv/bin/activate
pip install fastapi "uvicorn[standard]" httpx python-multipart

On Windows, activate with venv\Scripts\activate instead of the source line; everything else is identical. Expected output: pip resolving and installing each package, ending in a line like Successfully installed .... This tutorial was tested against fastapi 0.141.1, uvicorn 0.52.0, and httpx 0.28.1. FastAPI is the web framework you will use for all three services; uvicorn is the ASGI server that actually runs a FastAPI app; httpx is what your Python code uses to make server-to-server calls (the client exchanging a code for tokens, the resource server calling the authorization server to validate a token); python-multipart is required by FastAPI to parse form-encoded POST bodies, which is how the OAuth token endpoint receives its parameters per RFC 6749.

Step 2: Build the Authorization Server

The authorization server is the one piece of this system a real OAuth integration never builds; it is Google’s, GitHub’s, or your company’s identity team’s job. Building a working one yourself is what turns the RFC’s abstract language into something you have actually seen execute. Save the following as authserver.py:

import base64
import hashlib
import secrets
import time

from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse

app = FastAPI(title="Demo Authorization Server")

CLIENTS = {
    "travelbuddy": {
        "client_secret": "demo-secret-value",
        "redirect_uris": {"http://localhost:8000/callback"},
    }
}

AUTH_CODE_TTL = 60
ACCESS_TOKEN_TTL = 15
REFRESH_TOKEN_TTL = 3600

AUTH_CODES = {}
ACCESS_TOKENS = {}
REFRESH_TOKENS = {}
DEMO_USER = "alice"


def b64url_no_pad(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

This top section is the authorization server’s “developer console” in miniature: a dictionary standing in for a real client registration database. Notice that redirect_uris is a set of exact strings the server already knows about. That single design choice is a critical defense: if the authorization server accepted any redirect_uri a request happened to send, an attacker could register their own request with their own redirect_uri and have authorization codes delivered straight to them.

The /authorize Endpoint: Where the User Sees a Consent Screen

@app.get("/authorize", response_class=HTMLResponse)
def authorize(
    response_type: str,
    client_id: str,
    redirect_uri: str,
    scope: str = "",
    state: str = "",
    code_challenge: str = "",
    code_challenge_method: str = "plain",
):
    client = CLIENTS.get(client_id)
    if response_type != "code" or client is None or redirect_uri not in client["redirect_uris"]:
        return HTMLResponse("invalid_request: unknown client_id or redirect_uri", status_code=400)

    return HTMLResponse(f"""
    <html><body>
      <h3>Demo Authorization Server</h3>
      <p><b>{client_id}</b> wants to access your <b>{scope}</b> data as {DEMO_USER}.</p>
      <form method="post" action="/approve">
        <input type="hidden" name="client_id" value="{client_id}">
        <input type="hidden" name="redirect_uri" value="{redirect_uri}">
        <input type="hidden" name="scope" value="{scope}">
        <input type="hidden" name="state" value="{state}">
        <input type="hidden" name="code_challenge" value="{code_challenge}">
        <input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
        <button name="decision" value="allow">Approve</button>
        <button name="decision" value="deny">Deny</button>
      </form>
    </body></html>
    """)

Real authorization servers put a login form in front of this step; this demo assumes Alice is already “logged in” so the tutorial can stay focused on the OAuth exchange itself rather than session cookies. The important production behavior is still here: the server rejects anything with an unrecognized client_id or a redirect_uri that does not exactly match what was registered, before showing any consent screen at all.

The /approve Endpoint: Issuing a Single-Use Authorization Code

@app.post("/approve")
def approve(
    client_id: str = Form(...),
    redirect_uri: str = Form(...),
    scope: str = Form(""),
    state: str = Form(""),
    code_challenge: str = Form(""),
    code_challenge_method: str = Form("plain"),
    decision: str = Form(...),
):
    if decision != "allow":
        return RedirectResponse(f"{redirect_uri}?error=access_denied&state={state}", status_code=302)

    code = secrets.token_urlsafe(24)
    AUTH_CODES[code] = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": scope,
        "user": DEMO_USER,
        "code_challenge": code_challenge,
        "code_challenge_method": code_challenge_method,
        "expires_at": time.time() + AUTH_CODE_TTL,
        "used": False,
    }
    return RedirectResponse(f"{redirect_uri}?code={code}&state={state}", status_code=302)

When the user clicks Approve, the server generates a random code, stores everything needed to validate the eventual exchange (which client it belongs to, which redirect_uri, the PKCE challenge), gives it a short 60-second lifetime, and marks it unused. Then it redirects the browser back to the client’s redirect_uri with that code attached, plus the same state value the client originally sent. That state round-trip is what lets the client detect forged callbacks later; you will see exactly how in Step 4.

The /token Endpoint: Where PKCE Is Actually Checked

@app.post("/token")
def token(
    grant_type: str = Form(...),
    code: str = Form(None),
    redirect_uri: str = Form(None),
    client_id: str = Form(...),
    client_secret: str = Form(...),
    code_verifier: str = Form(None),
    refresh_token: str = Form(None),
):
    client = CLIENTS.get(client_id)
    if client is None or client["client_secret"] != client_secret:
        return JSONResponse({"error": "invalid_client"}, status_code=401)

    if grant_type == "authorization_code":
        entry = AUTH_CODES.get(code)
        if entry is None:
            return JSONResponse({"error": "invalid_grant", "error_description": "unknown code"}, status_code=400)
        if entry["used"]:
            return JSONResponse({"error": "invalid_grant", "error_description": "code already used"}, status_code=400)
        if entry["expires_at"] < time.time():
            return JSONResponse({"error": "invalid_grant", "error_description": "code expired"}, status_code=400)
        if entry["client_id"] != client_id or entry["redirect_uri"] != redirect_uri:
            return JSONResponse({"error": "invalid_grant", "error_description": "client_id/redirect_uri mismatch"}, status_code=400)

        if entry["code_challenge"]:
            if not code_verifier:
                return JSONResponse({"error": "invalid_grant", "error_description": "missing code_verifier"}, status_code=400)
            if entry["code_challenge_method"] == "S256":
                computed = b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest())
            else:
                computed = code_verifier
            if computed != entry["code_challenge"]:
                return JSONResponse({"error": "invalid_grant", "error_description": "PKCE verification failed"}, status_code=400)

        entry["used"] = True

        access_token = secrets.token_urlsafe(32)
        new_refresh_token = secrets.token_urlsafe(32)
        ACCESS_TOKENS[access_token] = {
            "client_id": client_id, "user": entry["user"], "scope": entry["scope"],
            "expires_at": time.time() + ACCESS_TOKEN_TTL,
        }
        REFRESH_TOKENS[new_refresh_token] = {
            "client_id": client_id, "user": entry["user"], "scope": entry["scope"],
            "expires_at": time.time() + REFRESH_TOKEN_TTL,
        }
        return JSONResponse({
            "access_token": access_token, "token_type": "Bearer", "expires_in": ACCESS_TOKEN_TTL,
            "refresh_token": new_refresh_token, "scope": entry["scope"],
        })

    elif grant_type == "refresh_token":
        entry = REFRESH_TOKENS.get(refresh_token)
        if entry is None or entry["client_id"] != client_id:
            return JSONResponse({"error": "invalid_grant", "error_description": "unknown refresh_token"}, status_code=400)
        if entry["expires_at"] < time.time():
            return JSONResponse({"error": "invalid_grant", "error_description": "refresh_token expired"}, status_code=400)

        access_token = secrets.token_urlsafe(32)
        ACCESS_TOKENS[access_token] = {
            "client_id": client_id, "user": entry["user"], "scope": entry["scope"],
            "expires_at": time.time() + ACCESS_TOKEN_TTL,
        }
        return JSONResponse({"access_token": access_token, "token_type": "Bearer", "expires_in": ACCESS_TOKEN_TTL, "scope": entry["scope"]})

    return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)


@app.post("/introspect")
def introspect(token: str = Form(...)):
    entry = ACCESS_TOKENS.get(token)
    if entry is None or entry["expires_at"] < time.time():
        return JSONResponse({"active": False})
    return JSONResponse({
        "active": True, "client_id": entry["client_id"], "user": entry["user"],
        "scope": entry["scope"], "exp": int(entry["expires_at"]),
    })

This is the busiest endpoint in the system, and it is worth reading slowly because every check in it maps directly to a real attack it prevents. The client secret check stops anyone who does not know TravelBuddy’s secret from redeeming codes as if they were TravelBuddy. The “already used” check stops a stolen code from being redeemed twice (authorization codes are meant to be used exactly once; this is what stops a replayed code, which you will attack on purpose in Step 8). The expiry check keeps a leaked code from being useful hours later. The client_id and redirect_uri match check ties the code back to the exact request that created it. And finally, the PKCE block re-hashes whatever code_verifier the caller just presented and compares it to the challenge stored back when the code was issued; this is the actual mechanism described in RFC 7636 that turns a purely intercepted code into something useless on its own.

The last endpoint, /introspect, implements a simplified version of RFC 7662, OAuth 2.0 Token Introspection. It exists because access tokens here are opaque random strings, not something the resource server can validate on its own; it has to ask the authorization server “is this token real, and what does it grant?” You will see exactly that call happen in the next step.

Step 3: Build the Resource Server

The resource server’s job is deliberately narrow: it never talks to the browser, never sees a login form, and knows nothing about authorization codes, consent screens, or PKCE. All it knows how to do is check whether a bearer token is valid and, if so, serve the protected data. Save this as resourceserver.py:

import httpx
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse

app = FastAPI(title="Demo Resource Server")

AUTH_SERVER = "http://localhost:9000"

PROFILES = {
    "alice": {"name": "Alice Example", "email": "[email protected]", "plan": "pro"},
}


@app.get("/api/profile")
def profile(authorization: str = Header(default="")):
    if not authorization.startswith("Bearer "):
        return JSONResponse(
            {"error": "invalid_request", "error_description": "missing bearer token"},
            status_code=401,
            headers={"WWW-Authenticate": 'Bearer realm="demo"'},
        )
    access_token = authorization.removeprefix("Bearer ")

    resp = httpx.post(f"{AUTH_SERVER}/introspect", data={"token": access_token})
    info = resp.json()
    if not info.get("active"):
        return JSONResponse(
            {"error": "invalid_token", "error_description": "token is expired or unknown"},
            status_code=401,
            headers={"WWW-Authenticate": 'Bearer realm="demo", error="invalid_token"'},
        )

    if "profile" not in info["scope"].split():
        return JSONResponse(
            {"error": "insufficient_scope", "error_description": "token lacks 'profile' scope"},
            status_code=403,
            headers={"WWW-Authenticate": 'Bearer realm="demo", error="insufficient_scope"'},
        )

    return {"user": info["user"], "data": PROFILES[info["user"]]}

Two status codes matter here and they mean different things: 401 Unauthorized means “I do not know who you are,” either no token was sent or the token is expired or fake. 403 Forbidden means “I know exactly who you are, and your token is valid, but it was never granted the permission this endpoint requires.” Conflating those two in an API’s error handling is a common real-world bug; it makes debugging permission issues much harder than it needs to be, because a client cannot tell “log in again” apart from “ask the user to grant an additional scope.” Both error responses also include a standard WWW-Authenticate header per RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage, so a well-behaved client can tell programmatically what went wrong instead of parsing an error string.

Step 4: Build the Client Application

The client is TravelBuddy. Its one hard rule is that it must never see Alice’s actual credentials, only a token she delegated to it. Save this as client.py:

import base64
import hashlib
import secrets
import time

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse

app = FastAPI(title="Demo Client (TravelBuddy)")

AUTH_SERVER = "http://localhost:9000"
CLIENT_ID = "travelbuddy"
CLIENT_SECRET = "demo-secret-value"
REDIRECT_URI = "http://localhost:8000/callback"

PENDING = {}
SESSION = {}


def b64url_no_pad(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


@app.get("/", response_class=HTMLResponse)
def home():
    if "access_token" in SESSION:
        return HTMLResponse('<a href="/whoami">Call resource server</a> | <a href="/refresh">Refresh token</a>')
    return HTMLResponse('<a href="/login">Login with Demo Provider</a>')


@app.get("/login")
def login():
    state = secrets.token_urlsafe(16)
    code_verifier = secrets.token_urlsafe(48)
    code_challenge = b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest())
    PENDING[state] = {"code_verifier": code_verifier, "created_at": time.time()}

    params = {
        "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
        "scope": "profile", "state": state,
        "code_challenge": code_challenge, "code_challenge_method": "S256",
    }
    query = "&".join(f"{k}={v}" for k, v in params.items())
    return RedirectResponse(f"{AUTH_SERVER}/authorize?{query}", status_code=302)

Notice that /login generates a brand new state and a brand new PKCE code_verifier on every single attempt, then stores the verifier server-side, keyed by that state. This matters: a real client typically stores this in the user’s server-side session, but the principle is the same either way, the verifier must never be exposed to the browser and must never be reused across login attempts.

@app.get("/callback")
def callback(request: Request):
    params = dict(request.query_params)
    state = params.get("state", "")
    pending = PENDING.pop(state, None)

    if params.get("error"):
        return HTMLResponse(f"Authorization denied: {params['error']}", status_code=400)
    if pending is None:
        return HTMLResponse("invalid_state: possible CSRF attempt, aborting", status_code=400)

    code = params.get("code")
    token_resp = httpx.post(f"{AUTH_SERVER}/token", data={
        "grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
        "code_verifier": pending["code_verifier"],
    })
    if token_resp.status_code != 200:
        return HTMLResponse(f"token exchange failed: {token_resp.text}", status_code=400)

    SESSION.update(token_resp.json())
    return RedirectResponse("/whoami", status_code=302)

This is the step most tutorials skip over, and it is the one that actually stops cross-site request forgery. The client popped the state it stored under whatever value the callback presented; if nothing was stored under that value, either because it was never issued or because it was already consumed, the request is rejected outright, before any code is exchanged. Without this check, an attacker could trick a victim’s browser into visiting a callback URL carrying the attacker’s own authorization code, silently logging the victim into the attacker’s account, a real, named attack class called login CSRF. The state parameter is what defeats it.

@app.get("/whoami")
def whoami():
    access_token = SESSION.get("access_token")
    if not access_token:
        return RedirectResponse("/login", status_code=302)
    resp = httpx.get("http://localhost:9001/api/profile", headers={"Authorization": f"Bearer {access_token}"})
    return HTMLResponse(f"<pre>status={resp.status_code}\n{resp.text}</pre>")


@app.get("/refresh")
def refresh():
    refresh_token = SESSION.get("refresh_token")
    if not refresh_token:
        return HTMLResponse("no refresh_token in session, log in first", status_code=400)
    resp = httpx.post(f"{AUTH_SERVER}/token", data={
        "grant_type": "refresh_token", "refresh_token": refresh_token,
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
    })
    if resp.status_code == 200:
        SESSION["access_token"] = resp.json()["access_token"]
    return HTMLResponse(f"<pre>status={resp.status_code}\n{resp.text}</pre>")


@app.get("/debug/session")
def debug_session():
    # Teaching-only: dumps raw tokens so you can copy them into curl commands.
    # Never ship an endpoint like this in a real application.
    return SESSION

The /debug/session endpoint is not part of any OAuth specification; it exists purely so you can copy a real access token out and use it with curl in Step 7. Notice the comment: an endpoint that prints out live bearer tokens is a serious information disclosure risk and has no place outside a local teaching exercise.

Step 5: Run All Three Services

Open three terminals (or three tabs), activate the same virtual environment in each, and start one service per terminal:

# Context: oauth-demo/ with venv activated, in three separate terminals.
# Purpose: run the authorization server, resource server, and client as three independent processes, the way they would be three independent deployments in production.
uvicorn authserver:app --port 9000       # terminal 1
uvicorn resourceserver:app --port 9001   # terminal 2
uvicorn client:app --port 8000           # terminal 3

Verify each one is actually listening before moving on. This tutorial used the checks below, run from a fourth terminal, and captured the real results shown:

curl -s -o /dev/null -w "authserver:%{http_code} " "http://localhost:9000/introspect" -X POST -d "token=x"
curl -s -o /dev/null -w "\nresourceserver:%{http_code} " "http://localhost:9001/api/profile" -H "Authorization: Bearer x"
curl -s -o /dev/null -w "\nclient:%{http_code}\n" "http://localhost:8000/"

Expected output:

authserver:200
resourceserver:401
client:200

The authorization server returns 200 because /introspect always answers with a normal JSON body (just {"active": false} for an unknown token, not an error status). The resource server correctly returns 401 for a bearer token it has never heard of. The client’s homepage loads normally. If any of these do not match, stop here and check that terminal’s output for a Python traceback before continuing; a typo in one of the three files above is the most likely cause.

Step 6: Walk Through the Full Login in a Real Browser

Open http://localhost:8000/ in an actual web browser and click “Login with Demo Provider.” Watch the address bar closely as you go, and open your browser’s developer tools to the Network tab if you want to see every redirect explicitly.

You will land on the authorization server’s consent page at localhost:9000/authorize, with a URL carrying the querystring your client just built (client_id, redirect_uri, scope, a random state, and the PKCE code_challenge). The page itself is intentionally plain, just a sentence and an Approve or Deny button, because the point of this tutorial is the protocol underneath, not a polished consent UI. Click Approve.

You will immediately bounce to localhost:8000/callback and then automatically to /whoami, which should render a small block of JSON. This exact sequence, run as an automated HTTP script rather than a live browser click for reproducibility while writing this guide, produced the following real values (yours will differ, since a fresh state, PKCE verifier, code, and tokens are generated on every attempt, but the shape will match exactly):

Redirect from /login:
http://localhost:9000/authorize?response_type=code&client_id=travelbuddy&redirect_uri=http://localhost:8000/callback&scope=profile&state=jYl9IdkjUexTmWxgfoC6Gw&code_challenge=op0y_VF8TyoWXubJRyMNKUmIIdjaBgZfxDRU5CF9Jno&code_challenge_method=S256

Redirect from /approve:
http://localhost:8000/callback?code=9r4VsYUkNPr5Frwn8VO90ZHDGgQVsRxy&state=jYl9IdkjUexTmWxgfoC6Gw

Final result at /whoami:
status=200
{"user":"alice","data":{"name":"Alice Example","email":"[email protected]","plan":"pro"}}

Walk through what just happened, hop by hop. The client generated a state and a PKCE pair, then redirected the browser to the authorization server with the challenge (never the verifier). The authorization server checked the client_id and redirect_uri, showed consent, and on approval issued a one-time code bound to that challenge, sending the browser back to the client with the code and the same state it was given. The client (this next part never touches the browser at all; it is a direct server-to-server HTTPS call) confirmed the returned state matched what it had stored, then presented the code plus the original code_verifier to the authorization server’s /token endpoint. The authorization server re-hashed the verifier, matched it against the stored challenge, and only then issued real tokens. The client used the access token to call the resource server, which validated it via introspection and returned Alice’s profile. That entire paragraph is the Authorization Code flow with PKCE, and you just watched it happen with your own values.

Step 7: Inspect the Raw Protocol Yourself With curl

Seeing JSON rendered in a browser is convenient, but seeing the actual bearer token makes the “it’s just an HTTP header” nature of OAuth concrete. After logging in through the browser in Step 6, visit http://localhost:8000/debug/session to see the raw token response your client received:

curl -s http://localhost:8000/debug/session
{"access_token":"rksj3sSWeJ6wb0iLIdLEBB3wDBQC8Jndf_0A2jHkdmY","token_type":"Bearer","expires_in":15,"refresh_token":"Tenm6kx_75ixT2E1WeOYgH2je5yeZZHitZwNkKbTK2Y","scope":"profile"}

Copy the access_token value and use it directly, bypassing the client entirely, to prove the resource server does not care who is asking, only whether the token is valid:

TOKEN="rksj3sSWeJ6wb0iLIdLEBB3wDBQC8Jndf_0A2jHkdmY"
curl -s -w "\nHTTP_STATUS:%{http_code}\n" "http://localhost:9001/api/profile" -H "Authorization: Bearer $TOKEN"
{"user":"alice","data":{"name":"Alice Example","email":"[email protected]","plan":"pro"}}
HTTP_STATUS:200

You can also call the introspection endpoint directly, the exact call the resource server makes internally on every request:

curl -s -w "\nHTTP_STATUS:%{http_code}\n" -X POST "http://localhost:9000/introspect" -d "token=$TOKEN"
{"active":true,"client_id":"travelbuddy","user":"alice","scope":"profile","exp":1785481888}
HTTP_STATUS:200

Now try a request with no token at all, and one with an obviously fake token:

curl -s -w "\nHTTP_STATUS:%{http_code}\n" "http://localhost:9001/api/profile"
curl -s -w "\nHTTP_STATUS:%{http_code}\n" "http://localhost:9001/api/profile" -H "Authorization: Bearer not-a-real-token"
{"error":"invalid_request","error_description":"missing bearer token"}
HTTP_STATUS:401

{"error":"invalid_token","error_description":"token is expired or unknown"}
HTTP_STATUS:401

Both are correctly rejected with 401, one because no Authorization header was sent at all, the other because the introspection call came back with "active": false for a token the authorization server never issued.

Step 8: Verify the Security Properties by Deliberately Breaking Them

Reading code that checks for CSRF, replay, and PKCE tampering is one thing; watching those checks actually reject a real attack attempt is what makes them stick. Each of the following was run against the live services above and produced the real output shown.

Tampered or Unknown State (CSRF Protection)

Visiting the client’s callback with a state value it never issued simulates a forged link an attacker might send a victim:

curl -s "http://localhost:8000/callback?code=fakecode123&state=not-a-real-state"
invalid_state: possible CSRF attempt, aborting

Wrong PKCE Verifier

Using a real, freshly issued code but the wrong code_verifier (as an attacker who only intercepted the code, without ever seeing the original verifier, would be forced to do):

{"error": "invalid_grant", "error_description": "PKCE verification failed"}

Replaying an Already-Used Authorization Code

After a code is legitimately redeemed once, trying to redeem the exact same code again:

{"error": "invalid_grant", "error_description": "code already used"}

Wrong Client Secret

{"error": "invalid_client"}

Unregistered Redirect URI

Requesting authorization with a redirect_uri that does not exactly match what was registered for the client, the way an attacker trying to redirect codes to their own server would have to:

curl "http://localhost:9000/authorize?response_type=code&client_id=travelbuddy&redirect_uri=http://evil.example.com/callback&scope=profile&state=x&code_challenge=y&code_challenge_method=S256"
invalid_request: unknown client_id or redirect_uri

Requesting a Scope the Token Was Never Granted

Temporarily changing the client to request an empty scope, then trying to call the profile endpoint with the resulting token, produces a 403, not a 401, because the token itself is perfectly valid, it was simply never granted the profile permission:

status=403
{"error":"insufficient_scope","error_description":"token lacks 'profile' scope"}

Access Token Expiry and the Refresh Token Flow

This demo intentionally sets a 15-second access token lifetime (a real system would use something like an hour) so you can watch expiry happen without waiting around. Immediately after logging in, calling /whoami succeeds. Waiting past the 15-second window and calling it again:

status=401
{"error":"invalid_token","error_description":"token is expired or unknown"}

This is exactly why refresh tokens exist: instead of forcing the user through the entire consent screen again, the client can quietly redeem its longer-lived refresh token for a new access token:

curl http://localhost:8000/refresh
status=200
{"access_token":"4ipCvA58e9u35OVONGzDTlVACee2hETMul65sDZ4cIE","token_type":"Bearer","expires_in":15,"scope":"profile"}

And calling the resource server again with this new token succeeds, with no consent screen involved at any point:

status=200
{"user":"alice","data":{"name":"Alice Example","email":"[email protected]","plan":"pro"}}

Common Mistakes and Gotchas

A few of these are easy to get wrong even after you understand the flow conceptually. Watch for them in your own integrations.

Treating the state parameter as optional. It is tempting to skip it “just for a quick internal tool.” Without it, your callback endpoint has no way to distinguish a legitimate redirect from an attacker’s forged one, and login CSRF becomes possible, as demonstrated in Step 8.

Skipping PKCE because “we already have a client secret.” PKCE protects the redirect leg of the flow, where the authorization code travels through the browser, which is a risk regardless of whether the client can also keep a server-side secret. That is exactly why OAuth 2.1 mandates it for every client type, not only public ones.

Storing access tokens in browser localStorage. Any cross-site scripting vulnerability elsewhere on the same page can read localStorage and exfiltrate the token. Prefer httpOnly cookies, or a backend-for-frontend pattern where the browser never holds the raw token at all.

Treating an access token as proof of identity. An access token proves permission to call an API; it says nothing verified about who the user is. If your application needs to know the user’s identity (for a login system, for example), that is what OpenID Connect’s ID token, a signed JWT, is for. Do not decode an opaque OAuth access token and trust whatever you find inside it.

Requesting scopes you do not need (“scope creep”). Every additional scope is something a user has to trust you with and something your own systems now have to protect. Request the minimum your feature actually requires.

Not enforcing single-use, short-lived authorization codes server-side. This tutorial’s authorization server marks a code used the instant it is redeemed and rejects anything expired. Skipping either check, which is easy to do if you only “happy path” test your own implementation, silently reopens the replay and delayed-interception attacks Step 8 walked through.

How to Confirm Everything Works End-to-End

Before considering your implementation solid, walk through this sequence once more from a clean start (restart all three services to clear the in-memory stores). Log in through the browser and confirm /whoami returns Alice’s profile with a 200. Copy the access token from /debug/session and confirm the same request succeeds with curl directly against the resource server, bypassing the client. Wait past the 15-second token lifetime and confirm the same request now returns 401, then call /refresh and confirm a fresh token restores access. Finally, run through the five attack attempts from Step 8 (tampered state, wrong PKCE verifier, replayed code, wrong client secret, and an unregistered redirect_uri) and confirm each is rejected with the specific error shown. If all of that matches, you have a working, correctly enforced OAuth 2.0 Authorization Code flow with PKCE, built and verified by hand rather than taken on faith from a diagram.

Next Steps

Once this flow feels concrete rather than abstract, a few directions are worth exploring next. Try swapping this demo authorization server for a real one by registering an actual OAuth app with GitHub or Google; the concepts transfer directly, though you will need to handle a real user login step you skipped here. Look into OpenID Connect and JWTs to add real authentication (verified identity) on top of the authorization you just built. For anything beyond a learning exercise, use a mature library instead of hand-rolling this logic: Authlib is a solid choice for Python OAuth clients and servers, and most production authorization servers are entire dedicated products (Keycloak, Auth0, or a cloud provider’s own identity platform) rather than something a single team maintains by hand. Finally, read RFC 6749 and RFC 7636 directly now that you have working code to map their language onto; specifications are far easier to parse once you have already built the thing they are describing.

Tags:

AuthorizationFastAPIOAuth 2.0PKCEPython

Share

The Google logo on the glass facade of a building at Google's Googleplex headquarters
Previous Post

Google’s Science One Framework Turns AI-Generated Research Into a Verifiability Test

A 12-inch silicon wafer showing rows of unfinished chip dies under iridescent light
Next Post

Samsung Warns the Global Memory Shortage Will Worsen in 2027 and Persist Through 2028

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026