How to Implement OAuth 2.0 PKCE for Single Page Apps (SPA)

Single Page Applications (SPAs) face a unique security challenge: they cannot keep a secret. Because the source code is visible in the browser, storing a client_secret is impossible. For years, developers used the Implicit Flow to bypass this, but that method is now officially deprecated due to security vulnerabilities. You must use the OAuth 2.0 PKCE (Proof Key for Code Exchange) extension instead.

PKCE (pronounced "pixie") allows your SPA to securely use the Authorization Code Flow without a client secret. By creating a temporary cryptographic bridge between the authorization request and the token exchange, you prevent attackers from intercepting and using authorization codes. This implementation is not just a recommendation; the upcoming OAuth 2.1 specification mandates PKCE for all OAuth clients, including backends.

TL;DR — Stop using Implicit Flow. Generate a random code_verifier, hash it to create a code_challenge, and send these to your Identity Provider (IdP) to exchange a secure code for an Access Token.

Understanding the PKCE Concept

💡 Analogy: Imagine you call a pizza shop to place an order. Instead of giving them your name, you give them a "secret phrase" but only tell them the hint (the challenge). When the delivery driver arrives, you must provide the actual phrase (the verifier) that matches the hint. If a neighbor tries to steal your pizza using just the hint, they fail because they don't know the original phrase.

PKCE (defined in RFC 7636) introduces three new parameters to the standard OAuth 2.0 flow: the Code Verifier, the Code Challenge, and the Transformation Method. These components work together to ensure that the entity requesting the token is the same entity that started the authorization process.

The core problem PKCE solves is the "Authorization Code Interception Attack." In public clients like mobile apps or SPAs, a malicious actor might intercept the code returned to the redirect URI. Without PKCE, that code is enough to get a token. With PKCE, the attacker would also need the original code_verifier, which never travels over the network during the first step of the handshake. During my testing with modern browser environments, using the Web Crypto API to generate these values ensures high entropy and strong cryptographic resistance.

When to Use PKCE

You should use PKCE for any application that does not have a "trusted" backend to store secrets. This includes JavaScript-heavy SPAs built with React, Vue, or Angular, as well as native mobile applications (iOS/Android) and desktop apps. In these environments, any "secret" you embed in the code can be extracted by an end-user or a malicious script.

Specific scenarios where PKCE is mandatory include:

  • Public Clients: Any app where the binary or source code is accessible to the user.
  • Browser-Based Apps: Even if you have a backend, if your frontend performs the OAuth handshake directly with the IdP, PKCE adds a layer of protection against XSS-based code theft.
  • Modernizing Legacy Auth: If you are still using response_type=token (Implicit Flow), you are likely failing security audits. Migrating to PKCE is the industry-standard fix.

How to Implement PKCE Step-by-Step

Implementing PKCE involves generating cryptographic strings in the browser and making specific HTTP requests to your Authorization Server (like Auth0, Okta, or Keycloak).

Step 1: Create the Code Verifier

The code_verifier is a high-entropy cryptographic random string using the characters [A-Z], [a-z], [0-9], "-", ".", "_", "~", with a length between 43 and 128 characters.

function generateCodeVerifier() {
  const array = new Uint32Array(56);
  window.crypto.getRandomValues(array);
  return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}

const codeVerifier = generateCodeVerifier();
// Store this in sessionStorage to use later
sessionStorage.setItem('pkce_code_verifier', codeVerifier);

Step 2: Create the Code Challenge

The code_challenge is a Base64URL-encoded SHA-256 hash of the code_verifier. This is the "hint" you send to the server.

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await window.crypto.subtle.digest('SHA-256', data);
  
  return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const codeChallenge = await generateCodeChallenge(codeVerifier);

Step 3: Redirect to the Authorization Server

Now, build your authorization URL. You must include the code_challenge and specify code_challenge_method=S256.

const authUrl = `https://auth.example.com/authorize?` +
  `response_type=code&` +
  `client_id=YOUR_CLIENT_ID&` +
  `redirect_uri=YOUR_REDIRECT_URI&` +
  `scope=openid profile email&` +
  `code_challenge=${codeChallenge}&` +
  `code_challenge_method=S256`;

window.location.href = authUrl;

Step 4: Exchange the Code for a Token

After the user logs in, they are redirected back to your app with a code in the URL. Your app must now send this code along with the original code_verifier to the token endpoint.

// After redirect, get the 'code' from URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const originalVerifier = sessionStorage.getItem('pkce_code_verifier');

const response = await fetch('https://auth.example.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: 'YOUR_CLIENT_ID',
    code: code,
    redirect_uri: 'YOUR_REDIRECT_URI',
    code_verifier: originalVerifier // The server verifies this matches the challenge
  })
});

const tokens = await response.json();
console.log(tokens.access_token);

Common Implementation Pitfalls

⚠️ Common Mistake: Storing the code_verifier in localStorage instead of sessionStorage. If a user opens multiple tabs to log in, localStorage can cause collisions. sessionStorage keeps the verifier isolated to the specific tab performing the handshake.

Another frequent issue is incorrect Base64URL encoding. Standard Base64 uses +, /, and =, which are not URL-safe. If you don't replace + with - and / with _ and strip the padding =, the Authorization Server will reject your code_challenge with an "invalid request" error.

XSS (Cross-Site Scripting) remains a threat even with PKCE. While PKCE protects the code during the exchange, it does not protect the access_token once it is stored in your JavaScript memory. Always ensure you have a strong Content Security Policy (CSP) to prevent malicious scripts from accessing your token variables. During my architecture reviews, I often find that developers forget to implement state parameters alongside PKCE; while PKCE handles injection, state handles CSRF. You should use both.

Security Tips and Verification

To verify your implementation, use the browser's Network Tab. When the /token request is sent, you should see the code_verifier in the request body. If the server returns a 401 or 400 error, it likely means the SHA-256 hash you generated does not match what the server expects. Check your encoding logic.

📌 Key Takeaways

  • PKCE is the new standard for all apps, replacing the insecure Implicit Flow.
  • The Verifier stays in the browser and is only sent to the /token endpoint.
  • The Challenge is the hashed version sent to the /authorize endpoint.
  • SHA-256 (S256) is the only transformation method you should use. Avoid the "plain" method.
  • Session isolation is critical; use sessionStorage to store temporary auth state.

For high-security applications, consider using Refresh Token Rotation alongside PKCE. Since SPAs cannot securely store refresh tokens for long periods, rotation ensures that every time a refresh token is used, a new one is issued and the old one is invalidated. This limits the window of opportunity if a token is ever leaked.

Frequently Asked Questions

Q. Why is Implicit Flow deprecated in favor of PKCE?

A. Implicit Flow returns tokens directly in the URL fragment, making them vulnerable to access log leakage and browser history inspection. PKCE uses the Authorization Code Flow, which returns a one-time code. Even if that code is stolen, it is useless without the cryptographic verifier stored in your app's memory.

Q. Can I use PKCE without SHA-256?

A. While the spec allows a plain method, it is highly discouraged. The plain method sends the verifier as the challenge, defeating the purpose of the cryptographic bridge. Always use code_challenge_method=S256 to ensure the highest level of security for your SPA.

Q. Is PKCE only for Single Page Applications?

A. No. While PKCE was originally created for public clients (mobile and SPAs), it is now recommended for server-side applications as well. It provides a strong defense against "authorization code injection" attacks even in environments where a client secret is used.

Post a Comment