You sign in with a button, get bounced to an authorization server, approve, and land back on the app already logged in. The piece that makes that round trip work is one URL: the redirect_uri. OAuth redirect_uri manipulation is what happens when the authorization server is careless about checking that URL, so an attacker can point the authorization code or token at a server they own and walk away with your session. The flow looks normal to the user. The code just lands in the wrong place.
A quick frame: where the code goes
In the OAuth 2.0 authorization code flow, the app sends the user to the authorization server with a request like this:
https://auth.acme-notes.com/authorize? response_type=code& client_id=app123& redirect_uri=https://app.acme-notes.com/callback& scope=read& state=xyz789
The user logs in and approves. The authorization server then sends them back by redirecting the browser to the redirect_uri with a short lived code attached:
https://app.acme-notes.com/callback?code=AUTH_CODE_HERE&state=xyz789
The app’s backend takes that code, exchanges it for an access token, and the user is in. The whole security of this step rests on one idea: the code must only ever be delivered to a URL the real app controls. The redirect_uri is the address label on the package. If the server lets the client write any label it wants, the package goes wherever the attacker says.
OAuth redirect_uri manipulation: the validation failures that leak the code
The fix is supposed to be simple. The client registers its callback URL ahead of time, and the server only sends codes to a URL that matches what was registered. The bugs all come from matching too loosely. Here are the common ways that check fails.
No exact match, so subpaths and query params slip through
Say the registered URL is https://app.acme-notes.com/callback and the server checks only that the incoming value starts with that string. Now an attacker can append a path or a query:
redirect_uri=https://app.acme-notes.com/callback/../evil redirect_uri=https://app.acme-notes.com/callback?next=https://evil.example
If any endpoint on that host bounces the request onward, the code travels with it. That is a classic open redirect chained into OAuth. The host matches. The destination does not.
Wildcard or substring matching
Some servers allow a wildcard like https://*.acme-notes.com/callback for convenience across subdomains. If an attacker can register or control any subdomain, even a forgotten one, they get a matching callback:
redirect_uri=https://attacker-controlled.acme-notes.com/callback
Substring checks are worse. A server that just looks for acme-notes.com anywhere in the value accepts this:
redirect_uri=https://acme-notes.com.evil.example/callback
The real domain is right there in the string. It is also just a subdomain of evil.example, which the attacker owns.
Missing registration entirely
If the client never registered a redirect_uri, or the server allows any value when none is registered, there is nothing to match against. The attacker sets the callback to their own server and the code is handed straight over.
Chaining with an open redirect on the legitimate domain
This is the one that bites teams who thought they did everything right. Suppose exact matching works and only https://app.acme-notes.com/callback is accepted. But somewhere else on that same host there is an old marketing endpoint that redirects wherever a parameter says:
https://app.acme-notes.com/go?url=https://evil.example
The attacker cannot change the registered callback. They do not need to. They craft an authorize URL with the exact, valid redirect_uri, and inside the app’s own flow the code lands on a page that then forwards the browser, fragment and query intact, to the attacker. The OAuth check passed. The open redirect did the rest.
The authorization code is a bearer token for your account. Whoever it reaches first wins. Loose redirect_uri matching just hands them the address.
A concrete walkthrough
Here is the tampered request next to the honest one. The attacker sends a victim a link that looks like a normal login. The only change is the callback:
// Honest https://auth.acme-notes.com/authorize?response_type=code& client_id=app123&redirect_uri=https://app.acme-notes.com/callback&state=xyz789 // Tampered, on a server with loose matching https://auth.acme-notes.com/authorize?response_type=code& client_id=app123&redirect_uri=https://app.acme-notes.com.evil.example/callback&state=xyz789
The victim is already logged in to the authorization server, so they may not even see a prompt. The server validates the callback with a substring check, decides it is fine, and redirects:
https://app.acme-notes.com.evil.example/callback?code=AUTH_CODE_HERE&state=xyz789
The attacker’s server logs the code, exchanges it for a token, and is now inside the victim’s account. The victim never typed a password into a fake page. They used the real one.
Why PKCE and state help but do not fully fix this
Two protections often get named as the answer here. They are good. They are not a replacement for matching the URL.
- State stops cross site request forgery on the callback. It ties the response back to the request the browser actually started. It does nothing about where the code is delivered. A stolen code with a matching state is still a stolen code.
- PKCE binds the code to a secret the real client holds, so a leaked code cannot be exchanged without the matching verifier. That blocks many theft scenarios. But if the attacker controls the page the code lands on, in a public client running in the browser, the verifier can leak through the same channel. PKCE also does not help when the attacker can run script on a matched host through a chained open redirect.
This is the same lesson as other authentication bugs where one weak check undoes the rest of the protocol. It shows up in SAML signature wrapping, where a valid signature guards the wrong bytes, and in JWT algorithm confusion, where the token verifies but with the attacker’s key. The clever parts of the flow do not save you if the boring check at the edge is loose.
Defenses that actually close it
- Exact string match on registered redirect URIs. Compare the full incoming value against the full registered value, byte for byte. No prefix checks, no normalization that strips paths, no host only comparisons.
- No wildcards. Do not allow
*in registered URLs. Register each full callback your app uses, even if that means a longer list. - Register complete URIs. Scheme, host, port, and path, all fixed. Never accept a request with no registered value to match against.
- Kill open redirects on allowed hosts. Audit every endpoint on a host that holds a valid callback. An open redirect anywhere on that host reopens this bug even with perfect matching.
- Use PKCE and state. Add them as layers, not as the fix. They cut the value of a leaked code and block CSRF on the callback.
- Prefer the authorization code flow with strict matching. Avoid handing tokens back directly in a redirect. Deliver a code to one exact registered URL and exchange it server side.
The assumption that breaks
Strip away the parameters and one assumption is left. The server assumes the redirect_uri it receives is one it agreed to. That holds only when the comparison is exact and every allowed host is clean of open redirects. The bug is rarely a single obvious flaw. It is a loose match plus a stray redirect two teams away, and only chaining them shows the leak. This is the kind of issue you find by asking what a system trusts, where it checks, and whether two safe looking pieces combine into an unsafe one. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about how checks chain, rather than scanning for one known pattern, is what an autonomous researcher that tests assumptions is built to do. Read more on our about page.
Frequently asked questions
What is OAuth redirect_uri manipulation?
It is an attack where the authorization server validates the redirect_uri loosely, so an attacker can change it and have the authorization code or token delivered to a server they control. In the OAuth 2.0 authorization code flow the server sends the code back to the redirect_uri, so whoever that URL points at receives the code. If the check is not an exact match against a registered URL, the attacker redirects the code to their own host and takes over the account.
How does an attacker exploit a loose redirect_uri check?
They craft an authorize URL with a tampered callback and trick a logged in user into opening it. Common failures: prefix or substring matching that accepts https://acme-notes.com.evil.example/callback, wildcards like https://*.acme-notes.com/callback on a subdomain they control, no registered value to match against, or an open redirect on the legitimate host that bounces the code onward even when matching is exact. In each case the code lands on the attacker’s server.
Do PKCE and state stop redirect_uri manipulation?
They help but do not fully fix it. State stops cross site request forgery on the callback but does nothing about where the code is delivered. PKCE binds the code to a secret the real client holds, so a leaked code is harder to exchange, but in a public browser client the verifier can leak through the same channel the code does, and PKCE does not help when the attacker runs script on a matched host through a chained open redirect. Treat both as layers, not as the fix.
How do you prevent OAuth redirect_uri manipulation?
Match the full registered redirect URI exactly, byte for byte, with no prefix checks or wildcards. Register complete URIs with scheme, host, port, and path, and never accept a request with no registered value. Audit every endpoint on any host that holds a valid callback and remove open redirects, since one open redirect reopens the bug even with exact matching. Add PKCE and state as extra layers, and prefer the authorization code flow with strict matching over returning tokens in a redirect.
