Application Security

Securing the Modern Web: Implementing OAuth2 PKCE for SPAs and Mobile Clients

For years, the authorization code flow was the gold standard for securing web applications. However, Single-Page Applications (SPAs) and native mobile clients introduced a unique challenge: they are "public clients." Unlike traditional server-side applications, these clients cannot securely store a client secret. This vulnerability opened the door for malicious actors to intercept authorization codes and impersonate legitimate users, a threat known as authorization code interception.

The industry response was the introduction of PKCE (Proof Key for Code Exchange), defined in RFC 7636. PKCE adds a layer of security without requiring a client secret, making it the mandatory standard for OAuth2 in public clients. This post will guide intermediate to advanced developers through the mechanics of PKCE and how to implement it securely.

Why PKCE is Non-Negotiable for Public Clients

Before diving into the code, it is crucial to understand the threat model. In a standard authorization code flow without PKCE, the flow looks like this:

  1. The client redirects the user to the Authorization Server.
  2. The user authenticates and grants permission.
  3. The Authorization Server redirects back to the client with an authorization code.
  4. The client exchanges the code for an access token using its client ID and secret.

In a SPA or mobile app, there is no backend to hold the secret. If an attacker can intercept the authorization code (via XSS, malware, or network sniffing), they can use that code to request an access token, effectively hijacking the user's session. PKCE solves this by binding the authorization code to a cryptographic key that only the client knows at the time of the initial request.

The PKCE Workflow Explained

The PKCE flow introduces two key components: the code_verifier and the code_challenge.

  • Code Verifier: A high-entropy cryptographic random string generated by the client.
  • Code Challenge: A transformed version of the verifier, sent to the Authorization Server during the initial login request.

When the client exchanges the code for a token later, it sends the original code_verifier. The Authorization Server transforms this verifier and compares it to the code_challenge it received earlier. If they match, the token is issued.

Implementation: Generating the Code Verifier and Challenge

The most secure method for generating the challenge is using the S256 transformation. This involves taking the SHA-256 hash of the code verifier and URL-safe Base64 encoding the result.

Here is a practical JavaScript implementation for generating these values in a browser environment:

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

async function createVerifier() {
  // Generate a random string between 43 and 128 characters
  const verifier = generateRandomString(32);
  return verifier;
}

async function createChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await window.crypto.subtle.digest('SHA-256', data);
  
  // Convert to Base64 URL Encoded string
  const base64encoded = btoa(String.fromCharCode(...new Uint8Array(hash)));
  return base64encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

Executing the Authorization Request

Once you have your code_verifier and code_challenge, you must redirect the user to the Authorization Server. The critical addition here is the code_challenge and code_challenge_method=S256 parameters.

const params = new URLSearchParams({
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'http://localhost:3000/callback',
  response_type: 'code',
  scope: 'openid profile email',
  code_challenge: codeChallenge,
  code_challenge_method: 'S256'
});

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

Handling the Token Exchange

After the user grants access, they are redirected back to your redirect_uri with an authorization code in the URL query string. The final step is to exchange this code for an access token. Crucially, you must send the code_verifier you generated at the start of the flow.

const tokenResponse = await fetch('https://auth-server.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: authorizationCode, // From URL query params
    redirect_uri: 'http://localhost:3000/callback',
    client_id: 'YOUR_CLIENT_ID',
    code_verifier: codeVerifier // The original random string
  })
});

const tokens = await tokenResponse.json();

Conclusion

Implementing PKCE is no longer optional for developers building SPAs or mobile applications. By adding just a few lines of cryptographic logic, you protect your users from authorization code interception attacks. While the initial setup requires careful management of state and secure storage of the code_verifier, the long-term benefits to application security are substantial. As the ecosystem moves toward stricter security standards, adopting PKCE ensures your applications remain robust, compliant, and user-trusted.

Share: