Why OAuth Security Is So Hard to Get Right

OAuth 2.0 is a framework specification, not a protocol. RFC 6749 deliberately leaves many implementation details to the implementor β€” which means there are dozens of ways to build an OAuth flow, and many of them are insecure. The RFC authors acknowledged this; the document itself says "this specification is designed to provide a clear, simple, and flexible approach to implementing OAuth" β€” a set of objectives that turns out to be in tension.

The attack surface for OAuth implementation flaws is large because the flow involves multiple parties (resource owner, client, authorization server, resource server), multiple redirect steps, and state that needs to be maintained across those redirects. Each handoff is an opportunity for an attacker to intercept or manipulate the flow.

Use a library: If you're implementing OAuth 2.0 from scratch in 2026, seriously reconsider. Libraries like authlib (Python), passport-oauth2 (Node.js), and spring-security-oauth2 (Java) handle most of these pitfalls correctly. The vulnerabilities in this post are almost exclusively found in custom implementations.

The OAuth 2.0 Authorization Code Flow and Where It Breaks

The authorization code flow is the correct grant type for most applications. At a high level:

  1. Client redirects user to authorization server with response_type=code, client_id, redirect_uri, scope, and state
  2. User authenticates and grants consent at the authorization server
  3. Authorization server redirects back to redirect_uri with an authorization code and the original state
  4. Client verifies state, then exchanges the code for tokens via a back-channel request (server to server)
  5. Authorization server returns access token and refresh token

The vulnerabilities cluster around steps 1, 2, and 3 β€” the redirects and the state verification.

Open Redirect Attacks in OAuth: Stealing Auth Codes

The authorization server validates the redirect_uri parameter against a list of registered redirect URIs for the client. If this validation is weak β€” using substring matching, prefix matching, or allowing wildcard subdomains β€” an attacker can construct a redirect URI that the authorization server accepts but that points to an attacker-controlled endpoint.

oauth-redirect-exploit.txt Text β€” redirect URI manipulation
--- Legitimate authorization URL ---
https://auth.example.com/oauth/authorize?
  client_id=myapp&
  redirect_uri=https://app.example.com/callback&
  response_type=code&
  state=abc123

--- Attacker's manipulated URL (if AS uses prefix matching) ---
https://auth.example.com/oauth/authorize?
  client_id=myapp&
  redirect_uri=https://app.example.com.attacker.com/callback&
  response_type=code&
  state=abc123

--- Or with path traversal (if AS matches only the base path) ---
  redirect_uri=https://app.example.com/callback/../../../attacker

--- Auth code sent to attacker's server ---
--- Attacker exchanges it for tokens via legitimate token endpoint ---

The fix is exact matching: Authorization servers must compare the redirect_uri parameter against the registered URIs using exact string matching. No prefix matching, no path normalization before comparison, no wildcard subdomains. This is explicit in the OAuth Security BCP (RFC 9700).

CSRF in OAuth Flows: State Parameter Misuse

The state parameter in the authorization request serves as a CSRF token for the OAuth flow. The client generates a random value, includes it in the authorization request, and verifies it when the authorization server redirects back with the authorization code.

Without a valid state check, an attacker can trick a user's browser into completing an OAuth flow that the attacker initiated β€” resulting in the user's account being linked to the attacker's identity ("account linking attack") or the user being logged into the attacker's account.

oauth_state_check.py Python β€” correct state validation
import secrets, hashlib
from flask import session, request, abort

# When initiating the OAuth flow:
def start_oauth():
    state = secrets.token_urlsafe(32)  # cryptographically random
    session["oauth_state"] = state    # store in server-side session
    return redirect_to_auth_server(state=state)

# When handling the callback:
def oauth_callback():
    returned_state = request.args.get("state")
    expected_state = session.pop("oauth_state", None)

    # ALWAYS verify state before using the code
    if not returned_state or not expected_state:
        abort(400, "Missing state parameter")
    if not secrets.compare_digest(returned_state, expected_state):
        abort(400, "State mismatch β€” possible CSRF attack")

    # Now safe to exchange the authorization code
    code = request.args.get("code")
    exchange_code_for_tokens(code)

Token Leakage via Referrer Headers

When an authorization code or access token appears in a URL β€” as the implicit flow's response or as a query parameter in the callback URL β€” that URL can be leaked in the browser's Referer header when the page makes requests to third-party resources (analytics scripts, CDN resources, embedded widgets).

The authorization code flow is less vulnerable because the code appears in the callback URL only briefly and is single-use. But if your callback page includes any third-party scripts before consuming and clearing the code, those scripts' host servers will receive the code in the Referer header.

