Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Sponsored Reserved space — layout preview until AdSense is connected

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

Sponsored

Sponsored Reserved space — layout preview until AdSense is connected

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)

  1. Client redirects the user to the authorization endpoint with client_id, redirect_uri, response_type=code, and other parameters.
  2. Authorization Server validates the request, authenticates the user, and returns an authorization code to the redirect_uri.
  3. Client sends the code (plus its secret) to the token endpoint.
  4. 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)

  1. Generate a Code Verifier – a high‑entropy random string (43‑128 characters).
  2. Derive a Code Challenge – a hash of the verifier (SHA‑256) and then Base64‑URL‑encode it.
  3. Redirect the user to the authorization endpoint, including code_challenge and code_challenge_method=S256.
  4. Receive the authorization code at the redirect_uri.
  5. POST the code together with the original verifier to the token endpoint.
  6. 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).

Code snippet
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

  1. PKCE Generationgenerate_verifier creates a random string; generate_challenge hashes it with SHA‑256 and URL‑encodes the result.
  2. Authorization Request – The URL built in step 3 includes code_challenge and code_challenge_method=S256.
  3. 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 a code query parameter.
  4. Token Exchange – The server posts the code together with the original code_verifier to 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.

Code snippet
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 verifier in a user‑specific session (e.g., Flask’s signed cookie or a server‑side cache).
  • Verify the code_verifier when exchanging the code for tokens at https://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:

  1. Use SHA‑256 (S256) for the challenge.
  2. Keep the code verifier secret and short‑lived.
  3. 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.

Sponsored

Sponsored Reserved space — layout preview until AdSense is connected

Sponsored

Sponsored Reserved space — layout preview until AdSense is connected

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.