PKCE
Proof Key for Code Exchange: the mandatory extension that prevents authorization code interception; required for all public clients since RFC 9700 (2025)
state
Required OAuth parameter that prevents CSRF attacks: must be a cryptographically random, single-use value validated on return from the authorization server
Implicit flow
Deprecated OAuth flow that returns access tokens in the URL fragment: exposed in browser history, referrer headers, and server logs
redirect_uri
Most commonly exploited OAuth parameter: an open or loosely validated redirect_uri allows an attacker to steal the authorization code

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 single protocol: it is a framework with multiple flows, optional parameters, and implementation choices at every step. The security of an OAuth implementation depends entirely on making the right choices at each decision point.

The OAuth 2.0 authorization code flow, implemented correctly with PKCE and exact redirect URI matching, is secure. The same flow, implemented with a loose redirect URI pattern and no state parameter, allows authorization code theft and CSRF attacks that completely bypass authentication.

Flaw 1: Open or Loosely Validated Redirect URI

The redirect_uri is where the authorization server sends the authorization code after user approval. If an attacker can control the redirect_uri, they receive the code: which they can exchange for an access token.

Vulnerable: wildcard or prefix matching:

# Authorization server accepts any URI starting with the app's domain
Allowed: https://app.example.com/*

# Attacker crafts:
https://authserver.com/authorize?
  client_id=myapp&
  redirect_uri=https://app.example.com.attacker.com/callback&
  ...

The attacker registers app.example.com.attacker.com: the wildcard match passes, and the code is delivered to the attacker's server.

Vulnerable: open redirect on the legitimate domain:

# Application has an open redirect at /redirect?url=
https://app.example.com/redirect?url=https://attacker.com

# Attacker uses:
redirect_uri=https://app.example.com/redirect?url=https://attacker.com/steal

Secure: exact URI matching only:

# Authorization server should validate exact match, not prefix or pattern match
ALLOWED_REDIRECT_URIS = {
    'myapp-client-id': [
        'https://app.example.com/oauth/callback',
        'https://staging.example.com/oauth/callback',
    ]
}

def validate_redirect_uri(client_id: str, redirect_uri: str) -> bool:
    allowed = ALLOWED_REDIRECT_URIS.get(client_id, [])
    return redirect_uri in allowed  # Exact string match, no wildcards

Register only specific, exact URIs: never register https://app.example.com/* or https://app.example.com. Every legitimate redirect path should be registered individually.

Flaw 2: Missing or Weak State Parameter (CSRF)

The state parameter prevents CSRF attacks where an attacker tricks a victim's browser into completing an OAuth flow and binding the attacker's account credentials to the victim's session.

How the CSRF attack works:

  1. Attacker initiates an OAuth flow with their own client, gets an authorization URL
  2. Before completing the flow themselves, they trick the victim into visiting that URL
  3. Victim clicks "Allow": their browser sends the authorization code to the redirect_uri
  4. Victim's session is now linked to the attacker's OAuth credentials
  5. Attacker can now access victim's account by completing the flow later

Vulnerable: no state parameter:

https://authserver.com/authorize?client_id=app&redirect_uri=...&response_type=code
# No state parameter: the authorization response cannot be tied to the initiating request

Secure: cryptographically random state, validated on return:

import secrets
import hashlib

def initiate_oauth_flow(request):
    # Generate a cryptographically random state value
    state = secrets.token_urlsafe(32)
    
    # Store in server-side session (not cookie: prevent fixation)
    request.session['oauth_state'] = state
    request.session['oauth_state_created'] = time.time()
    
    # Include in authorization URL
    auth_url = f"{AUTH_URL}?client_id={CLIENT_ID}&state={state}&..."
    return redirect(auth_url)

def handle_oauth_callback(request):
    received_state = request.GET.get('state')
    stored_state = request.session.pop('oauth_state', None)
    state_created = request.session.pop('oauth_state_created', 0)
    
    # Validate: state must match, must be recent, must be present
    if not received_state or not stored_state:
        raise AuthError('Missing state parameter')
    if not secrets.compare_digest(received_state, stored_state):
        raise AuthError('State parameter mismatch: possible CSRF attack')
    if time.time() - state_created > 300:  # 5-minute expiry
        raise AuthError('State expired')
    
    # State validated: proceed with code exchange
    code = request.GET.get('code')
    exchange_code_for_token(code)
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.

