FlawOpen
● CWE-601 / CWE-352

OAuth 2.0 Redirect URI Attacks: Code Theft, Login CSRF, and PKCE

A permissive authorization-server redirect check or an open redirect on a registered callback can expose an authorization code. Exploitation still depends on code binding and token-endpoint controls; missing login-flow binding can separately cause login CSRF.

💡 Plain English Explainer (ELI5)

Think of the authorization server as a trusted post office: it sends a one-time code only to the return address registered for an app. If that address is matched loosely, or the registered endpoint forwards visitors to an arbitrary site, the code can be diverted. A captured code does not automatically grant access: the attacker must also satisfy the client’s token-exchange requirements, including PKCE and client authentication when required. A separate login-CSRF flaw occurs when a site cannot tell whether the browser started the login it is completing.

Core Concepts & Key Terms

Authorization Code
A short-lived credential returned through the browser redirect. The client exchanges it at the token endpoint; redemption is bound to the client and redirect URI, and to a PKCE verifier when PKCE is used.
redirect_uri exact matching
RFC 9700 requires the authorization server to compare a requested redirect URI with a pre-registered URI using exact string matching, with a narrow dynamic-port exception for native-app localhost redirects.
state and login CSRF
An unpredictable value bound to the browser session and checked on callback can correlate the response with a login the user initiated. Enforced PKCE can also provide CSRF protection; clients may use both for defense in depth.
PKCE (Proof Key for Code Exchange)
The client creates a secret code verifier and sends its derived challenge in the authorization request. The token endpoint requires the verifier to redeem the code, reducing code-interception risk for public and confidential clients.

Step-by-Step Redirect and Login-CSRF Attack Paths

Path A · 1

The authorization server accepts an unsafe redirect URI

A faulty authorization server uses a wildcard, prefix, or substring rule instead of comparing the requested redirect_uri with an exact registered value. RFC 9700 requires exact matching, except for the specified native-app localhost port case.

Path A · 2

A registered endpoint forwards to an untrusted site

The accepted URI may reach a client-controlled open redirect such as https://app.example/redirect?next=.... This combines a loose authorization-server check with a separate CWE-601 weakness in the client application.

Path A · 3

The browser carries the code through the redirect

After the user authorizes, the authorization server returns the code to the accepted URI. If the open redirect forwards the request and preserves the code, the attacker may capture it. A code alone is not a bearer access token.

Path A · 4

Token redemption still has to succeed

The attacker can use a captured code only if they can satisfy the token endpoint’s checks. PKCE blocks redemption without the original verifier; confidential-client authentication and matching redirect-URI checks provide additional binding.

Path B

Missing response binding enables login CSRF

If the callback accepts an authorization response that was not tied to the browser’s login attempt, an attacker may cause a victim to complete a flow associated with the attacker’s account. This is account confusion, not automatic access to the victim’s account.

Source Code: Flaw vs. Secure Implementation

VULNERABLE PATTERN
# VULNERABLE: user input is embedded in redirect_uri; state and PKCE are absent.
from urllib.parse import urlencode
from flask import Flask, request, redirect

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"

@app.route("/auth/login")
def oauth_login():
    next_url = request.args.get("next", "")
    # The callback is an open redirect, and this URI varies with user input.
    redirect_uri = "https://app.example/redirect?next=" + next_url
    params = {
        "client_id": "example-client",
        "response_type": "code",
        "redirect_uri": redirect_uri,
    }
    # Exploitation also requires the authorization server to accept this URI
    # under an unsafe wildcard or prefix rule.
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    # No state check ties this response to the browser's login attempt.
    code = request.args.get("code")
    return exchange_code_for_tokens(code)  # no PKCE verifier provided
HARDENED DEFENSE
# SECURE: fixed registered redirect URI, session-bound state, and PKCE.
import base64
import hashlib
import secrets
from urllib.parse import urlencode
from flask import Flask, request, redirect, session, abort

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"
REDIRECT_URI = "https://app.example/auth/callback"  # exact registered value
CLIENT_ID = "example-client"

@app.route("/auth/login")
def oauth_login():
    state = secrets.token_urlsafe(32)
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode("ascii")
    session["oauth_state"] = state
    session["pkce_verifier"] = verifier
    params = {
        "client_id": CLIENT_ID,
        "response_type": "code",
        "redirect_uri": REDIRECT_URI,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(
        request.args.get("state", ""), expected
    ):
        abort(403, "Invalid OAuth state")

    code = request.args.get("code")
    verifier = session.pop("pkce_verifier", None)
    if not code or not verifier:
        abort(400, "Missing authorization response")

    # The token request must use this exact redirect_uri and verifier.
    tokens = exchange_code_for_tokens(
        code=code, redirect_uri=REDIRECT_URI, code_verifier=verifier
    )
    session["user_id"] = tokens["user_id"]
    return redirect("/dashboard")

Engineering Hardening Checklist

← Browse Full Security Directory Explore Vulnerability Playbooks → RFC 9700: OAuth 2.0 Security Best Current Practice → RFC 7636: Proof Key for Code Exchange (PKCE) → RFC 6749: Authorization Code Redirection URI Manipulation → CWE-601: Open Redirect → CWE-352: Cross-Site Request Forgery →