PKCE for OAuth: Securing Public Clients on Edge Networks

· Michael Chen · 10 min read

Single-page applications running in the browser cannot keep secrets. Every line of JavaScript is visible to the user, their browser extensions, and any malicious code injected through XSS vulnerabilities. When these applications use OAuth 2.0 to authenticate users, the authorization code that returns via the browser redirect is exposed to interception — by rogue extensions, compromised operating systems, or network attackers. PKCE (Proof Key for Code Exchange, RFC 7636) closes this vulnerability by binding each authorization code to the specific client instance that initiated the request. For engineers building edge-delivered applications, PKCE is particularly powerful when the CDN edge itself acts as the OAuth client, keeping all secrets — client credentials, code verifiers, and tokens — at the edge layer where the browser can never access them.

What Problem Does PKCE Solve in OAuth 2.0?

The OAuth 2.0 authorization code flow was designed for confidential clients — server-side applications that can securely store a client secret. The client proves its identity by including the client secret in the token exchange request. If an attacker steals the authorization code from the redirect URI, they cannot exchange it without the secret.

Public clients like single-page applications and mobile apps broke this security model. They cannot store client secrets because their code runs on untrusted devices. Without a secret, anyone who captures the authorization code can exchange it for tokens. The authorization code travels through the browser's URL bar during the redirect callback, making it vulnerable to several interception vectors: malicious browser extensions that monitor navigation events, custom URI scheme hijacking on mobile operating systems, browser history accessible to other applications, and shared device scenarios where the redirect URL is logged.

PKCE Flow Preventing Authorization Code Interception PKCE: Proof Key for Code Exchange CDN Edge OAuth Client (BFF) Browser User's device Auth Server IdP / OAuth provider 1. Generate PKCE pair verifier = random(43-128 chars) challenge = SHA256(verifier) Store verifier in Edge KV 2. redirect 3. /authorize + challenge User authenticates 4. callback + code 5. code to edge 6. Exchange code + verifier → tokens code + verifier (server-to-server) Attacker intercepts code Cannot exchange — no verifier!
Figure 1: PKCE flow at the CDN edge — the edge generates the code verifier, stores it in KV, and sends only the challenge through the browser. Even if an attacker intercepts the authorization code, they cannot exchange it without the verifier.

PKCE solves this by introducing a one-time proof that binds the token exchange to the authorization request. Before starting the flow, the client generates a cryptographically random string called the code verifier. It then computes a code challenge by hashing the verifier with SHA-256. The challenge goes to the authorization server in the initial request (through the browser), while the verifier stays with the client. At token exchange, the client sends the original verifier, and the server confirms it matches the stored challenge. An attacker who intercepts the authorization code cannot produce the verifier because it was never transmitted through the browser.

How Does the CDN Edge Implement PKCE as a Backend for Frontend?

The most secure pattern for edge-delivered applications is the Backend for Frontend (BFF) model, where the CDN edge handles the entire OAuth flow on behalf of the browser. The browser never sees client secrets, authorization codes, or access tokens. It interacts only with session cookies set by the edge. This architecture eliminates the "public client" problem entirely — the edge is a confidential client that can store secrets, and PKCE adds an additional security layer on top.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages
// Step 1: User requests protected resource
// Edge detects no session cookie
// Generate PKCE pair
const verifier = generateRandomString(64); // a-z, A-Z, 0-9, -._~
const challenge = base64url(sha256(verifier));

// Store verifier in Edge KV (keyed by state parameter)
const state = generateRandomString(32);
await edgeKV.put(`pkce:${state}`, verifier, { ttl: 300 });

// Redirect user to authorization server
const authUrl = new URL("https://idp.example.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", TENANT_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", "https://app.example.com/callback");
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");

return Response.redirect(authUrl.toString(), 302);

// Step 2: Callback arrives at edge
// Extract code and state from query parameters
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");

// Retrieve stored verifier
const verifier = await edgeKV.get(`pkce:${state}`);
if (!verifier) return new Response("Invalid state", { status: 400 });

// Exchange code for tokens (server-to-server, never touches browser)
const tokenResponse = await fetch("https://idp.example.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.example.com/callback",
    client_id: TENANT_CLIENT_ID,
    client_secret: TENANT_CLIENT_SECRET,
    code_verifier: verifier  // PKCE proof
  })
});

