Add PKCE to the Auth Code Flow
Learn to add PKCE to the authorization code flow in this OAuth 2 · OpenID Connect lesson — practical steps, edge cases, and what to study next.
Focus: add pkce to the authorization code flow
You've built your first authorization code flow. It works. The user clicks Sign in, your app redirects to the provider, you exchange the code for tokens, and the user is happily authenticated. But there's a quiet, gaping hole in that flow: what if a malicious app intercepts the authorization code? In the classic flow, an attacker who grabs that code can exchange it for tokens — unless you use PKCE (Proof Key for Code Exchange, pronounced 'pixy'). This lesson walks you through adding PKCE to the authorization code flow, turning a vulnerable pattern into a hardened one. By the end, you'll understand why PKCE exists, how to implement it step by step, and how to make it painless in Python.
The Problem This Lesson Solves
The authorization code flow was designed for confidential clients — apps that can keep a secret. But modern apps are often public clients: single-page apps, mobile apps, desktop apps, or serverless functions that cannot securely store a client_secret. In these cases, your client ID is public, and anyone could impersonate your app.
The specific vulnerability that PKCE solves is the authorization code interception attack. It works like this:
- Your app sends a user to the authorization server with a
client_idandredirect_uri. - The provider returns an authorization code to that redirect URI.
- An attacker — perhaps running a malicious app on the same device or intercepting network traffic — could steal that code.
- If the attacker uses that stolen code to request tokens before your legitimate app does, they've just gained access tokens, and your user's data is compromised.
In the traditional flow, the client_secret protects the token exchange. Without it, code interception is a real threat. PKCE solves this without requiring a secret. It adds a dynamic, single-use secret that only the legitimate app knows, making code interception useless.
Real-world impact: Major OAuth providers now recommend (or require) PKCE for all clients, even confidential ones. Google, Okta, Auth0, and Microsoft Entra all support PKCE. Learning it isn't optional — it's becoming the default.
Core Concept / Mental Model
Think of PKCE as a handshake with a pre-agreed password. Before your app sends its user to the provider, you and the provider agree on a secret phrase. You use that phrase to create a challenge, and only someone who knows the original phrase can prove they own it when exchanging the code.
It works like this: imagine you write a message in a box, lock it with a padlock, and hand the box to someone to deliver. You keep the key hidden. When the delivery person reaches the destination, they can't open the box without the key — but you can verify it's you by opening another lock with the same key.
The pieces in PKCE are:
- Code Verifier: A random, high-entropy string (between 43 and 128 characters) that your app generates. This is the "secret phrase."
- Code Challenge: A transformed version of the verifier, sent with the authorization request. The transformation uses SHA-256 (or, rarely, the plain text itself). This is the "padlock."
- Authorization Code: The short-lived code returned by the provider after the user authorizes.
- Token Request: Your app sends the authorization code along with the code verifier to the token endpoint. The provider checks that the verifier matches the challenge — and only then issues tokens.
Because the verifier is never sent with the authorization request and is random each time, an attacker who intercepts the code and challenge cannot compute or guess the verifier. The stolen code becomes worthless.
How It Works Step by Step
PKCE plugs into the standard authorization code flow without changing the user experience. Here's the exact choreography:
-
Generate the verifier and challenge (in your app, before redirect). - Generate
code_verifier: a random string of 43–128 characters. Use a cryptographically secure random source. - Computecode_challenge = BASE64URL(SHA256(code_verifier)). The base64url encoding omits=padding and uses-and_instead of+and/. -
Initiate the authorization request. - Redirect the user to the provider's
/authorizeendpoint, addingcode_challengeandcode_challenge_method=S256. - Keep theclient_id,redirect_uri,response_type=code, and optionallyscope. -
Receive the authorization code. - The provider authenticates the user, asks for consent, and redirects back to your
redirect_uriwith?code=.... -
Exchange the code for tokens. - Send a POST to the
/tokenendpoint with thecodeand thecode_verifier. - The provider recomputes the challenge from the verifier and compares it to the one stored earlier. If they match, tokens are issued. -
Beware of the verifier lifecycle. - The verifier must be stored securely between steps 2 and 4, typically in memory or a secure store. It should be used once and discarded.
Pro tip: Always use
S256as the challenge method. Plain text (plain) is allowed by some providers but offers no protection if the challenge is intercepted — you'd be sending the secret itself.
Hands-On Walkthrough
Let's implement PKCE in a minimal Python example. We'll use only the standard library and a tiny HTTP server, so you can follow along without extra dependencies.
Step 1: Generate Verifier and Challenge
import hashlib
import secrets
import base64
def generate_pkce_pair():
# Generate a code_verifier: 64 random bytes, base64url-encoded (no padding)
verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b'=').decode()
# Compute the challenge using SHA-256
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()
return verifier, challenge
This gives you a verifier like J3m... and a challenge like K8x.... The verifier is unique for every authorization attempt.
Step 2: Build the Authorization URL
from urllib.parse import urlencode
AUTH_ENDPOINT = "https://provider.example.com/oauth2/authorize"
CLIENT_ID = "your-public-client-id"
REDIRECT_URI = "http://localhost:8080/callback"
def build_authorization_url(verifier, challenge):
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile email",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": secrets.token_urlsafe(16),
}
return f"{AUTH_ENDPOINT}?{urlencode(params)}", params["state"]
You'll use the state parameter to tie the callback to the original request. It prevents CSRF, another form of interception.
Step 3: Exchange the Code for Tokens
Once the user is redirected back with ?code=...&state=..., you exchange the code:
import urllib.request
import urllib.parse
TOKEN_ENDPOINT = "https://provider.example.com/oauth2/token"
def exchange_code_for_token(auth_code, code_verifier):
data = {
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": code_verifier,
}
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(TOKEN_ENDPOINT, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
Note we don't send a client_secret — PKCE replaces it for public clients.
Putting It All Together (Minimal Server)
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/callback"):
# parse code and state from query
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
code = query.get("code", [None])[0]
state = query.get("state", [None])[0]
if state != self.server.stored_state:
self.send_response(400)
self.end_headers()
return
tokens = exchange_code_for_token(code, self.server.stored_verifier)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(tokens).encode())
else:
# start flow: generate PKCE, store, redirect
verifier, challenge = generate_pkce_pair()
auth_url, state = build_authorization_url(verifier, challenge)
self.server.stored_verifier = verifier
self.server.stored_state = state
self.send_response(302)
self.send_header("Location", auth_url)
self.end_headers()
if __name__ == "__main__":
server = HTTPServer(("localhost", 8080), Handler)
print("Server running on http://localhost:8080")
server.serve_forever()
Expected output: When you visit http://localhost:8080, you'll be redirected to the provider's login page. After authenticating, you'll see the token JSON in your browser. The token endpoint will happily accept your request — because you sent the correct verifier.
Compare Options / When to Choose What
PKCE is not a magic bullet; it's a protocol layer that fits into different app architectures. Here's how it compares to similar approaches:
| Approach | Best for | Security | Implementation effort | PKCE needed? |
|---|---|---|---|---|
| Authorization Code + PKCE (public client) | SPAs, mobile, desktop, CLI | High — no secret to leak, code interception mitigated | Low-to-medium (adds 2 params and a hash) | Yes, required |
| Authorization Code + client_secret (confidential client) | Server-side web apps, APIs | High — secret stored securely | Medium (secret management) | Recommended (defense-in-depth) |
| Client Credentials | Machine-to-machine | High — uses secret, no user context | Low | Not applicable (no user authorization) |
| Implicit Flow (legacy) | Old SPAs | Poor — token in URL, no code exchange | Low | Replaced by PKCE code flow |
If you control both ends (client and authorization server), you might consider custom headers or mTLS, but PKCE is the industry standard for TLS-secured OAuth – it adds a per-request secret without the operational burden of certificate management.
Troubleshooting & Edge Cases
Error: invalid_grant during token exchange — Often because the code verifier doesn't match the challenge. Double-check that you used the same verifier in both steps and that it hasn't been regenerated. Also ensure the authorization code hasn't expired (usually 5–10 minutes) and hasn't been used already.
Challenge method mismatch — If your server only supports S256, but you send plain or plain method, it will fail. Always inspect the provider's metadata (they expose supported methods at /.well-known/oauth-authorization-server).
State parameter not matching — You might have forgotten to validate state in the callback. Ignore it and you're vulnerable to CSRF; validate it but use the wrong stored value, you'll get false negatives. Keep state in memory or secure cookie tied to the session.
PKCE in native apps — Some mobile SDKs have limitations on custom schemes. Use a secure loopback redirect or claim-based URI but keep PKCE — the verifier is still your friend.
Base64 URL encoding issues — If you generate a verifier with + or /, you'll hve problems. Always use urlsafe_b64encode and strip =. Python's standard library does this if you follow the example.
Storage of the verifier — Avoid storing it in browser localStorage (XSS can exfiltrate it). In-memory (or secure session) is fine for short-lived flows.
What You Learned & What's Next
You've now added PKCE to the authorization code flow, making your public clients resilient to code interception. You can explain the core idea: the code challenge is a hash of a secret known only to your app. You've completed a practical exercise with the verifier/challenge generation and a full token exchange. This is a critical step in securing your OAuth 2 integrations.
Next in the track, you'll explore refresh tokens — how to keep users logged in without asking them to re-authenticate, and how to manage token lifetimes securely. Or you might jump into ID tokens and OpenID Connect, which build on the code flow to give you user identity claims.
Keep your verifier secret, your challenge method S256, and your state validation tight. You're well on your way to being an OAuth professional.
Practice recap
Run the minimal server example, and modify it to log the verifier and challenge at each step. Then break it on purpose: send the wrong verifier in the token exchange. See the invalid_grant error — that's your proof PKCE works. Next lesson, you'll add refresh tokens to keep your users seamlessly authenticated.
Common mistakes
- Using the same PKCE verifier for multiple authorization attempts — verifiers must be unique per flow; reusing leaks correlation.
- Sending the code_verifier in the query string during token exchange — it should be in the POST body only.
- Storing the verifier in localStorage or a cookie that can be read by an XSS attack; instead keep it in memory or a secure session.
- Forgetting to validate the state parameter, leaving your flow open to CSRF.
- Choosing plain code_challenge_method when S256 is supported — plain sends the secret in a reversible form.
Variations
- Use a third-party library like
requests-oauthliborauthlibthat handles PKCE generation and exchange for you. - In a mobile or SPA, leverage the provider's SDK (e.g., MSAL, Auth0) which automatically implements PKCE for code flow.
- For server-side flows, some providers allow hash-based challenge with HMAC instead of SHA-256; check your provider's spec.
Real-world use cases
- A single-page React app that talks to a protected API — PKCE prevents token theft from browser-based code interception.
- A mobile banking app implementing OAuth — PKCE adds a required layer of protection against malicious apps intercepting the auth code.
- A command-line tool that authenticates users via OAuth — PKCE eliminates the need to bundle a client secret.
Key takeaways
- PKCE adds a dynamic, single-use verifier that protects the code exchange without a client_secret.
- Always use SHA-256 based code challenge (method=S256) for secure transformation.
- The authorization request sends a challenge; the token request sends the original verifier; the server matches them.
- Validate the state parameter to prevent CSRF, and keep the verifier secure in memory or a secure store.
- PKCE is best practice for all client types, even confidential ones, as defense-in-depth.
- You can implement PKCE in pure Python with standard library functions — no special OAuth library required.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.