PRACTITIONER GUIDE | APPLICATION SECURITY
Practitioner GuideUpdated 12 min read

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

Implicit flow
is deprecated in OAuth 2.0 Security BCP -- all new implementations must use authorization code with PKCE, including SPAs and mobile apps
State parameter
missing in approximately 25% of OAuth integrations assessed in web application penetration tests, leaving them vulnerable to CSRF-based account linking attacks
Redirect URI
wildcard misconfigurations allowing attacker.example.com to receive authorization codes found in major platforms including Facebook (2013) and GitHub (2014)
PKCE
required for all public clients by RFC 9700 (OAuth 2.1 draft) regardless of whether the authorization server is public or private

SponsoredRetool

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.

Start building for free today

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):

  1. Attacker initiates an OAuth flow at the target site with their own identity at the IdP
  2. Attacker receives a valid authorization code but does not visit the callback URL
  3. Attacker crafts a URL to the target site's callback with their authorization code: https://target.com/callback?code=ATTACKER_CODE
  4. Attacker tricks the victim into visiting this URL (via email link, img tag, etc.)
  5. Target site exchanges the code for a token (the attacker's identity), links it to the victim's session
  6. 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.

Free daily briefing

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

  1. RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
  2. RFC 6749: The OAuth 2.0 Authorization Framework
  3. OAuth 2.0 Security Best Current Practice (BCP 240)
  4. PortSwigger: OAuth 2.0 vulnerabilities

Free resources

25
Free download

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.

No spam. Unsubscribe anytime.

Free download

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.

No spam. Unsubscribe anytime.

Free newsletter

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.

Eric Bang
Author

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.

Black Hat Giveaway

Win a $2,495 Black Hat pass.

Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.

Joins Decryption Digest daily briefing. Unsubscribe anytime.

Giveaway: Black Hat USA 2026 Full-Access Pass ($2,495 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $2,495 Black Hat USA 2026 pass.