Skip to content

OAuth2 Authorization Code with PKCE Deep Dive — Securing Public Clients

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about OAuth2 Authorization Code with PKCE Deep Dive. We cover key concepts, practical examples, and best practices to help you master this topic.

OAuth2 Authorization Code with PKCE (Proof Key for Code Exchange) replaces the deprecated implicit flow by adding a cryptographically random code verifier that prevents authorization code interception attacks.

What You'll Learn

The PKCE flow in detail, S256 code challenge method, verifier and challenge generation, state parameter usage, and implementing PKCE in SPAs and mobile apps.

Why It Matters

The implicit flow is deprecated because access tokens appear in the URL fragment and can be intercepted by malicious extensions or history sniffing. PKCE ensures that even if the authorization code is intercepted, it cannot be exchanged for tokens without the verifier.

Real-World Use

Google, GitHub, and Auth0 all require PKCE for public clients. Durga Antivirus Pro uses PKCE for its browser-based dashboard, ensuring partner tokens cannot be stolen by browser extensions.

sequenceDiagram
    participant SPA as SPA Client
    participant Auth as Auth Server
    participant API as API Server

    SPA->>SPA: Generate code_verifier (random 43-128 chars)
    SPA->>SPA: code_challenge = base64url(sha256(verifier))
    SPA->>Auth: GET /authorize?response_type=code&code_challenge=S256...&code_challenge_method=S256
    Auth->>SPA: Redirect with ?code=AUTH_CODE
    SPA->>Auth: POST /token?code=AUTH_CODE&code_verifier=ORIGINAL_VERIFIER
    Auth->>Auth: Hash verifier, compare with challenge
    Auth->>SPA: { access_token, refresh_token }
    SPA->>API: API calls with Bearer token

Code Example: PKCE Flow Implementation (Client Side)

// PKCE utility functions
function base64URLEncode(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function generateCodeVerifier() {
  const array = new Uint8Array(64);
  crypto.getRandomValues(array);
  return base64URLEncode(array);
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(digest);
}

// Step 1: Initiate PKCE login
async function initiatePKCELogin() {
  const verifier = await generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);

  // Store verifier in session storage (temporary, same session only)
  sessionStorage.setItem('pkce_verifier', verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'dodatech-spa',
    redirect_uri: 'https://app.dodatech.com/callback',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state: crypto.randomUUID(),
    scope: 'openid profile email'
  });

  window.location.href =
    `https://auth.dodatech.com/authorize?${params}`;
}

// Step 2: Handle callback and exchange code
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const verifier = sessionStorage.getItem('pkce_verifier');

  if (!code || !verifier) return;

  const resp = await fetch('https://auth.dodatech.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://app.dodatech.com/callback',
      client_id: 'dodatech-spa',
      code_verifier: verifier
    })
  });

  const tokens = await resp.json();
  sessionStorage.removeItem('pkce_verifier');

  return tokens;
}

Code Example: PKCE Token Exchange (Server Side)

import hashlib, base64, secrets
from flask import Flask, request, jsonify
from urllib.parse import urlencode

app = Flask(__name__)

# In-memory store (use Redis in production)
authorization_codes = {}
clients = {
    "dodatech-spa": {
        "redirect_uris": ["https://app.dodatech.com/callback"],
        "public": True  # No client_secret for public clients
    }
}

def base64url_decode(value):
    return base64.urlsafe_b64decode(value + '==')

def base64url_encode(value):
    return base64.urlsafe_b64encode(value).rstrip(b'=').decode()

def verify_pkce(code_verifier, code_challenge, method):
    if method == 'plain':
        return code_verifier == code_challenge
    elif method == 'S256':
        computed = base64url_encode(
            hashlib.sha256(code_verifier.encode()).digest()
        )
        return computed == code_challenge
    return False

@app.route("/authorize", methods=["GET"])
def authorize():
    client_id = request.args.get("client_id")
    redirect_uri = request.args.get("redirect_uri")
    code_challenge = request.args.get("code_challenge")
    challenge_method = request.args.get("code_challenge_method", "S256")

    client = clients.get(client_id)
    if not client or redirect_uri not in client["redirect_uris"]:
        return "Invalid client", 400

    auth_code = secrets.token_urlsafe(32)
    authorization_codes[auth_code] = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "code_challenge": code_challenge,
        "challenge_method": challenge_method,
        "expires_at": __import__('time').time() + 60
    }

    params = urlencode({"code": auth_code, "state": request.args.get("state", "")})
    return f"Redirect to {redirect_uri}?{params}"

@app.route("/token", methods=["POST"])
def token():
    code = request.form.get("code")
    verifier = request.form.get("code_verifier")
    client_id = request.form.get("client_id")

    stored = authorization_codes.get(code)
    if not stored:
        return jsonify({"error": "invalid_grant"}), 400

    if __import__('time').time() > stored["expires_at"]:
        return jsonify({"error": "code_expired"}), 400

    # PKCE verification
    if not verify_pkce(verifier, stored["code_challenge"], stored["challenge_method"]):
        return jsonify({"error": "invalid_grant", "description": "PKCE verification failed"}), 400

    # Clean up used code
    del authorization_codes[code]

    # Issue tokens
    access_token = secrets.token_urlsafe(32)
    refresh_token = secrets.token_urlsafe(32)

    return jsonify({
        "access_token": access_token,
        "token_type": "Bearer",
        "expires_in": 3600,
        "refresh_token": refresh_token
    })

Code Example: Customizing Code Challenge Method

# Additional method: using SHA-512 for stronger binding
import hashlib

def generate_code_challenge(verifier, method="S256"):
    if method == "S256":
        digest = hashlib.sha256(verifier.encode()).digest()
    elif method == "S512":
        digest = hashlib.sha512(verifier.encode()).digest()
    else:
        return verifier  # plain

    return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

# Verifier validation
def validate_verifier(verifier):
    """Validate code_verifier per RFC 7636."""
    if len(verifier) < 43 or len(verifier) > 128:
        return False
    # Must contain only unreserved characters
    import re
    return bool(re.match(r'^[A-Za-z0-9\-._~]+$', verifier))

Common Mistakes

1. Using 'plain' Code Challenge Method

The plain method sends the verifier directly as the challenge. An attacker who intercepts the authorization request can impersonate the client. Always use S256.

2. Not Validating Redirect URI

The server must validate the redirect_uri against the registered URIs. Without validation, an attacker can use an open redirect to intercept the authorization code.

3. Reusing Authorization Codes

Authorization codes are single-use. Once exchanged, delete them from storage. Reuse detection should revoke all tokens issued with that code.

4. Short Verifier Length

Use at least 64 bytes of random data for the verifier. Short verifiers (below 43 characters) weaken the cryptographic binding.

5. Storing Verifier in localStorage

The verifier should live in sessionStorage or in-memory only. localStorage persists and could be accessed by other tabs or extensions.

Practice Questions

  1. What problem does PKCE solve in the authorization code flow?
  2. How does the S256 code challenge method work?
  3. Why is the 'plain' code challenge method discouraged?
  4. What is the minimum and maximum length of a code verifier?
  5. How does the state parameter complement PKCE?

Answers:

  1. PKCE prevents authorization code interception attacks. Even if an attacker intercepts the code, they cannot exchange it without the verifier.
  2. The client hashes the verifier with SHA-256 and sends the hash as the challenge. The server hashes the presented verifier and compares it with the challenge.
  3. The plain method sends the verifier itself as the challenge. An attacker who sees the authorization request also sees the challenge, which is the verifier.
  4. Minimum 43 characters, maximum 128 characters. Only unreserved characters (A-Z, a-z, 0-9, hyphen, period, underscore, tilde) are allowed.
  5. The state parameter links the authorization request to the callback, preventing CSRF Attacks on the redirect. It complements PKCE which protects the code exchange.

Challenge: Implement a complete PKCE authorization flow with S256 challenge, state parameter validation, authorization code expiry, and refresh token rotation.

FAQ

Is PKCE required for all OAuth2 clients?

It is required for public clients (SPAs, mobile apps) and recommended for confidential clients as a defense-in-depth measure.

Does PKCE replace client_secret?

For public clients, yes. PKCE provides the cryptographic proof that the same client that initiated the flow is completing it.

Can PKCE be used with the implicit flow?

No. PKCE is designed for the authorization code flow. The implicit flow is deprecated and should not be used.

What happens if the verifier is lost?

The authorization code cannot be exchanged. The user must restart the authorization flow. This is why the verifier is stored temporarily.

Does PKCE protect against malware on the device?

No. Malware can intercept the verifier from memory before it is used. PKCE protects against network interception, not device compromise.

How long should the authorization code live?

Authorization codes are valid for 60 seconds typically. Short expiry limits the window for interception.

Mini Project

Build a complete OAuth2 authorization server with PKCE support: /authorize endpoint with code_challenge validation, /token endpoint with verifier exchange, and a test SPA client that generates verifiers and handles the callback.

What's Next

Now explore OAuth2 Client Credentials for machine-to-machine authentication without user interaction.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro