Securing Public OAuth Clients with PKCE: A Practical Guide
Public OAuth clients can’t keep secrets. PKCE lets them exchange an authorization code securely by adding a code challenge and verifier. This guide walks through the mechanics, shows a Node.js example, and covers trade‑offs and best‑practice checks.
11 Jul 2026, 03:54 UTC

Problem: Public Clients Can’t Hide Secrets
When a web app, mobile app, or single‑page application talks to an OAuth 2.0 provider, it’s called a public client. Unlike a server‑side application, a public client can’t keep a client secret safe – the code lives in the browser or on a device that any attacker could inspect. The classic authorization‑code flow becomes vulnerable because an attacker could intercept the temporary authorization code and exchange it for an access token.
Thesis: PKCE Turns the Code Exchange Into a One‑Way Street
The Proof Key for Code Exchange (PKCE) extension was designed to solve exactly that problem. By adding a short‑lived cryptographic “code challenge” to the initial request and a matching “code verifier” to the token request, PKCE ensures that only the legitimate client can redeem the authorization code. The result is a secure flow that requires no client secret and works in any environment.
How PKCE Works
- Code Verifier – A random string of 43–128 characters (usually URL‑safe base64). It’s generated by the client and kept in memory.
- Code Challenge – Derived from the verifier. The most common method is SHA‑256 followed by base64url encoding (denoted "S256"). The plain text method is also allowed but only if the server declares support.
- Authorization request includes
code_challengeandcode_challenge_method. - Token request includes the original
code_verifier. - The authorization server validates that the verifier matches the stored challenge before issuing an access token.
Step‑by‑Step Implementation (Node.js Example)
- Generate the PKCE pair
const crypto = require('crypto'); function base64urlEncode(buffer) { return buffer.toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } const verifier = base64urlEncode(crypto.randomBytes(32)); // 43–128 chars const challenge = base64urlEncode(crypto.createHash('sha256').update(verifier).digest()); - Build the authorization URL
const params = new URLSearchParams({ response_type: 'code', client_id: 'YOUR_CLIENT_ID', redirect_uri: 'https://yourapp.com/callback', scope: 'openid profile email', code_challenge: challenge, code_challenge_method: 'S256', }); const authUrl = `https://auth.example.com/authorize?${params.toString()}`; - Redirect the user – Open
authUrlin the browser. The user logs in and consents. - Handle the callback – The server receives
codeas a query parameter. - Exchange the code for tokens
const tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: callbackCode, redirect_uri: 'https://yourapp.com/callback', client_id: 'YOUR_CLIENT_ID', code_verifier: verifier, }), }); const tokens = await tokenResponse.json(); - Use the access token – Attach
Authorization: Bearer <access_token>to API calls.
Remember: keep verifier in memory only; once the token exchange completes, discard it. Storing it in local storage exposes it to XSS attacks.
Checking PKCE Support on the Server
Not all providers advertise PKCE by default. Before hard‑coding code_challenge_method=S256, fetch the provider’s discovery document (https://provider.com/.well-known/openid-configuration) and confirm the code_challenge_methods_supported array contains S256. If the array is missing or empty, the server may ignore or reject your challenge.
Trade‑offs and Limitations
- Overhead – Generating a random verifier and computing a SHA‑256 hash is trivial (microseconds) and adds no network latency.
- Compatibility – Some legacy providers only support the plain method. In that case, you must use
code_challenge_method=plainand ensure the server declares support. - Server‑side validation – If the provider’s implementation is buggy, an attacker could still replay the code. Verify the provider’s documentation and test the flow thoroughly.
- Code Reuse – Never reuse a verifier or challenge across different authorization requests. Each flow should have a fresh pair.
Actionable Take‑aways
- Always use PKCE for native, mobile, and single‑page apps.
- Generate a fresh 32‑byte random verifier per flow and store it only in memory.
- Prefer the SHA‑256 method; fall back to plain only if the discovery document explicitly allows it.
- Validate the provider’s discovery document before deployment.
- Write automated tests that perform a full PKCE flow against a test provider to confirm token issuance only after the correct verifier is presented.
Conclusion
PKCE is a lightweight, battle‑tested extension that removes the need for client secrets in public OAuth clients. By following the steps above, you can protect your users from authorization code interception attacks without adding complexity to your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.