August 6, 2026
OAuth 2.0 Attack Surface: Redirect URIs, Token Leakage, and Account Takeover Risks
Apple paid $100,000 for this one. The researcher found it in an afternoon. The server issued OAuth tokens for any email address you asked…

By CYBER MIND SPACE
7 min read
Apple paid $100,000 for this one. The researcher found it in an afternoon. The server issued OAuth tokens for any email address you asked for — no proof of ownership required.
In May 2020, Bhavuk Jain was poking at Sign in with Apple.
He noticed the OAuth flow let you pass a custom email address in the JWT request. So he tried passing someone else's.
Apple's server issued JWTs for any email address an attacker requested in the JWT request body, with no validation that the requester owned the address.
You could request a token for tim@apple.com. The server would issue it. Apps that trusted Sign in with Apple would accept it. Full account takeover, one request, zero interaction from the victim.
That's not a novel cryptographic attack. That's a missing if statement. Apple paid $100,000 for it.
OAuth is not secure by design. It's a delegation protocol. The security comes from how implementations handle six specific parameters — and most implementations handle at least one of them badly.
⚡ The Flow — 60 Seconds
OAuth Authorization Code Flow
──────────────────────────────────────────────────────────────────
[1] User clicks "Login with Google"
↓
[2] App sends:
GET /authorize?
response_type=code
&client_id=APP_ID
&redirect_uri=https://app.com/callback ← attack surface
&scope=email profile
&state=random_csrf_token ← often missing
↓
[3] User logs in at Google, approves scopes
↓
[4] Google redirects to:
https://app.com/callback?code=AUTH_CODE ← in the URL ← dangerous
↓
[5] App POSTs code to Google:
POST /token
{ code, client_id, client_secret, redirect_uri }
↓
[6] Google returns access_token + refresh_tokenOAuth Authorization Code Flow
──────────────────────────────────────────────────────────────────
[1] User clicks "Login with Google"
↓
[2] App sends:
GET /authorize?
response_type=code
&client_id=APP_ID
&redirect_uri=https://app.com/callback ← attack surface
&scope=email profile
&state=random_csrf_token ← often missing
↓
[3] User logs in at Google, approves scopes
↓
[4] Google redirects to:
https://app.com/callback?code=AUTH_CODE ← in the URL ← dangerous
↓
[5] App POSTs code to Google:
POST /token
{ code, client_id, client_secret, redirect_uri }
↓
[6] Google returns access_token + refresh_tokenSix parameters. Three of them are where attacks live: redirect_uri, state, and the code sitting in step 4's URL.
🎯 Attack 1 — Redirect URI Bypass (The One That Keeps Shipping)
The spec is explicit. Redirect URI validation must use exact string comparison. Not prefix matching. Not regex. Exact.
Almost nobody does exact.
STRICT VALIDATION (correct)
──────────────────────────────────────────────────────────────────
Registered: https://app.com/callback
Submitted: https://app.com/callback
Result: ✓ Accepted
Submitted: https://app.com/callback/../../evil
Result: ✗ Rejected (not an exact match)
──────────────────────────────────────────────────────────────────
COMMON BYPASS PATTERNS (when validation is weak)
──────────────────────────────────────────────────────────────────
Prefix match only:
https://app.com.evil.com/callback ← registered prefix matches
https://app.com@evil.com/callback ← @ character abuses URL parsing
https://app.com/callback/../../../evil ← path traversal
Regex escape failure:
Registered regex: https://app\.com/callback
Attacker submits: https://appXcom/callback ← dot not escaped, X matches
→ Regex accepts it. Code sent to attacker.STRICT VALIDATION (correct)
──────────────────────────────────────────────────────────────────
Registered: https://app.com/callback
Submitted: https://app.com/callback
Result: ✓ Accepted
Submitted: https://app.com/callback/../../evil
Result: ✗ Rejected (not an exact match)
──────────────────────────────────────────────────────────────────
COMMON BYPASS PATTERNS (when validation is weak)
──────────────────────────────────────────────────────────────────
Prefix match only:
https://app.com.evil.com/callback ← registered prefix matches
https://app.com@evil.com/callback ← @ character abuses URL parsing
https://app.com/callback/../../../evil ← path traversal
Regex escape failure:
Registered regex: https://app\.com/callback
Attacker submits: https://appXcom/callback ← dot not escaped, X matches
→ Regex accepts it. Code sent to attacker.CVE-2024–52289 in Authentik's OAuth 2.0 implementation allowed attackers to bypass redirect URI validation due to insecure use of regular expressions. By exploiting this flaw, an attacker could redirect authentication to a URI they controlled — enabling a one-click account takeover.
The pattern: developer registers https://app\.com as a regex. Dot in regex means "any character." Attacker submits https://appXcom — it matches. Authorization code lands on their server.
One unescaped dot. Account takeover.
Testing this takes four requests. Grab the authorization endpoint, try path confusion, subdomain confusion, @ character abuse, and regex bypass patterns. If any return a redirect to a URL you control, you have a Critical.
📨 Attack 2 — Authorization Code Stolen by the Browser Itself
The authorization code lands in the URL. In step 4 of the flow. As a query parameter.
That's a problem, because URLs go places.
CODE IN URL → WHERE IT LEAKS
──────────────────────────────────────────────────────────────────
Redirect: https://app.com/callback?code=AUTH_CODE&state=xyz
↓ Browser behavior
┌────────────────────────────────────────────────────────────┐
│ Browser history ← code stored, visible to anyone │
│ with access to the device │
│ │
│ Referer header ← if /callback loads external │
│ scripts, images, or analytics, │
│ the full URL (with code) goes │
│ in the Referer header of those │
│ requests │
│ │
│ Server logs ← proxy and web server logs store │
│ the full request URI │
└────────────────────────────────────────────────────────────┘CODE IN URL → WHERE IT LEAKS
──────────────────────────────────────────────────────────────────
Redirect: https://app.com/callback?code=AUTH_CODE&state=xyz
↓ Browser behavior
┌────────────────────────────────────────────────────────────┐
│ Browser history ← code stored, visible to anyone │
│ with access to the device │
│ │
│ Referer header ← if /callback loads external │
│ scripts, images, or analytics, │
│ the full URL (with code) goes │
│ in the Referer header of those │
│ requests │
│ │
│ Server logs ← proxy and web server logs store │
│ the full request URI │
└────────────────────────────────────────────────────────────┘CVE-2025–4664 affected Google Chrome where the browser's Loader component failed to enforce referrer policy restrictions, exposing authorization codes via the Referer header when pages loaded external resources.
Not a bug in your code. A bug in Chrome. Any app that loaded external images, analytics scripts, or third-party fonts on the /callback page while it still had the code in the URL was leaking codes to every one of those third parties via the Referer header.
The code is single-use — but that doesn't help if the attacker races you to exchange it. Google's token endpoint processes the first request. Yours becomes a 400 error you might not even notice.
🔄 Attack 3 — Missing State Parameter = OAuth CSRF
The state parameter is how the app proves it initiated the OAuth flow, not an attacker.
When it's missing, or not validated on the callback, anyone can start an OAuth flow on behalf of the victim.
CSRF OAUTH FLOW — no state parameter
──────────────────────────────────────────────────────────────────
[1] Attacker starts OAuth flow with their own account
GET /authorize?client_id=APP&redirect_uri=https://app.com/callback
[2] Attacker gets their own authorization code
code=ATTACKER_CODE
[3] Attacker stops before redeeming it. Instead:
Crafts a malicious page:
<img src="https://app.com/callback?code=ATTACKER_CODE"
style="display:none">
[4] Victim visits attacker's page
Victim's browser sends the request
App exchanges ATTACKER_CODE for a token
App links ATTACKER's account to VICTIM's session
[5] Victim is now logged into attacker's account
Attacker logs into the app
They see victim's data, payment methods, private contentCSRF OAUTH FLOW — no state parameter
──────────────────────────────────────────────────────────────────
[1] Attacker starts OAuth flow with their own account
GET /authorize?client_id=APP&redirect_uri=https://app.com/callback
[2] Attacker gets their own authorization code
code=ATTACKER_CODE
[3] Attacker stops before redeeming it. Instead:
Crafts a malicious page:
<img src="https://app.com/callback?code=ATTACKER_CODE"
style="display:none">
[4] Victim visits attacker's page
Victim's browser sends the request
App exchanges ATTACKER_CODE for a token
App links ATTACKER's account to VICTIM's session
[5] Victim is now logged into attacker's account
Attacker logs into the app
They see victim's data, payment methods, private contentCVE-2024–42476 affected the Nim OAuth library where the state parameter check was completely disabled when compiled with certain flags. This created a CSRF vulnerability allowing attackers to associate victim sessions with attacker-controlled resources.
The entire defense against this attack is five lines of code: generate a random state on step 2, store it in the session, check it matches on the callback, reject anything that doesn't. When that's missing, every OAuth integration becomes a forced-login CSRF vector.
⛓️ Attack 4 — Open Redirect Chains Into OAuth Takeover
This is the chain nobody closes because the open redirect looks too boring to fix.
An open redirect reported in 2018, assessed as very low risk, and presumably not fixed — combined with an OAuth integration — escalates to complete account takeover. The methodology is trivial to repeat.
OPEN REDIRECT + OAUTH = ACCOUNT TAKEOVER
──────────────────────────────────────────────────────────────────
Target has:
- An OAuth integration with registered redirect: https://app.com/*
- An open redirect: https://app.com/go?url=ANYTHING
Attack:
GET /authorize?
response_type=code
&client_id=APP_ID
&redirect_uri=https://app.com/go?url=https://evil.com
Authorization server checks:
→ Does redirect_uri start with https://app.com? ✓
→ Redirect accepted
User clicks, authenticates, gets redirected to:
https://app.com/go?url=https://evil.com?code=AUTH_CODE
/go bounces to evil.com with the code in the URL
Attacker exchanges code → access token → full accountOPEN REDIRECT + OAUTH = ACCOUNT TAKEOVER
──────────────────────────────────────────────────────────────────
Target has:
- An OAuth integration with registered redirect: https://app.com/*
- An open redirect: https://app.com/go?url=ANYTHING
Attack:
GET /authorize?
response_type=code
&client_id=APP_ID
&redirect_uri=https://app.com/go?url=https://evil.com
Authorization server checks:
→ Does redirect_uri start with https://app.com? ✓
→ Redirect accepted
User clicks, authenticates, gets redirected to:
https://app.com/go?url=https://evil.com?code=AUTH_CODE
/go bounces to evil.com with the code in the URL
Attacker exchanges code → access token → full accountGitHub's OAuth integration with Cloudflare in 2021 chained an open redirect into authorization code theft through exactly this pattern. The only safe redirect URI validation is exact-string match against registered URIs — the IETF's OAuth 2.0 Security Best Current Practice document (RFC 9700, 2025) makes this explicit.
This is why every "low-severity" open redirect lives under threat. Its real severity is whatever the most sensitive OAuth flow on that domain happens to be.
🔀 Attack 5 — IdP Mixing and Token Trust Abuse
This one hits applications that support multiple identity providers and make authorization decisions based on the email address inside the token.
IdP CONFUSION ATTACK
──────────────────────────────────────────────────────────────────
Victim's account: registered via Google
Email in Google token: victim@gmail.com
App logic:
┌──────────────────────────────────────────────────────────┐
│ Receive OAuth token │
│ Extract email from token payload │
│ Find account where email = token.email │
│ Log in that account │
└──────────────────────────────────────────────────────────┘
↑ No check on WHICH IdP issued the token
Attack:
Attacker registers on ANY other supported IdP (e.g. GitHub)
Sets their GitHub email to victim@gmail.com
Logs in via GitHub OAuth
Token email = victim@gmail.com ✓
App finds victim's account → attacker logged inIdP CONFUSION ATTACK
──────────────────────────────────────────────────────────────────
Victim's account: registered via Google
Email in Google token: victim@gmail.com
App logic:
┌──────────────────────────────────────────────────────────┐
│ Receive OAuth token │
│ Extract email from token payload │
│ Find account where email = token.email │
│ Log in that account │
└──────────────────────────────────────────────────────────┘
↑ No check on WHICH IdP issued the token
Attack:
Attacker registers on ANY other supported IdP (e.g. GitHub)
Sets their GitHub email to victim@gmail.com
Logs in via GitHub OAuth
Token email = victim@gmail.com ✓
App finds victim's account → attacker logged inIn the Flickr account takeover writeup, the token that AWS Cognito returned had enough permissions to overwrite user data. Changing the user email for a different user email led to taking over others' accounts.
The fix isn't complicated. The app must check: (1) which IdP issued this token, (2) is that IdP the one this account was registered with, and (3) does the email match the one on record for that specific IdP. Email alone is not an identity claim. Email from a verified-by-us provider is.
🔬 Testing Methodology — What to Check on Every OAuth Flow
OAUTH SECURITY CHECKLIST
──────────────────────────────────────────────────────────────────
redirect_uri tests
□ Submit redirect_uri with trailing slash: /callback/
□ Append path: /callback/../../evil
□ Subdomain confusion: app.com.evil.com
□ @ confusion: app.com@evil.com
□ Case variation: App.Com/Callback
□ Open redirect on domain: /go?url=https://evil.com
□ Fragment confusion: /callback#https://evil.com
state parameter tests
□ Remove state param entirely — does callback still work?
□ Submit state=fixed — does app reject mismatches?
□ Replay old state — does app accept expired values?
code handling tests
□ Does /callback page load external resources? (Referer risk)
□ Can the code be replayed after one use?
□ Is code bound to the redirect_uri it was issued for?
token tests
□ Does scope escalation work? (request offline_access if not granted)
□ Does the app validate iss + aud on received tokens?
□ Can a token from IdP A authenticate an IdP B account?OAUTH SECURITY CHECKLIST
──────────────────────────────────────────────────────────────────
redirect_uri tests
□ Submit redirect_uri with trailing slash: /callback/
□ Append path: /callback/../../evil
□ Subdomain confusion: app.com.evil.com
□ @ confusion: app.com@evil.com
□ Case variation: App.Com/Callback
□ Open redirect on domain: /go?url=https://evil.com
□ Fragment confusion: /callback#https://evil.com
state parameter tests
□ Remove state param entirely — does callback still work?
□ Submit state=fixed — does app reject mismatches?
□ Replay old state — does app accept expired values?
code handling tests
□ Does /callback page load external resources? (Referer risk)
□ Can the code be replayed after one use?
□ Is code bound to the redirect_uri it was issued for?
token tests
□ Does scope escalation work? (request offline_access if not granted)
□ Does the app validate iss + aud on received tokens?
□ Can a token from IdP A authenticate an IdP B account?🛡️ Defensive Guidance
AttackRoot CauseFixRedirect URI bypassPrefix/regex match instead of exactExact string comparison only. No wildcards, no regex.Code leaked via RefererCode in URL, page loads external resourcesUse PKCE. Set Referrer-Policy: no-referrer on callback page.Missing state = CSRFState not generated or not validatedGenerate cryptographic state on each flow. Validate it on callback. Reject mismatches.Open redirect chainOpen redirect inside registered domainClose open redirects. Never allow ?url= to external domains on the callback domain.IdP mixingEmail used as identity across providersBind accounts to (email, IdP) pairs. Never email alone.
One control that improves several of these at once: PKCE (Proof Key for Code Exchange). It binds the authorization code to the client that initiated the flow via a cryptographic verifier. Code theft becomes useless — an attacker who intercepts the code cannot exchange it without the code_verifier that was generated client-side.
The only safe redirect URI validation is exact-string match against registered URIs. If your authorization server does anything less, treat it as a vulnerability.
RFC 9700 (OAuth 2.0 Security Best Current Practice, 2025) says this plainly. Implementations still ship regex matching, prefix matching, and wildcard matching — because those are more convenient for developers who register many redirect URIs. Convenience and security land on opposite sides of this one.
📌 TL;DR
→ Apple paid $100K for a missing ownership check on the email field. One parameter. Critical finding. → Redirect URI: exact-string match only. Every weaker validation is a bypass waiting to be found. → Authorization codes land in URLs. URLs go in browser history, server logs, and Referer headers. CVE-2025–4664 (Chrome) proved this leaks in production. → Missing state = CSRF. Attacker links their code to victim's session. Victim browsed to an attacker's page. That's the full attack. → Open redirects on OAuth domains aren't Low severity. They're Low + Critical = Critical chain. → IdP mixing: email is not identity. (email, provider) is identity. → Use PKCE. Validate state. Exact-match redirect URIs. Check iss and aud. All five. Every OAuth integration.
References
- Bhavuk Jain — Sign in with Apple account takeover, $100,000 bounty (May 2020)
- CVE-2024–52289 — Authentik OAuth2 redirect URI RegEx bypass, one-click account takeover
- CVE-2025–4664 — Chrome Loader referrer policy bypass, authorization code leakage
- CVE-2024–42476 — Nim OAuth2 library state parameter CSRF
- RFC 9700 — OAuth 2.0 Security Best Current Practice (IETF, 2025)
- GitHub + Cloudflare OAuth chain — open redirect → code theft (2021)
- Flickr account takeover via AWS Cognito token — security.lauritz-holtmann.de
- OWASP OAuth 2.0 Security Cheat Sheet