# OAuth 2.0 PKCE Explained: Why Public Clients Need It and How to Implement It

> A deep dive into PKCE for OAuth 2.0 public clients — what the attack it prevents looks like, how the flow works step by step, and how to implement it correctly.

- **Published:** 2025-12-15
- **Author:** Clixo
- **Reading time:** 6 min read
- **Tags:** oauth2, pkce, security, authentication, mobile, spa
- **Canonical URL:** https://clixo.sh/blog/oauth2-pkce-public-clients-deep-dive

If you are building a mobile app, a single-page application, or a CLI tool that uses OAuth 2.0, you need PKCE. Authorization codes without PKCE can be intercepted and exchanged for tokens by a malicious app on the same device, or leaked via referrer headers or browser history. The fix is not complex, but it requires understanding why the attack is possible and what PKCE actually does to prevent it.

This is a deep dive into PKCE for OAuth 2.0 public clients: the attack model, the mechanism, and a correct implementation walkthrough.

## What Is a Public Client and Why Does It Matter

OAuth 2.0 distinguishes between confidential clients and public clients.

A **confidential client** is a server-side application that can hold a client secret securely. The secret lives on the server, never exposed to the browser or device. When the authorization code comes back, the client authenticates with the secret before exchanging the code for tokens. This client authentication step prevents stolen codes from being exchanged by an attacker.

A **public client** cannot hold a secret securely. Native mobile apps can be decompiled and the secret extracted. Single-page applications run entirely in the browser where any secret is visible. CLI tools distributed to end users cannot contain secrets. When a public client presents its `client_id` in the token exchange, there is no secret to verify — anyone who obtains the authorization code can exchange it.

PKCE solves this problem for public clients.

## The Authorization Code Interception Attack

On mobile platforms, multiple apps can register as handlers for the same custom URL scheme (`myapp://callback`). If a malicious app registers the same scheme, the OS may deliver the OAuth callback — including the authorization code — to the wrong app.

Even on web platforms, codes can leak via referrer headers if the authorization server redirects to a page that makes external requests, or via browser history if logging is misconfigured.

Without PKCE, an intercepted authorization code is immediately useful: the attacker makes a standard token exchange request and receives access tokens.

## How PKCE Prevents the Attack

PKCE (Proof Key for Code Exchange, RFC 7636) works by binding the authorization code to the client that generated it, using a secret known only at request time.

**The flow:**

1. **Before redirecting to the authorization server**, the client generates a `code_verifier`: a cryptographically random string, 43-128 characters, using characters from the unreserved set `[A-Z a-z 0-9 - . _ ~]`.

2. The client computes the `code_challenge` from the verifier: `BASE64URL(SHA256(code_verifier))`. This is the `S256` method — always use `S256`, never `plain`.

3. The client sends the `code_challenge` and `code_challenge_method=S256` to the authorization server along with the standard authorization request. The server stores the challenge tied to this authorization session.

4. The user authenticates and authorizes. The server issues an authorization code.

5. **To exchange the code for tokens**, the client sends the `code_verifier` along with the standard token request.

6. The authorization server hashes the `code_verifier` using the method specified earlier and compares it to the stored `code_challenge`. If they match, the exchange proceeds. If they do not match, the request is rejected.

An attacker who intercepts the authorization code does not have the `code_verifier` — it was never transmitted over the network. They cannot compute it from the `code_challenge` because SHA-256 is a one-way function. The intercepted code is useless.

```mermaid
sequenceDiagram
  participant C as Public client
  participant AS as Auth server
  participant RS as Resource server
  C->>C: Generate code_verifier
  C->>C: code_challenge = BASE64URL(SHA256(verifier))
  C->>AS: Auth request + code_challenge + S256
  AS->>AS: Store challenge with session
  AS->>C: Redirect with auth code
  C->>AS: Token request + code + code_verifier
  AS->>AS: SHA256(verifier) == stored challenge?
  AS->>C: Access token + refresh token
  C->>RS: API call with access token
```

## Implementation Walkthrough

### Generating the code verifier and challenge

```js
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

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

Store the `code_verifier` in `sessionStorage` for SPAs, or in memory for mobile apps. It needs to survive the redirect but not beyond the current session.

### Authorization request

Append to your authorization URL:

```
&code_challenge=BASE64URL_ENCODED_CHALLENGE
&code_challenge_method=S256
```

### Token exchange

Include in your token request body:

```
code_verifier=YOUR_VERIFIER_VALUE
```

No client secret is needed (public clients do not have one). The PKCE exchange serves as proof of possession in its place.

## Scopes and PKCE Together

PKCE solves the code interception problem. It does not solve the over-permissioned scope problem. These are separate concerns that require separate design decisions.

Scope best practices for public clients:

- Request only the scopes you need for the current operation.
- Use incremental authorization: request `read` permissions by default, request `write` permissions only when the user takes a write action.
- Do not request offline access (refresh token issuance) unless you have a specific need and a plan to store the refresh token securely.

## Checking Authorization Server Support

Before relying on PKCE, confirm the authorization server requires it for public clients. Some servers accept PKCE as optional — which means an attacker who skips PKCE during the authorization request can exchange a stolen code without a verifier.

Well-configured authorization servers should **require** PKCE for public clients, not merely support it. If you control the authorization server, enforce this. If you are using a third-party provider, check their documentation and test that token exchange requests without a verifier are rejected.

## Deprecation of Implicit Flow

The implicit flow returned tokens directly in the URL fragment, bypassing the code exchange entirely. It was designed for SPAs before PKCE existed. OAuth 2.1 deprecated it.

If you have any remaining implicit flow integrations, migrate them to authorization code with PKCE. The token-in-fragment design leaks tokens into browser history, referrer headers, and server logs in ways that are difficult to control.

PKCE is a well-designed, lightweight addition to the OAuth 2.0 flow that materially reduces the attack surface for public clients. Most OAuth libraries support it with a single configuration option. There is no good reason to ship a public client without it.

If you are building OAuth integrations and want them done correctly from the start, [Clixo designs and implements production auth systems](https://clixo.sh/#contact) for teams that cannot afford rework later.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