The mitigations: include a Referrer-Policy: no-referrer header on callback pages, use response_mode=form_post (which sends the code in a POST body, not a URL) where the authorization server supports it, and consume and clear the code immediately in the callback handler before rendering anything.

Implicit Flow: Why It's Retired and Still Dangerous

The implicit flow (response_type=token) was designed for browser-based apps that couldn't keep a client secret. Instead of an authorization code exchanged back-channel, it returns an access token directly in the URL fragment. This means the access token is:

  • In the browser's address bar (visible to browser extensions, screenshot tools)
  • In the browser's history (accessible by any JavaScript on the page)
  • In server logs if the URL is logged
  • In Referer headers sent to third-party resources on the page

OAuth 2.0 Security Best Current Practice (RFC 9700) explicitly deprecates the implicit flow. Use the authorization code flow with PKCE for browser-based apps instead.

Still widely used: Despite being deprecated, we regularly find the implicit flow in use in applications written before 2019. If you're maintaining legacy OAuth code, check whether you're using response_type=token and migrate to response_type=code with PKCE.

PKCE and When It Can Still Be Bypassed

PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization code interception attacks. The client generates a random code_verifier, hashes it to create a code_challenge, sends the challenge with the authorization request, and then proves knowledge of the verifier when exchanging the code for tokens.

pkce_implementation.py Python
import secrets, hashlib, base64

def generate_pkce_pair():
    # Generate code verifier (43-128 chars, URL-safe)
    code_verifier = secrets.token_urlsafe(64)

    # S256 challenge method (always use S256, never "plain")
    digest = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

    return code_verifier, code_challenge

# Authorization request includes:
# code_challenge=<challenge>&code_challenge_method=S256

# Token request includes:
# code_verifier=<verifier>

PKCE bypasses occur when:

  • The authorization server accepts code_challenge_method=plain β€” which doesn't hash the verifier, defeating the purpose
  • The authorization server doesn't enforce PKCE for public clients β€” allows clients to skip it entirely
  • The code_verifier is predictable β€” using a static value or a timestamp-based value rather than a cryptographically random one

Mix-Up Attacks Across Multiple Providers

Mix-up attacks occur when an application supports multiple authorization servers (e.g., "Sign in with Google" and "Sign in with GitHub"). An attacker can manipulate the flow so that an authorization code intended for one provider is sent to another β€” potentially stealing it or triggering an authorization exchange at an attacker-controlled server.

The mitigation is to include the authorization server's issuer identifier in the state parameter and verify it in the callback β€” ensuring the callback was actually triggered by the expected authorization server. OpenID Connect handles this correctly when you validate the iss claim in the ID token.

JWT as Access Tokens: Algorithm Confusion

When authorization servers issue JWTs as access tokens, resource servers that validate them are vulnerable to algorithm confusion attacks if they accept any algorithm. An attacker who knows the server uses RS256 (RSA) can forge tokens signed with the server's public key using HS256 (HMAC) β€” if the server doesn't explicitly require RS256 validation.

Always specify the expected algorithm explicitly when validating JWTs, and never accept the algorithm from the token header. For a detailed treatment of JWT security, see our post on Cryptographic Failures and JWT security.

OAuth Security Checklist for Implementers

  1. Use the authorization code flow with PKCE β€” for browser-based and mobile clients. Never use the implicit flow.
  2. Implement state parameter validation β€” always generate, store, and verify the state parameter. Use secrets.compare_digest for comparison.
  3. Validate redirect URIs by exact match β€” no prefix matching, no path normalization, no wildcard domains.
  4. Use S256 PKCE β€” never use plain code challenge method. Require PKCE for all public clients.
  5. Protect callback pages β€” add Referrer-Policy: no-referrer and consume the auth code immediately before rendering.
  6. Short-lived authorization codes β€” codes should expire within 60 seconds and be single-use.
  7. Validate the issuer on multi-provider setups β€” check the iss claim when using OpenID Connect.
  8. Specify the algorithm explicitly when validating JWTs β€” never accept algorithm from the token header.
  9. Rotate refresh tokens β€” implement refresh token rotation so that a leaked refresh token can't be used indefinitely.
  10. Use a battle-tested OAuth library β€” unless you're an authorization server implementer, use a library rather than rolling your own.

Test Your OAuth Implementation for Flaws

AquilaX SAST and DAST detect OAuth implementation flaws including open redirect vulnerabilities, missing state parameters, and insecure token handling.

Start Free Scan