// Clean up: delete used verifier
await edgeKV.delete(`pkce:${state}`);

// Set session cookie, redirect to original URL
const session = createSession(tokenResponse.id_token);
return new Response(null, {
  status: 302,
  headers: {
    "Location": originalUrl,
    "Set-Cookie": `session=${session}; Secure; HttpOnly; SameSite=Lax`
  }
});

In this model, the browser's only involvement is carrying the user to the authorization server and back via redirects. The code verifier lives exclusively in edge KV storage, the code-for-token exchange happens server-to-server from the edge to the IdP, and the resulting tokens are stored at the edge (never sent to the browser). The browser receives only an opaque, HttpOnly, Secure, SameSite=Lax session cookie that it cannot read or manipulate.

What Are the S256 and Plain Code Challenge Methods?

PKCE defines two methods for computing the code challenge from the code verifier. The choice of method directly affects the security guarantees PKCE provides.

The plain method sends the code verifier itself as the code challenge: challenge = verifier. This means the challenge value in the authorization request URL is identical to the verifier needed for token exchange. If an attacker can observe both the authorization request (in the browser's address bar or network logs) and the callback, they have everything needed to exchange the code. The plain method protects only against attackers who can intercept the callback but cannot see the initial authorization request — a narrow threat model.

The S256 method sends a SHA-256 hash: challenge = BASE64URL(SHA256(verifier)). Even if an attacker observes the challenge in the authorization request URL, they cannot reverse the SHA-256 hash to obtain the verifier. The authorization server stores only the challenge hash, and during token exchange, it recomputes SHA256(submitted_verifier) and compares it to the stored challenge. This provides genuine security against all interception scenarios because the verifier is never exposed outside the client.

S256 vs Plain Code Challenge Methods Code Challenge Methods Compared method: plain (NOT recommended) verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r..." challenge = verifier (same value) • Challenge visible in browser URL bar • Attacker sees challenge = knows verifier • Only protects against callback-only intercept Security: Minimal Deprecated in OAuth 2.1 method: S256 (REQUIRED) verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r..." challenge = BASE64URL(SHA256(verifier)) = "E9Melhoa2OwvFrEMTJguCHaoeK1t8..." • Challenge is one-way hash, not reversible • Attacker sees hash but cannot get verifier • Protects against all interception vectors Security: Strong
Figure 2: S256 vs plain code challenge methods — S256 hashes the verifier so the challenge cannot be reversed, while plain exposes the verifier in the authorization URL.

RFC 7636 requires authorization servers to support S256 and permits them to support plain. OAuth 2.1 effectively mandates S256 by requiring PKCE for all flows and making the plain method insufficient for security compliance. At the CDN edge, always use S256 — the SHA-256 computation adds negligible latency (microseconds on modern hardware) and provides meaningful security guarantees.

Why Does OAuth 2.1 Require PKCE for Confidential Clients Too?

The original PKCE specification (RFC 7636) positioned it as a solution for public clients that cannot store secrets. OAuth 2.1 extends the requirement to all clients, including confidential ones. This decision reflects lessons from real-world attacks and a defense-in-depth philosophy.

Client secrets authenticate the application, not the session. A valid client secret proves that the token exchange request comes from a legitimate instance of the application. It does not prove that the authorization code being exchanged was received by this specific session of the application. In a CDN edge deployment with multiple Points of Presence, an authorization code could theoretically be intercepted and exchanged at a different edge node using the same client credentials.

PKCE provides session-level binding. The code verifier is generated uniquely for each authorization request and stored in per-session state at the edge. Even if an attacker compromises the client secret through a configuration leak, they cannot exchange intercepted authorization codes without the per-session verifiers stored in edge KV. The combination of client secret (application authentication) plus PKCE verifier (session binding) provides two independent security layers.

For CDN edge implementations, this means the edge should always include both client_secret and code_verifier in the token exchange request, regardless of whether the authorization server technically requires PKCE for confidential clients. The marginal cost is negligible (one extra parameter in the token exchange, one KV read/write for the verifier), and the security benefit is significant.

What Are the Edge-Specific Considerations for PKCE Implementation?

CDN edge environments introduce unique challenges for PKCE implementation that don't exist in traditional server-side applications. The distributed nature of edge computing means that the node handling the initial authorization request may not be the same node that receives the callback.

Code verifier storage must be globally accessible. When the edge generates a PKCE pair, the verifier is stored in edge KV keyed by the state parameter. The callback, containing the same state value, may arrive at any edge node in the CDN network — the user's browser might be routed to a different geographic PoP for the return trip. Edge KV stores with global replication (propagation under 500 milliseconds) handle this seamlessly, but the authorization server's code expiration timeout must accommodate the replication delay. Setting the authorization code lifetime to 60 seconds (the OAuth 2.0 recommendation) provides ample margin.

Verifier cleanup prevents KV storage bloat. Not every authorization flow completes — users abandon the login form, browsers crash, network interruptions occur. Set a TTL of 300 seconds on verifier entries so that abandoned flows automatically clean up. The edge should also delete the verifier immediately after successful token exchange to prevent replay attempts.

Cryptographic random generation at the edge must use platform-appropriate APIs. The code verifier requires 256 bits of entropy minimum for S256 security. Edge runtimes typically provide crypto.getRandomValues() (Web Crypto API) which draws from the operating system's CSPRNG. Never generate verifiers using Math.random() or timestamp-based seeds — these produce predictable values that an attacker could guess, defeating PKCE's purpose entirely.

For monitoring, track PKCE-specific metrics: verifier KV lookup failures (indicate replication lag or callback routing issues), state parameter mismatch rates (possible CSRF attacks), and token exchange failures with invalid_grant errors (possible code interception attempts where the attacker's exchange fails because it lacks the verifier). A sudden increase in invalid_grant errors may indicate an active code interception campaign that PKCE is successfully blocking.

Frequently Asked Questions

What is PKCE and why was it created?

PKCE (Proof Key for Code Exchange, RFC 7636) is an OAuth 2.0 extension that protects the authorization code flow against interception attacks. It was created for public clients (mobile apps, SPAs) that cannot store client secrets. The client generates a random code verifier, sends its SHA-256 hash (code challenge) to the authorization server, and proves possession of the verifier during token exchange. Since OAuth 2.1, PKCE is required for all clients.

How does PKCE prevent authorization code interception attacks?

The code verifier is generated by the client and never sent through the browser. Only its SHA-256 hash (code challenge) travels in the authorization request URL. An attacker who intercepts the authorization code from the callback cannot exchange it because they don't have the code verifier. The authorization server rejects any exchange where the submitted verifier doesn't match the stored challenge hash.

What is the difference between S256 and plain code challenge methods?

The plain method sends the verifier itself as the challenge — an attacker who sees the authorization request URL knows the verifier. S256 sends a SHA-256 hash, making the challenge irreversible. S256 protects against all interception vectors, while plain only protects against callback-only intercept. OAuth 2.1 effectively requires S256.

Should confidential clients use PKCE even though they have client secrets?

Yes. OAuth 2.1 mandates PKCE for all clients. Client secrets authenticate the application but don't prove the authorization code was received by this specific session. PKCE adds session-level binding: even with a compromised client secret, attackers cannot exchange stolen codes without per-session verifiers stored in edge KV. The cost is negligible (one extra parameter, one KV operation).

How does the CDN edge implement PKCE for applications it protects?

The edge acts as a Backend for Frontend (BFF). It generates the code verifier, stores it in edge KV keyed by the state parameter, computes the S256 challenge, and redirects the user to the authorization server with the challenge. After the callback returns the code, the edge retrieves the stored verifier, exchanges code + verifier + client secret for tokens (server-to-server), and sets an opaque session cookie. The browser never touches verifiers, secrets, or tokens.

External References

References & Standards: Cloudflare documentation · W3C Web Performance