PKCE: Securing OAuth Public Clients in Python
Learn what PKCE is, why it matters for public OAuth clients, and see a Python implementation that secures the authorization code flow.
Focus: PKCE explained
PKCE: Securing OAuth Public Clients with Python
Why PKCE Matters
OAuth 2.0’s Authorization Code flow is the backbone of many web and mobile applications. However, when the client cannot keep a secret (e.g., a single‑page app, a native mobile app, or a desktop client), the classic flow is vulnerable. An attacker who intercepts the authorization request can capture the authorization code and, if the code is reused before it expires, exchange it for an access token.
PKCE (Proof Key for Code Exchange) was introduced to eliminate this risk. It adds a cryptographic tie‑back between the authorization request and the token request, ensuring that only the client that originally initiated the flow can exchange the code for a token. For public clients, PKCE is effectively mandatory.
Core Concepts
Authorization Code Flow (without PKCE)
- Client redirects the user to the authorization endpoint with
client_id,redirect_uri,response_type=code, and other parameters. - Authorization Server validates the request, authenticates the user, and returns an authorization code to the
redirect_uri. - Client sends the code (plus its secret) to the token endpoint.
- Authorization Server verifies the secret, then issues an access token.
In the public‑client scenario the client secret is unavailable, so step 3 is insecure unless a different protection mechanism is used.
PKCE Flow (with PKCE)
- Generate a Code Verifier – a high‑entropy random string (43‑128 characters).
- Derive a Code Challenge – a hash of the verifier (SHA‑256) and then Base64‑URL‑encode it.
- Redirect the user to the authorization endpoint, including
code_challengeandcode_challenge_method=S256. - Receive the authorization code at the
redirect_uri. - POST the code together with the original verifier to the token endpoint.
- Authorization Server hashes the received verifier, compares it to the stored challenge, and only then issues the token.
The verifier never leaves the client, and the challenge is bound to the authorization request, making token theft impossible.
Implementing PKCE in Python
Below is a self‑contained example that demonstrates the entire PKCE exchange using only the standard library and requests. It assumes you have a test client registration with an OAuth provider that supports PKCE (e.g., Auth0, Okta, or a custom test server).
import base64
import hashlib
import secrets
import urllib.parse
import requests
from http.server import BaseHTTPRequestHandler, HTTPServer
# ----------------------------------------------------------------------
# Helper functions for PKCE
# ----------------------------------------------------------------------
def generate_verifier(length=128):
"""Create a cryptographically strong verifier."""
return base64.urlsafe_b64encode(secrets.token_bytes(length)).decode('utf-8').rstrip('=')
def generate_challenge(verifier):
"""Produce the PKCE code challenge (Base64‑URL‑encoded SHA‑256)."""
sha256 = hashlib.sha256(verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(sha256).decode('utf-8').rstrip('=')
# ----------------------------------------------------------------------
# Simple HTTP server to receive the redirect with the authorization code
# ----------------------------------------------------------------------
class AuthCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if 'code' in query:
self.server.auth_code = query['code'][0]
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(b"<html><body><h2>Authentication successful! You can close this window.</h2></body></html>")
else:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Missing code parameter.")
def log_message(self, format, *args):
# Suppress default logging to keep output clean
pass
# ----------------------------------------------------------------------
# Main flow
# ----------------------------------------------------------------------
def main():
# ---- 1. Configuration (replace with your provider's values) ----
client_id = "YOUR_CLIENT_ID"
redirect_uri = "http://localhost:8000/callback"
auth_endpoint = "https://your-auth-server.com/authorize"
token_endpoint = "https://your-auth-server.com/oauth/token"
# ---- 2. Generate PKCE values ----
verifier = generate_verifier()
challenge = generate_challenge(verifier)
# Keep the verifier in memory for the token request
# (In a real app you might store it in a session or secure store)
# ---- 3. Build the authorization URL ----
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": "openid profile email", # adjust as needed
}
auth_url = f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
print("Open this URL in a browser:")
print(auth_url)
# ---- 4. Start a local server to capture the redirect ----
server = HTTPServer(('localhost', 8000), AuthCallbackHandler)
# Run the server in a separate thread so we can continue
import threading
thread = threading.Thread(target=server.handle_forever, daemon=True)
thread.start()
# ---- 5. Wait for the user to complete login ----
print("\nAfter you log in, the browser will redirect to the callback URL.")
print("The server will capture the authorization code automatically.\n")
input("Press Enter to continue...")
# ---- 6. Exchange the code for tokens ----
if not hasattr(server, 'auth_code'):
raise RuntimeError("No authorization code received.")
token_data = {
"grant_type": "authorization_code",
"code": server.auth_code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": verifier,
}
token_response = requests.post(token_endpoint, data=token_data)
token_response.raise_for_status()
tokens = token_response.json()
print("\nToken response:")
print(tokens)
if __name__ == "__main__":
main()How the Example Works
- PKCE Generation –
generate_verifiercreates a random string;generate_challengehashes it with SHA‑256 and URL‑encodes the result. - Authorization Request – The URL built in step 3 includes
code_challengeandcode_challenge_method=S256. - Local Callback Server – A tiny HTTP server listens on
localhost:8000/callback. When the user authorizes the app, the provider redirects to this endpoint with acodequery parameter. - Token Exchange – The server posts the
codetogether with the originalcode_verifierto the token endpoint. The provider validates the verifier against the stored challenge and returns tokens.
You can replace the placeholders (YOUR_CLIENT_ID, auth_endpoint, token_endpoint) with values from your own OAuth provider. The flow works the same for Auth0, Okta, Azure AD, or any RFC‑compliant server.
Real‑World Example: Using Auth0
Auth0 provides a straightforward PKCE implementation for native apps. Below is a trimmed version that shows only the essential parts; you can embed it into a Flask or FastAPI service.
import requests
import base64
import hashlib
import secrets
AUTH_DOMAIN = "YOUR_TENANT.auth0.com"
CLIENT_ID = "YOUR_CLIENT_ID"
REDIRECT_URI = "http://localhost:5000/callback"
SCOPES = ["openid", "profile", "email"]
def pkce_verifier():
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
def pkce_challenge(verifier):
sha256 = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(sha256).decode('utf-8').rstrip('=')
def authorize_url():
verifier = pkce_verifier()
challenge = pkce_challenge(verifier)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": " ".join(SCOPES),
}
return f"https://{AUTH_DOMAIN}/authorize?{requests.compat.urlencode(params)}", verifier
# Example usage:
url, verifier = authorize_url()
print("Visit this URL:", url)
# After login, the user is redirected to REDIRECT_URI?code=...In a production service you would:
- Store
verifierin a user‑specific session (e.g., Flask’s signed cookie or a server‑side cache). - Verify the
code_verifierwhen exchanging the code for tokens athttps://YOUR_TENANT.auth0.com/oauth/token. - Use HTTPS everywhere; never expose the verifier in logs or URLs.
Common Pitfalls
| Pitfall | Why It Breaks PKCE | Fix |
|---|---|---|
| Using SHA‑1 instead of SHA‑256 | The spec requires S256. SHA‑1 is deprecated and rejected by many providers. |
Always hash with hashlib.sha256. |
| Mismatched verifier storage | If the verifier is not available when exchanging the code, the request fails. | Keep the verifier in a short‑lived session or in memory only; never write it to disk unencrypted. |
| Incorrect Base64‑URL encoding | Missing padding (=) or using regular Base64 will cause verification errors. |
Use base64.urlsafe_b64encode and strip trailing = characters. |
| Replay attacks | Reusing a code without a fresh verifier can succeed if the code is not single‑use. | Most providers treat each code as single‑use; ensure you exchange immediately after receipt. |
Forgetting to enforce code_challenge_method=S256 |
Some providers reject plain challenges, weakening security. |
Always set code_challenge_method to S256. |
Summary
PKCE adds a cryptographic proof that the client which started the OAuth Authorization Code flow is the same one exchanging the code for tokens. By generating a random verifier, hashing it into a challenge, and sending that challenge during the authorization request, public clients gain the same security guarantees that confidential clients have with a client secret.
The Python example above shows a minimal, production‑ready implementation: generate PKCE values, build the authorization URL, capture the redirect, exchange the code, and verify the verifier. Adapting the code to frameworks like Flask, FastAPI, or even a simple http.server is straightforward.
Remember to:
- Use SHA‑256 (
S256) for the challenge. - Keep the code verifier secret and short‑lived.
- Validate the code verifier against the stored challenge before issuing tokens.
With these practices, your Python applications can safely use OAuth 2.0 in public client scenarios, protecting user credentials and ensuring robust authentication.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.