OAuth 2.0 Security for Developers: PKCE, Implicit Flow Risks, and Secure Token Storage

OAuth 2.0 is the standard for delegated authorization in web and mobile applications, but implementation mistakes create exploitable vulnerabilities — authorization code interception, token leakage via referrer headers, and insecure client-side storage. This guide covers the secure patterns and what to avoid.

OAuth 2.0 is the authorization framework used by virtually every application that integrates with external identity providers or exposes an API. Used correctly, it provides delegated access without sharing credentials. Implemented incorrectly, it leaks access tokens, enables authorization code interception, and introduces cross-site request forgery vectors that can compromise user sessions.

This guide covers the current secure OAuth 2.0 patterns, the historical flows that should no longer be used, and the token storage decisions that determine whether stolen tokens can be exploited.

The Flows and Why It Matters Which One You Use

Authorization Code Flow + PKCE (the current standard)

The Authorization Code Flow with PKCE (Proof Key for Code Exchange) is the correct choice for virtually every OAuth 2.0 integration — both server-side applications and single-page applications. PKCE was originally designed for mobile apps that couldn’t securely store a client secret, but it is now recommended for all public clients.

How PKCE works:

  1. The client generates a random code_verifier (minimum 43 characters, maximum 128, URL-safe base64)
  2. The client derives a code_challenge by hashing the verifier: code_challenge = BASE64URL(SHA256(code_verifier))
  3. The authorization request includes code_challenge and code_challenge_method=S256
  4. The authorization server issues an authorization code tied to the challenge
  5. The token exchange includes the original code_verifier, which the server verifies against the stored challenge

This prevents authorization code interception: even if an attacker intercepts the authorization code (via a malicious redirect URI, a referrer header leak, or the browser history), they cannot exchange it for tokens without the code_verifier, which never leaves the client.

Python implementation (server-side token exchange):

import secrets
import hashlib
import base64
import urllib.parse

def generate_pkce_pair():
    # Generate a cryptographically secure code verifier
    code_verifier = secrets.token_urlsafe(64)  # 86-character URL-safe string
    
    # Derive the code challenge using S256 method
    digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
    
    return code_verifier, code_challenge

def build_authorization_url(auth_endpoint, client_id, redirect_uri, scope, state, code_challenge):
    params = {
        'response_type': 'code',
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'scope': scope,
        'state': state,  # CSRF protection
        'code_challenge': code_challenge,
        'code_challenge_method': 'S256',
    }
    return f"{auth_endpoint}?{urllib.parse.urlencode(params)}"

def exchange_code_for_tokens(token_endpoint, code, code_verifier, client_id, redirect_uri):
    import httpx
    response = httpx.post(token_endpoint, data={
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': redirect_uri,
        'client_id': client_id,
        'code_verifier': code_verifier,  # Verifies the PKCE challenge
    })
    response.raise_for_status()
    return response.json()

JavaScript SPA implementation:

// Secure OAuth 2.0 + PKCE for single-page applications
async function generatePKCE() {
  const array = new Uint8Array(64);
  crypto.getRandomValues(array);
  const codeVerifier = btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  
  return { codeVerifier, codeChallenge };
}

async function initiateLogin() {
  const { codeVerifier, codeChallenge } = await generatePKCE();
  const state = crypto.randomUUID(); // CSRF protection
  
  // Store verifier and state in sessionStorage (not localStorage)
  // They're only needed for the duration of the auth flow
  sessionStorage.setItem('pkce_code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);
  
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });
  
  window.location.href = `${AUTH_ENDPOINT}?${params}`;
}

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const returnedState = params.get('state');
  
  // Validate state to prevent CSRF
  const expectedState = sessionStorage.getItem('oauth_state');
  if (returnedState !== expectedState) {
    throw new Error('State mismatch — potential CSRF attack');
  }
  
  const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
  sessionStorage.removeItem('pkce_code_verifier');
  sessionStorage.removeItem('oauth_state');
  
  // Exchange code for tokens
  const response = await fetch(TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: codeVerifier,
    }),
  });
  
  return response.json();
}

Implicit Flow: Do Not Use

