TL;DR
OAuth2 is easy to use and hard to understand. The requests that matter most happen server-to-server, invisible to the browser. This tool makes them visible: a local two-server playground that runs the flows step by step, pauses at each stage, and explains what is moving through the front channel versus the back channel.
The Problem
Reading the OAuth2 spec and watching the HTTP traffic are two different experiences.
Most developers integrate OAuth2 by copying a library example and moving on. That works until something breaks and you have to debug a token exchange you have never seen. Or until you are building something in the identity security space and need to know, specifically, which parts of the flow an attacker can intercept and which they cannot.
The front channel (browser redirects) is visible. The back channel (server-to-server token exchange) is not. That distinction matters more than most OAuth2 tutorials make clear.
How It Works
The playground runs two local servers:
- Auth Server (port 8000): The IdP simulation. Handles
/authorize,/token,/introspect. Stores clients, users, and codes in memory. - Client App (port 8001): The relying party. Initiates flows, handles callbacks, displays results and the live tracer.
Three flows are supported:
- Authorization Code + PKCE — the recommended flow for public clients. No client secret. PKCE proves the token request came from the same app that started the login.
- Authorization Code with secret — for confidential clients that can store a secret.
- Client Credentials — machine-to-machine. No user, no browser, no front channel at all.
The Guided Step-Through
Each flow has a guided mode that pauses at every stage. Before each step executes, the page shows:
- The exact parameters being sent
- A plain-English explanation of what they do and why
- Which channel this step uses (front or back)
- A security note on what could go wrong
After the step executes, the response parameters appear with the same treatment.
PKCE pair generation (client/main.py):
# Generates a code_verifier (secret, stays in app) and code_challenge
# (SHA-256 hash, sent to auth server). The verifier is never sent to the browser.
def pkce_pair() -> tuple[str, str]:
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
return verifier, challenge
PKCE verification on the auth server (server/main.py):
# Auth server hashes the verifier and checks it against the stored challenge.
# If they match, the token request came from the same party that started the login.
def verify_pkce(code_verifier: str, code_challenge: str, method: str = "S256") -> bool:
if method == "S256":
digest = hashlib.sha256(code_verifier.encode()).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return computed == code_challenge
return code_verifier == code_challenge
What I Learned Building It
The first version of the guided flow had a bug that is worth documenting.
After a successful token exchange, the handler redirected the browser to the next step using HTTP 307 (Temporary Redirect). RFC 7231 says 307 preserves the original HTTP method. The browser had POST’d to the step handler, which called the token endpoint (success, code consumed), then returned a 307. The browser preserved the POST and submitted again — this time with an already-consumed code. Error: Invalid or expired code.
The fix is HTTP 303 (See Other), which instructs the browser to GET the redirect target regardless of the original method. This is the Post-Redirect-Get pattern.
# Wrong: browser re-POSTs, calling the token endpoint twice
return RedirectResponse(url="/guided/pkce/step4")
# Correct: browser GETs the redirect target (Post-Redirect-Get)
return RedirectResponse(url="/guided/pkce/step4", status_code=303)
This bug is not just a web development detail. In production OAuth implementations, authorization codes are single-use. Real authorization servers detect reuse and may revoke all tokens issued to that client as a replay attack mitigation. The playground hit this behavior exactly as designed.
Security Note
The playground is local-only. No external services. Test users and secrets are hardcoded for learning purposes — none of this is production-ready. What it does model correctly: PKCE replaces client secrets for public clients, authorization codes are single-use, back-channel calls never expose tokens to the browser.
Run It
git clone https://github.com/ahimsauzi/oauth2-playground
cd oauth2-playground
pip install -r requirements.txt
python run.py
# Open http://localhost:8001