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_uriexact 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.
stateand 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
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.
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.
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.
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.
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: 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
# 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
- Configure the authorization server to compare
redirect_uriwith a pre-registered URI using exact string matching. Preserve only the RFC 9700 exception for native-app localhost ports. - Do not put a user-controlled destination in an OAuth callback URI. Remove open redirects from callback paths and accept only allow-listed local route identifiers.
- Use authorization code flow with PKCE S256 for every client type. Send the same registered
redirect_uriin the authorization and token requests. - Bind the callback to the login attempt: validate a one-time, session-bound
state, or rely on PKCE for CSRF only when the authorization server’s PKCE support is enforced. Using both is valid defense in depth. - A captured authorization code is not sufficient by itself. Ensure token redemption also requires the correct client authentication and PKCE verifier; never put access tokens in redirect URLs.
- Avoid logging authorization codes and set a restrictive
Referrer-Policyon callback pages; do not render third-party content before removing sensitive query parameters.