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:
- The client generates a random
code_verifier(minimum 43 characters, maximum 128, URL-safe base64) - The client derives a
code_challengeby hashing the verifier:code_challenge = BASE64URL(SHA256(code_verifier)) - The authorization request includes
code_challengeandcode_challenge_method=S256 - The authorization server issues an authorization code tied to the challenge
- 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:
- 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)
- 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
| Storage | XSS Accessible | Persists | Recommendation |
|---|---|---|---|
localStorage | Yes | Yes | Never for access tokens |
sessionStorage | Yes | Until tab close | Acceptable for short-lived tokens only |
| HttpOnly cookie | No | Yes (if persistent) | Preferred for server-side apps |
| In-memory (JS variable) | During attack only | Until page refresh | Best 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)
-
stateparameter 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