OAuth 2.0 Authorization Code Flow Security: How to Implement PKCE, Validate State, and Avoid the Redirect URI and Token Leakage Pitfalls

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
OAuth 2.0 is not a specification for a single protocol. It is a framework for authorization flows, and the security properties of each flow depend on whether the implementation correctly applies all the required security controls. The authorization code flow with PKCE is the current recommended pattern for essentially all use cases. The common implementation mistakes are not exotic: they are missing state parameters, overly broad redirect URIs, tokens appearing in browser history via URL fragments, and access tokens stored in localStorage where XSS can steal them.
How the Authorization Code Flow with PKCE Works
The complete flow for a public client (SPA or mobile app):
Step 1: Client generates PKCE challenge
// Generate a cryptographically random code_verifier
const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
// Derive the code_challenge via SHA-256
const codeChallenge = base64URLEncode(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
);
// Store code_verifier securely for later use (sessionStorage or memory)
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
Step 2: Build the authorization request
const state = base64URLEncode(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem('oauth_state', state);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Step 3: Authorization server redirects back with code
The authorization server validates the user's consent and redirects to redirect_uri?code=AUTHORIZATION_CODE&state=STATE.
Step 4: Client validates state and exchanges code
// Validate state matches what was sent
const returnedState = new URLSearchParams(window.location.search).get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
throw new Error('State mismatch -- potential CSRF attack');
}
// Exchange code for tokens using code_verifier
const code = new URLSearchParams(window.location.search).get('code');
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'your-client-id',
code_verifier: codeVerifier,
}),
});
Missing State Parameter: CSRF-Based Account Hijacking
The state parameter is a CSRF token for the OAuth flow. Without it, an attacker can execute a CSRF attack that links their account to the victim's account, or forces the victim's browser to complete an OAuth flow that the attacker initiated.
The attack (missing state):
- Attacker initiates an OAuth flow at the target site with their own identity at the IdP
- Attacker receives a valid authorization code but does not visit the callback URL
- Attacker crafts a URL to the target site's callback with their authorization code:
https://target.com/callback?code=ATTACKER_CODE - Attacker tricks the victim into visiting this URL (via email link, img tag, etc.)
- Target site exchanges the code for a token (the attacker's identity), links it to the victim's session
- Victim is now logged in as, or their account is connected to, the attacker's identity
Fix: always generate a cryptographically random state parameter (at least 128 bits), store it in sessionStorage or a secure cookie before the redirect, and validate it exactly before processing the callback. Reject any callback that does not include a matching state value.
The state parameter can also carry application state (the page the user was trying to reach before authentication) in addition to its CSRF function. Encode this as a JSON object within the state value, signed with an HMAC to prevent tampering.
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Redirect URI Validation Failures
The redirect URI is where the authorization code is delivered. If the authorization server validates the redirect URI too loosely, an attacker can manipulate it to receive the authorization code.
Common redirect URI validation failures:
Prefix matching without path validation:
If the registered URI is https://app.example.com/callback but the server accepts any URI that starts with https://app.example.com, an attacker can use https://app.example.com.attacker.com (the registered domain appears as a prefix) or https://app.example.com/callback/../../steal.
Wildcard subdomain acceptance:
Registered URI https://*.example.com/callback allows attacker to register https://evil.example.com/callback if they can create subdomains (e.g., via a subdomain takeover of an orphaned DNS record).
Open redirect on the callback domain: Even with exact redirect URI matching, if the callback endpoint itself has an open redirect vulnerability (redirecting users to an arbitrary URL), the authorization code can be leaked via the Referer header when the redirect destination is attacker-controlled.
Fix:
- Require exact string matching for redirect URIs (no wildcards, no prefix matching)
- Register the exact production redirect URIs in the authorization server
- Use separate client registrations for development and production with non-overlapping redirect URI lists
- Audit the callback endpoint for open redirect vulnerabilities
Token Storage and Leakage: SPA-Specific Risks
Where an SPA stores tokens determines whether XSS can steal them:
localStorage: Persistent across browser sessions, accessible to any JavaScript on the page. An XSS vulnerability on any part of the application can read all localStorage values. Never store access tokens or refresh tokens in localStorage.
sessionStorage: Cleared when the tab closes, accessible to JavaScript. Better than localStorage but still accessible to XSS.
In-memory variables: Not accessible to XSS via localStorage/sessionStorage APIs, but lost on page refresh. Suitable for access tokens (which should be short-lived). Requires a token refresh strategy on page load.
HTTP-only cookies with SameSite=Strict: Inaccessible to JavaScript (XSS cannot read them), but requires a backend-for-frontend (BFF) pattern where the SPA's backend manages the token and proxies API calls. This is the most secure pattern for SPAs with sensitive access tokens.
Token leakage via URL: Never use the implicit flow (which returns tokens in the URL fragment) for new implementations. Authorization codes in the URL query string are visible in browser history and server logs. Configure your callback to immediately exchange the code and redirect to a URL without the code parameter:
// After successful token exchange, clean the URL
window.history.replaceState({}, document.title, window.location.pathname);
Token leakage via Referer header: If your callback page loads any external resources (analytics, CDN) before cleaning the URL, the code appears in the Referer header of those requests. Use a Content Security Policy that restricts resource loading, or clean the URL before any external requests.
Common Mistakes in Confidential Client Implementations
Confidential clients (server-side web applications) have a client secret and can keep it. Additional security requirements:
Client secret handling: Never embed client secrets in SPA or mobile application code. Use a backend-for-frontend to keep the secret server-side. Store client secrets in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault), not in environment variables committed to source control.
Token validation on the resource server: The resource server (your API) must validate every access token it receives:
import jwt
from jwt.algorithms import RSAAlgorithm
import requests
def get_jwks():
resp = requests.get('https://auth.example.com/.well-known/jwks.json')
return resp.json()
def validate_token(token):
jwks = get_jwks()
# Find the key matching the token's kid header
kid = jwt.get_unverified_header(token)['kid']
key_data = next(k for k in jwks['keys'] if k['kid'] == kid)
public_key = RSAAlgorithm.from_jwk(key_data)
return jwt.decode(
token,
public_key,
algorithms=['RS256'],
audience='your-api-audience',
issuer='https://auth.example.com/'
)
Cache the JWKS for 5-10 minutes to avoid fetching on every request, but refresh when you encounter an unknown kid.
Scope validation:
Do not use access tokens as a binary authenticated/not-authenticated check. Validate that the token's scope includes the permission required for the specific endpoint being accessed. An access token with scope read:profile should not authorize a write:data operation.
The bottom line
OAuth 2.0 security is in the details. Generate cryptographically random state parameters and PKCE code verifiers for every authorization request. Validate redirect URIs with exact string matching in your authorization server configuration. Store access tokens in memory or HTTP-only cookies, never in localStorage. Clean the callback URL before rendering any external content. These four controls prevent the most commonly exploited OAuth vulnerabilities in web application assessments.
Frequently asked questions
Do I need PKCE if my authorization server is private and only accessible to my own clients?
Yes. OAuth 2.1 and the OAuth Security Best Current Practice (BCP 240) require PKCE for all authorization code flows regardless of whether the client is public or confidential and regardless of whether the authorization server is internal. PKCE prevents authorization code interception even in environments where TLS is used, because codes can leak through browser history, logs, or referrer headers. The cost of implementing PKCE is minimal; the protection is meaningful.
What is the Backend for Frontend (BFF) pattern and when should I use it?
The BFF pattern places a lightweight server-side component between the SPA and the authorization server. The SPA initiates the OAuth flow; the BFF completes the token exchange and stores the tokens server-side. The BFF then proxies API calls, attaching the token from the server-side session. The SPA never sees the access or refresh token. This pattern is recommended when the SPA handles sensitive data or when the access tokens grant significant permissions, because it eliminates the XSS token theft risk. The trade-off is additional infrastructure and session management complexity.
How does token rotation work with refresh tokens?
Rotating refresh tokens means the authorization server issues a new refresh token each time the old one is used to obtain a new access token. The old refresh token is immediately invalidated. If an attacker steals a refresh token and uses it, the legitimate client's next use of the original token will fail, triggering a re-authentication flow that signals the theft. Some authorization servers implement refresh token reuse detection: if the same refresh token is presented twice, the server invalidates all tokens in that refresh token family, forcing full re-authentication.
What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 is an authorization framework: it defines how a client obtains permission to access resources on behalf of a user. It does not define how the user's identity is conveyed to the client. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. OIDC adds the ID token (a JWT containing claims about the authenticated user: sub, email, name) and the UserInfo endpoint. Use OAuth 2.0 alone for API authorization (the client needs access to a resource). Use OIDC when the application also needs to know who the user is (authentication and identity).
What security risks exist with the implicit flow and why is it deprecated?
The implicit flow was designed for SPAs in older browser environments that could not make cross-origin token endpoint requests. It returns the access token directly in the URL fragment after the redirect, bypassing the token endpoint entirely. The security problems: access tokens in URL fragments can be captured in browser history, server logs, Referer headers, and by JavaScript on the page. There is no client authentication, so any attacker who captures the authorization response gets a usable token. OAuth 2.0 Security Best Current Practice (RFC 9700) prohibits the implicit flow. All SPAs should use Authorization Code with PKCE instead, which is now supported in all modern browsers.
How should refresh tokens be handled securely in an OAuth2 SPA implementation?
Refresh tokens in SPAs should be stored in memory (JavaScript variable or closure), never in localStorage or sessionStorage, which are accessible to any JavaScript executing on the page. Use refresh token rotation: each time a refresh token is exchanged for a new access token, the authorization server issues a new refresh token and invalidates the old one. If a rotated refresh token is used again (indicating theft and reuse), the authorization server should invalidate the entire token family. Implement refresh token inactivity expiry (for example, invalidate refresh tokens unused for 7 days) separately from absolute expiry. For browser-based apps, the BFF (Backend for Frontend) pattern keeps refresh tokens on a server-side session and uses a session cookie for browser communication, removing refresh tokens from the browser entirely.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