The implicit flow returns tokens directly in the authorization response URL fragment (#access_token=...). It was designed for environments where a server-side token exchange was not possible. It is now deprecated in OAuth 2.1 for two reasons:

  1. Tokens appear in the URL fragment, which leaks to browser history, server logs if the redirect target is logged, and any JavaScript executing in the page context (third-party scripts, browser extensions)
  2. No PKCE equivalent exists for the implicit flow — authorization code interception is not defended against

If you have an existing application using the implicit flow, migrate to Authorization Code + PKCE. All major identity providers (Auth0, Okta, Entra ID, Cognito) support PKCE for public clients.

Client Credentials Flow

For machine-to-machine authentication (service accounts, background jobs, API clients without a user context), use the client credentials flow. The client authenticates with client_id and client_secret directly against the token endpoint — no user interaction, no redirect.

The client secret must be protected as a credential: stored in a secrets manager, never in source code, rotated on a defined schedule, and scoped to the minimum required permissions.

Token Storage: The Decision That Determines Exploitability

Where you store OAuth tokens determines what an attacker can do with an XSS vulnerability or a compromised third-party script.

Access token storage options

StorageXSS AccessiblePersistsRecommendation
localStorageYesYesNever for access tokens
sessionStorageYesUntil tab closeAcceptable for short-lived tokens only
HttpOnly cookieNoYes (if persistent)Preferred for server-side apps
In-memory (JS variable)During attack onlyUntil page refreshBest for SPAs

For SPAs without a backend: Store access tokens in memory (JavaScript module-level variable) and refresh tokens in sessionStorage. This means a page refresh requires re-fetching the access token using the refresh token, but it significantly limits XSS impact: a token stored in memory is only accessible if an XSS payload executes during the session, and only from that tab.

// Token storage module — in-memory storage for access tokens
let _accessToken = null;
let _tokenExpiry = null;

export function setAccessToken(token, expiresIn) {
  _accessToken = token;
  _tokenExpiry = Date.now() + (expiresIn * 1000) - 60000; // Refresh 60s before expiry
}

export function getAccessToken() {
  if (_accessToken && Date.now() < _tokenExpiry) {
    return _accessToken;
  }
  return null; // Trigger refresh
}

export function clearTokens() {
  _accessToken = null;
  _tokenExpiry = null;
  sessionStorage.removeItem('refresh_token');
}

For SPAs with a backend: use the BFF pattern. The Backend-for-Frontend pattern routes OAuth token management through a server-side component. The browser receives an HttpOnly session cookie; the server holds OAuth tokens and exchanges them for API calls on behalf of the user. This completely eliminates browser-side token storage and removes access tokens from XSS reach.

# BFF pattern: Flask session stores tokens, client gets HttpOnly session cookie
from flask import Flask, session, redirect, request
from functools import wraps

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

@app.route('/api/data')
def get_data():
    # Browser sends session cookie; server retrieves stored OAuth token
    access_token = session.get('access_token')
    if not access_token:
        return {'error': 'unauthorized'}, 401
    
    # Server makes the API call — token never reaches the browser
    response = httpx.get('https://api.example.com/data',
                         headers={'Authorization': f'Bearer {access_token}'})
    return response.json()

Common Vulnerabilities and How to Prevent Them

Open Redirect in the Redirect URI

OAuth implementations that accept a redirect_uri parameter without strict validation can be exploited to redirect the authorization code to an attacker-controlled domain.

ALLOWED_REDIRECT_URIS = {
    'https://app.example.com/callback',
    'https://staging.example.com/callback',
}

def validate_redirect_uri(redirect_uri: str) -> bool:
    # Exact match only — no prefix matching, no wildcard
    return redirect_uri in ALLOWED_REDIRECT_URIS

Register redirect URIs with your identity provider at the exact-match level. Many providers support wildcard matching for subdomains — avoid this. An open redirect in any page on your domain converts to an authorization code hijacking path.

Missing CSRF Protection on the Callback

The state parameter provides CSRF protection: the client generates a random value, includes it in the authorization request, and verifies it matches when the callback arrives. Without state validation, a CSRF attack can force a user to complete an OAuth flow with an attacker’s authorization code (account linking attacks, session fixation).

// Always validate state in the callback handler
function handleOAuthCallback(callbackParams) {
  const receivedState = callbackParams.get('state');
  const expectedState = sessionStorage.getItem('oauth_state');
  
  // Constant-time comparison to prevent timing attacks
  if (!receivedState || receivedState !== expectedState) {
    throw new SecurityError('Invalid OAuth state parameter');
  }
  
  sessionStorage.removeItem('oauth_state'); // Consume the nonce
}

Token Leakage via Referrer Headers

If access tokens are placed in URLs (Implicit Flow, or poor implementations that put tokens in query parameters), any outbound link from that page will include the token in the Referer header to the destination server.

Always use rel="noreferrer" on outbound links and set Referrer-Policy: strict-origin-when-cross-origin in response headers. More importantly, keep tokens out of URLs entirely.

Refresh Token Rotation

Refresh tokens should be rotated on every use — the identity provider issues a new refresh token with each access token refresh, and invalidates the old one. This limits the damage from a stolen refresh token: each use invalidates the previous token, so an attacker using a stolen refresh token triggers invalidation of the legitimate client’s token, creating a detectable anomaly.

def refresh_access_token(refresh_token: str) -> dict:
    response = httpx.post(TOKEN_ENDPOINT, data={
        'grant_type': 'refresh_token',
        'refresh_token': refresh_token,
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,  # Server-side confidential clients only
    })
    tokens = response.json()
    
    # Store the NEW refresh token — the old one is now invalid
    store_refresh_token(tokens['refresh_token'])  # Replaces the old value
    return tokens

Quick Reference: OAuth 2.0 Security Checklist

  • Using Authorization Code Flow + PKCE (not Implicit Flow)
  • state parameter generated and validated in callback
  • Redirect URIs registered as exact-match values with the IdP
  • Access tokens stored in memory or HttpOnly cookies (not localStorage)
  • Refresh token rotation enabled in the identity provider
  • Client secrets stored in a secrets manager, not in code or environment variables
  • Token endpoint protected with HTTPS only
  • code_challenge_method=S256 (not plain)
  • IdP token endpoint uses POST (access token never in URL)
  • BFF pattern considered for SPAs with sensitive data access