Alexander Garcia
Differences between OAuth 2.0's Proof Key for Code Exchange (PKCE) and Client Authentication Private Key JWT
Read time is about 16 minutes
Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.
Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.
Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.
Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.
Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.
Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.
When it comes to OAuth 2.0, there are two fundamentally different ways to prove a client's identity — Proof Key for Code Exchange (PKCE) and Client Authentication Private Key JWT. I spent over five years implementing both of these at VA.gov on the Identity team. PKCE secured every Veteran-facing login on the site (web, mobile, native apps), while Private Key JWT powered the machine-to-machine Service Token Service (STS) that let backend systems authenticate without a human in the loop.
Both methods exist to solve the same core problem: how does the authorization server know it's talking to the real client and not an impersonator? But they solve it in completely different ways, for completely different use cases. Understanding when to use each one is critical to getting your OAuth implementation right.
/authorize, /token, /refresh)/token and /refresh) — no user interactionclient_secretProof Key for Code Exchange, or PKCE, is an extension of OAuth 2.0 designed to secure native, mobile, and web applications — basically anything that has a "keyboard & mouse." PKCE adds additional security to the Authorization Code flow by binding the access token to a specific device and user. It accomplishes this by using state, a code verifier, and a code challenge to prevent Authorization Code interception attacks (also known as Man-in-the-Middle or MITM attacks).
Before PKCE existed, public clients (like single-page apps) had to use the Implicit flow, which returned tokens directly in the URL fragment. That was always a security compromise — tokens in URLs get logged, cached, and leaked through referrer headers. PKCE made the Authorization Code flow safe for public clients by replacing the static client_secret with a one-time cryptographic proof.
To use PKCE, the client application generates a state, code_verifier, and a code_challenge.
code_verifier is a one-time random secret string (minimum of 64 characters long)state is a one-time random string, (minimum of 28 characters long)code_challenge, a hashed value (usually SHA256) of the code_verifier.The client application generates both the code_verifier and state and saves it in storage (either Local Storage or Session Storage). It then sends the code_challenge to the authorization server when requesting an Authorization Code.
/* OAuth 2.0 Authorize (/authorize) with PKCE */ // Generate a state const state = generateRandomString(28); localStorage.setItem("state", state); // Generate a code verifier const codeVerifier = generateRandomString(64); localStorage.setItem("code_verifier", codeVerifier); // Generate a code challenge const codeChallenge = await sha256(codeVerifier); const authorizeUrl = ` <authorization-server-url>/authorize/ ?client_id=client_requesting_auth &response_type=code &state=982407f7db5a35f6bd8bdcd83264476ce1d2f7d170246dbbcb7086b4 &code_challenge=sCHXzXpnKE5V9z_upOqcxhBx1ihW_L4qqwGPxMcE9TY &code_challenge_method=S256 `;
The authorization server verifies the code_challenge and responds with the Authorization Code code and the initial state. The client application (aka "the browser") then checks to see if the state is the same as the original request and requests to exchange the authorization code for an Access Token, which is then used to access protected resources.
/* OAuth 2.0 Token Exchange (/token) with PKCE */ // User lands on a callback URL that has `state` and `code` window.location = `<client-url>/callback/ ?code=319311a3bb3f13f98f8f3ace3fa23j &state=982407f7db5a35f6bd8bdcd83264476ce1d2f7d170246dbbcb7086b4`; const savedLocalStorageState = localStorage.getItem("state"); const queryParameterState = new URL(window.location).searchParams.get("state"); /* Compare state saved in LocalStorage (during initial `/authorize`) with the `/callback/?state=<should_be_same_state> */ if (savedLocalStorageState === queryParameterState) { // if state matches, request code for an access token const url = ` <authorization-server-url>/token/ ?client_id=client_requesting_auth &grant_type=authorization_code &code=319311a3bb3f13f98f8f3ace3fa23j &code_verifier=175618f6eb0acc38891b3037b37df7de20f901ade08ad9124804... `; const response = await fetch(url); // request for token const data = await response.json(); // keep access tokens SECURE! } else { throw new Error("state did not match request"); }
Client Authentication Private Key JWT, also known as OAuth JWT, allows clients to authenticate with the authorization server using a private key instead of a shared client_secret. This method is typically used by server-side applications that require high levels of security — think backend services calling other backend services with no human involved.
The problem with a traditional client_secret is that it's a shared secret. Both the client and the authorization server know it, which means it can be leaked, logged, or intercepted. Private Key JWT eliminates this risk entirely: the client holds the private key and never shares it. The authorization server only ever sees the public key, which can't be used to impersonate the client.
At VA.gov, we used this for the Service Token Service (STS) — the system that let backend services like the VA mobile API authenticate to downstream systems without piggybacking on a Veteran's session. No browser, no redirect, no user interaction. Just cryptographic proof that the calling service is who it claims to be.
To use Private Key JWT, the client application generates a private/public key pair. The client registers the public key with the authorization server (usually during initial setup). When the client needs an access token, it creates a JWT with specific claims (iss, sub, aud, iat, exp), signs it with its private key, and sends it to the token endpoint. The authorization server verifies the signature using the registered public key and responds with an access token.
const jwt = require('jsonwebtoken'); const axios = require('axios'); // Generate a private/public key pair const { privateKey, publicKey } = jwt.generateKeyPairSync(); // Request an access token signed with the private key const jwtPayload = { iss: 'client-id', sub: 'client-id', aud: '<https://auth.example.com/token>', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60, }; // Sign the JWT token with the generated private key const token = jwt.sign(jwtPayload, privateKey, { algorithm: 'RS256' }); // Create request for `/token` endpoint with the following options const response = axios.post('<https://auth.example.com/token>', { // REQUIRED grant_type: 'client_credentials', // Optional, client_id already in signed JWT client_id: 'client-id', client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: token, //PHnbzW0...ZT }); // Use access token const accessToken = await response.data;
This is the question that matters most. The decision comes down to two things: who is the client and is there a human involved?
Use PKCE when:
/authorize → /token → /refresh)Use Private Key JWT when:
client_secret)The key distinction: PKCE proves the client that started the authorization flow is the same one finishing it (anti-interception). Private Key JWT proves the client is who it claims to be, period (identity assertion). They solve different problems for different architectures.
At VA.gov, we needed both. Veterans logging in through the browser used PKCE via our custom OAuth SDK. The VA mobile app's backend used Private Key JWT to call the Service Token Service for machine-to-machine tokens. Same authorization server, different authentication methods, each purpose-built for its use case.
| PKCE | Private Key JWT | |
| Encryption | Symmetric usually (HMAC) SHA-256 | Asymmetric (RSA Signature) usually with SHA-256 |
Client Type | Web (single-page applications), native, and mobile | Machine-to-machine or server-side applications (CLI, API-to-API, etc) |
| Secret | One-time random string | Encrypted JWT with Private Key (Public Key is exposed to Authorization Server) |
Neither method is universally "more secure" — they're secure for different threat models:
PKCE security considerations:
code_verifier must be cryptographically random (not just Math.random()) — use crypto.getRandomValues() or Node's crypto.randomBytes()sessionStorage is safer than localStorage because it's scoped to a single tab and cleared when the tab closesstate parameter prevents CSRF attacks, but only if you actually validate it on the callback (I've seen plenty of implementations that skip this)Private Key JWT security considerations:
client_secret, the signed JWTs have short expiration (exp), limiting the blast radiusaud claim must match the authorization server's token endpoint exactly — this prevents token confusion attacks where a JWT meant for one server is replayed against anotherIt's worth noting that OAuth 2.1 (the in-progress update to the OAuth spec) makes PKCE mandatory for all Authorization Code flows — not just public clients. This means even confidential clients that use the Authorization Code flow will need PKCE. The Implicit flow is being removed entirely. This validates what many of us in the identity space have been saying for years: PKCE should have always been required.
Private Key JWT remains the recommended client authentication method for confidential clients, replacing client_secret_post and client_secret_basic as the stronger alternative.
Both PKCE and Client Authentication Private Key JWT are effective methods for securing client credentials in OAuth 2.0. The method you choose depends on the type of application you're building and the threat model you're defending against. In many production systems, you'll end up implementing both — PKCE for your user-facing applications and Private Key JWT for your backend services.
If you want to learn more about these topics, check out oauth.net, oauth.com, or the IETF OAuth RFCs. And if you're interested in how we implemented both of these at scale on VA.gov, check out my post on Five Years to Launch: The Sign-in Service Story.