Flaw 3: Public Clients Without PKCE

Single-page applications (SPAs) and mobile apps are public clients: they cannot securely store a client secret because any user can inspect the app's code or binary and extract it. Sending a client_secret from a SPA is the same as having no secret at all.

PKCE (Proof Key for Code Exchange, RFC 7636) solves this by having the client generate a random verifier, hash it as a challenge, send the challenge with the authorization request, then send the original verifier with the token request: the server verifies the hash matches.

PKCE implementation (JavaScript/TypeScript):

async function initiateOAuthWithPKCE(): Promise<void> {
  // Generate a random code verifier (43-128 characters)
  const codeVerifier = generateCodeVerifier();
  
  // Hash it using SHA-256 to create the code challenge
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  
  // Store the verifier securely (sessionStorage, not localStorage)
  sessionStorage.setItem('code_verifier', codeVerifier);
  
  // Redirect to authorization server with challenge
  const params = new URLSearchParams({
    client_id: 'my-spa-client',
    response_type: 'code',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid profile',
    state: generateState(),
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });
  window.location.href = `https://auth.example.com/authorize?${params}`;
}

function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(digest));
}

// On callback: exchange code using the verifier
async function handleCallback(code: string): Promise<void> {
  const codeVerifier = sessionStorage.getItem('code_verifier');
  sessionStorage.removeItem('code_verifier');
  
  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: 'https://app.example.com/callback',
      client_id: 'my-spa-client',
      code_verifier: codeVerifier!,  // Sent instead of client_secret
    }),
  });
}

Never use the Implicit Flow (response_type=token). It returns the access token in the URL fragment, which appears in browser history, server access logs, and referrer headers. The Authorization Code flow with PKCE provides equivalent convenience without URL token exposure.

Flaw 4: Insufficient Scope Validation and Token Storage

Scope creep: request only what you need:

# Bad: requesting all available permissions
scope=openid profile email phone address admin:read admin:write offline_access

# Good: request minimum required for the current feature
scope=openid profile email

# Request additional scopes incrementally when needed
# User grants 'email' on initial login, only prompt for 'drive:read' when the feature is first used

Validate scopes on the resource server: do not trust the client:

# Resource server: validate the token's scope before serving the request
def require_scope(required_scope: str):
    def decorator(func):
        def wrapper(*args, **kwargs):
            token = get_bearer_token(request)
            payload = verify_jwt(token)
            granted_scopes = payload.get('scope', '').split()
            
            if required_scope not in granted_scopes:
                raise PermissionError(f'Required scope: {required_scope}')
            
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_scope('profile:write')
def update_profile(request):
    ...

Access token storage: avoid localStorage:

// Bad: localStorage is accessible to any JavaScript on the page (XSS risk)
localStorage.setItem('access_token', token);

// Better: httpOnly cookie (JavaScript cannot read it, prevents XSS token theft)
// Set by the server after token exchange:
// Set-Cookie: access_token=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api

// For SPAs that must store tokens client-side:
// Use sessionStorage (cleared on tab close, not accessible cross-origin)
// Accept that XSS = token theft: mitigate XSS rather than relying on storage security
sessionStorage.setItem('access_token', token);

// Short-lived access tokens (15 minutes) with refresh tokens limit the XSS theft window
// Even if a token is stolen, it expires quickly

Token lifetime guidelines:

  • Access tokens: 15 minutes maximum for sensitive scopes, 1 hour for low-sensitivity
  • Refresh tokens: Rotate on each use (refresh token rotation), expire after 30 days
  • ID tokens: 1 hour (used for identity only, not resource access)

The bottom line

OAuth 2.0 security requires getting four things right: exact redirect URI validation (no wildcards, no prefix matching), a cryptographically random state parameter validated on return (CSRF prevention), PKCE for all public clients (SPAs and mobile apps cannot store client secrets), and httpOnly cookies or short-lived access tokens to minimize XSS exposure. The Implicit flow is deprecated: use Authorization Code + PKCE. The RFC 9700 OAuth 2.0 Security Best Current Practice document is the authoritative reference for current implementation requirements.

Frequently asked questions

What are the most common OAuth 2.0 security vulnerabilities?

Open redirect URI validation (any URL accepted instead of exact registered URIs), missing state parameter enabling CSRF attacks, using the deprecated Implicit flow that exposes tokens in URLs, public clients (SPAs, mobile apps) that cannot store client secrets securely without PKCE, and excessive scope grants. RFC 9700 (OAuth 2.0 Security Best Current Practice) documents the current required mitigations.

What is PKCE and why is it required for OAuth 2.0?

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks for public clients: SPAs and mobile apps that cannot securely store a client_secret. The client generates a random code_verifier, hashes it as a code_challenge sent with the authorization request, then sends the original verifier with the token request. The authorization server verifies the hash matches, proving the same client that started the flow is completing it: without requiring a static secret.

What is an OAuth 2.0 redirect URI and why is it a security control?

The redirect URI is where the authorization server sends the authorization code after a user grants consent. It is a critical security control because if the redirect URI is not strictly validated, an attacker can craft an authorization request with a redirect URI they control and capture the authorization code when the server follows the redirect. Secure implementation: register exact redirect URIs with the authorization server (no wildcards, no partial matches); validate the redirect URI parameter in the authorization request against the registered list; use https:// URIs only; never register localhost URIs in production applications. Redirect URI mismatches should be rejected outright with no fallback.

What is the OAuth 2.0 state parameter and what does it protect against?

The state parameter is an opaque random value generated by the client, included in the authorization request, and returned unchanged by the authorization server in the redirect. The client verifies the returned state matches the generated value before processing the authorization code. This prevents CSRF attacks against the OAuth flow: without state validation, an attacker can initiate an authorization flow and trick a victim into completing it, causing the victim's account to be linked to the attacker's. Generate state as a cryptographically random string of at least 16 bytes, store it in the session, and validate before token exchange.

What is an OAuth 2.0 consent phishing attack and how do you defend against it?

OAuth consent phishing (also called 'illicit consent grant') tricks users into granting a malicious OAuth app access to their Microsoft 365 or Google account. The attacker registers a third-party app requesting permissions like 'Read your email' or 'Access your files', then sends a phishing link. When the user clicks the link and clicks 'Allow', the attacker receives OAuth tokens with full permissions to the user's data -- without needing the user's password. Defense: in Microsoft 365, configure app consent policies to require admin approval for third-party app access (this blocks user-driven consent grants); in Google Workspace, restrict OAuth app access to allowlisted apps only. Monitor Entra ID audit logs for 'Consent to application' events targeting sensitive permissions.

How do you implement OAuth PKCE (Proof Key for Code Exchange) and why is it required for public clients?

PKCE (Proof Key for Code Exchange, pronounced 'pixie') prevents authorization code interception attacks where an attacker captures the authorization code returned in a redirect URI. Without PKCE, a malicious app on the same device that registered a similar URI scheme can intercept the authorization code and exchange it for tokens. PKCE works by having the client generate a random code verifier (43-128 characters, high entropy), hash it with SHA-256 to create the code challenge, send the code challenge with the authorization request, and then send the original code verifier with the token exchange request. The authorization server validates that the SHA-256 hash of the verifier matches the challenge from the first request -- only the original client that generated the verifier can complete the exchange. Implementation: use OAuth PKCE as the standard flow for all public clients (single-page applications, native mobile apps, desktop apps). These are 'public clients' because they cannot safely store a client secret -- any secret embedded in client-side code or a mobile app binary can be extracted. Confidential clients (server-side applications that can store a client secret) should still use PKCE in addition to their client secret for defense in depth. Most modern OAuth libraries implement PKCE automatically when using the authorization code flow -- verify PKCE is enabled in your OAuth library configuration and that S256 (SHA-256) code challenge method is specified, not the weaker plain method.

Sources & references

  1. RFC 9700: OAuth 2.0 Security Best Current Practice
  2. PortSwigger Web Academy: OAuth Authentication Vulnerabilities
  3. OWASP: OAuth Cheat Sheet

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.