Security & Authentication

OAuth 2.0 PKCE for Public Clients: SPAs, Mobile, and CLI Apps

Deep dive into Proof Key for Code Exchange — why client secrets fail for public clients, PKCE flow mechanics, and implementation in React, mobile, and CLI apps.

The Problem with Public Clients

OAuth 2.0 was originally designed around confidential clients — server-side applications that can store a client_secret securely. The authorization code flow works well there: the secret proves to the authorization server that the code exchange request is legitimate.

But single-page apps (SPAs), mobile apps, and CLI tools are public clients. They cannot keep secrets:

  • A SPA's JavaScript is delivered to every user's browser — anyone can read the client_secret in the source
  • A mobile app's binary can be reverse-engineered to extract secrets
  • A CLI tool's source code is often public

The authorization code flow without a client secret is vulnerable to authorization code interception. On mobile, a malicious app can register the same custom URL scheme as your app and steal the authorization code when the browser redirects. Without a client secret, the attacker can then exchange the stolen code for tokens.

PKCE: Proof Key for Code Exchange

PKCE (RFC 7636, pronounced "pixy") solves this without requiring a client secret. It works by creating a one-time cryptographic proof that links the authorization request to the token exchange request.

Step-by-Step Flow

Step 1: Generate code_verifier

Before redirecting to the authorization server, generate a random secret:

// Generate a cryptographically random code_verifier
function generateCodeVerifier(): string {
  const array = new Uint8Array(64);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}

Step 2: Derive code_challenge

Hash the verifier with SHA-256:

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

Step 3: Authorization request

Include the challenge (not the verifier) in the authorization request:

GET /authorize
  ?response_type=code
  &client_id=my-spa
  &redirect_uri=https://app.example.com/callback
  &scope=openid profile email
  &state=random-csrf-token
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

The authorization server stores the challenge alongside the authorization code.

Step 4: Token exchange

After the user authorizes, exchange the code for tokens — sending the original verifier this time:

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_FROM_CALLBACK
&redirect_uri=https://app.example.com/callback
&client_id=my-spa
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The authorization server hashes the verifier and compares it to the stored challenge. If they match, the exchange succeeds. An attacker who intercepted the authorization code doesn't have the verifier — they cannot exchange the code.

SPA Implementation with React

Use an established OIDC client library rather than implementing the flow yourself:

npm install oidc-client-ts react-oidc-context
// main.tsx
import { AuthProvider } from 'react-oidc-context';

const oidcConfig = {
  authority: 'https://auth.example.com',
  client_id: 'my-spa',
  redirect_uri: window.location.origin + '/callback',
  scope: 'openid profile email',
  // PKCE is enabled by default in modern OIDC libraries
};

ReactDOM.createRoot(document.getElementById('root')!).render(
  <AuthProvider {...oidcConfig}>
    <App />
  </AuthProvider>
);

Token Storage in SPAs

Never store tokens in localStorage — it's accessible to any JavaScript on the page, including injected XSS. Options:

StorageXSS RiskCSRF RiskNotes
`localStorage`HighNoneAvoid for access tokens
`sessionStorage`HighNoneSlightly better — tab-isolated
Memory (JS variable)LowNoneBest for access tokens; lost on refresh
`HttpOnly` cookieNoneMediumBest for refresh tokens; mitigate CSRF with SameSite

The recommended pattern: store access tokens in memory, store refresh tokens in HttpOnly SameSite=Strict cookies (set by your backend). Use silent refresh (hidden iframe or background request) to obtain new access tokens.

Mobile Implementation

On mobile, PKCE should be combined with system browser usage:

  • Use SFSafariViewController (iOS) or Chrome Custom Tab (Android)
  • Never use WebView for OAuth — the app can intercept credentials
  • Use Universal Links (iOS) or App Links (Android) for redirect URIs

to ensure only your app handles the callback

// iOS: Using AppAuth SDK with PKCE
import AppAuth

let request = OIDAuthorizationRequest(
    configuration: configuration,
    clientId: "my-ios-app",
    clientSecret: nil,  // nil for public clients
    scopes: [OIDScopeOpenID, OIDScopeProfile],
    redirectURL: URL(string: "com.example.myapp:/callback")!,
    responseType: OIDResponseTypeCode,
    additionalParameters: nil
)  // AppAuth generates code_verifier and code_challenge automatically

CLI Implementation

For CLI tools, the standard approach is a localhost redirect:

  • CLI starts a temporary local HTTP server on a random port
  • Opens the browser to the authorization URL with redirect_uri=http://localhost:PORT/callback
  • User authenticates in their browser
  • Authorization server redirects to localhost — the CLI server captures the code
  • CLI exchanges the code (with PKCE verifier) for tokens
  • Stores tokens in the OS credential store (Keychain on Mac, Credential Manager on Windows)

For headless environments (CI/CD), consider the Device Authorization Grant (RFC 8628) instead — it displays a URL and code for the user to visit on any device.

関連プロトコル

関連用語

もっと見る: Security & Authentication