Redirect URI validation
An authorization code goes wherever the authorization request asks for it, as long as the authorization server agrees. The printer writes that request, but anyone can write one. It is an ordinary URL, and nothing stops an attacker from copying the printer's client ID into a request of their own and naming a different return address.
Redirects and authorization codes said the photo service compares the requested redirect URI with the registered one exactly, refusing even an added trailing slash. That strictness can look fussy until you follow an attacker through a version of the photo service that compares less carefully.
Sending the code somewhere else
All domains and codes in these examples are fictional. The attacker begins by building a link to the photo service's authorization endpoint:
https://auth.photos.example/authorize
?response_type=code
&client_id=photo-printer
&redirect_uri=https%3A%2F%2Fprinter.example.attacker.example%2Foauth%2Fcallback
&scope=photos.read
&state=demo-attacker-state-1
&code_challenge=13TUFxsJi_vyUQcynxTDC61Vw6HLxqvpa2jIzPC_fBc
&code_challenge_method=S256
Everything except the redirect URI is what the printer itself would send. The challenge is one the attacker computed from a verifier of their own.
- The attacker sends you the link, dressed up as an offer: "Print your summer album free this week."
- You are already signed in at the photo service. The consent screen names Photo Printer, because the request carries the printer's client ID, and you approve.
- This careless version of the photo service checks the redirect URI with a shortcut: does it begin with
https://printer.example? It does, so the service sends your browser, with a fresh code, toprinter.example.attacker.example, a name under the attacker's domain. - The attacker's server reads the code from the incoming request.
What the code is worth depends on the client it was issued to. Had the request named the printer's phone app, photo-printer-app, the attacker could redeem the code at once. A public client sends only its client ID to the token endpoint, and PKCE offers no protection here, because the attacker wrote the request and holds the verifier behind its challenge. The printer's website is a confidential client, so the attacker cannot redeem the code without the printer's credentials. They can still try to make the printer redeem it for them.
A consent screen that shows the return domain gives you a slim chance to notice. The address begins with printer.example precisely so that it passes a glance.
How careless matching goes wrong
The prefix check in step 3 is one of several shortcuts that have let attackers through. In each row below, the printer registered only https://printer.example/oauth/callback, except where noted.
| Requested redirect URI | Shortcut that accepts it | What the shortcut missed |
|---|---|---|
https://printer.example.attacker.example/oauth/callback | Begins with https://printer.example. | The host name keeps going. This host belongs to attacker.example. |
https://printer.example/oauth/callback/../../go | Begins with the registered address. | The browser resolves each .. segment, also when written %2e%2e, and arrives at https://printer.example/go. |
https://printer.example/oauth/callback/preview | The registered address or anything below it. | A different page, built for a different purpose, receives the code. |
https://printer.example/oauth/callback?next=https://attacker.example/ | Compares the address without its query. | The callback receives an instruction the printer never registered, which a callback that follows next would obey. |
https://attacker.example\.printer.example/oauth/callback | The host, as read by the server's own parser, ends with .printer.example. | Browsers treat \ as / in web addresses, so the host is attacker.example. |
https://promo.printer.example/oauth/callback, with https://*.printer.example/oauth/callback registered | Any subdomain of printer.example. | The subdomain may no longer be under the printing company's control. |
http://printer.example:8080/oauth/callback | The port may vary and plain HTTP is allowed, rules meant for loopback addresses. | A different service on another port receives the code over an unencrypted connection. |
The wildcard row needs one more idea. A subdomain takeover happens when a DNS record still points a name such as promo.printer.example at an outside hosting service the printing company has stopped using. Anyone who opens an account with that service under the old name can then serve pages at the printer's subdomain, and a wildcard registration would send them codes.
The backslash row belongs to a family of tricks that work because the validator and the browser read the same text differently. Percent-encoding, unusual characters, and letter case all give two parsers a chance to disagree. Exact comparison avoids the question. The requested value must be the same sequence of characters as a registered value, so even https://PRINTER.example/oauth/callback is refused, although it leads to the same place. Refusing a harmless variant costs a developer one corrected setting.
The only flexibility current guidance allows is the one Redirect URIs and client metadata described: the port of a loopback address, such as 127.0.0.1, used by a native app. It does not extend to any other host.
Leaks from the printer's own pages
Suppose the printer's site has a page at https://printer.example/go that sends the browser to whatever address appears in its to parameter, used for "continue to our partner" links. That is an open redirect, the kind of page Correlating requests and responses warned against.
If the photo service accepted any address on printer.example, the attacker could ask for the code to be returned to /go, with to pointing at attacker.example. The validation passes, because the address really is on the printer's domain, and the printer's own page then forwards the browser onward. Whether a code in the query survives the second hop depends on the redirector: some pass the remaining parameters along, and one that redirects with script can expose its full address in the Referer header if its referrer policy allows it. A response carried in the URL fragment, as the implicit grant carried access tokens, fares worse. When an HTTP redirect names no fragment of its own, the browser carries the original fragment along to the new address. Exact matching closes this route, because /go is not a registered address, and current guidance goes further: clients and authorization servers must not run open redirectors at all.
Even with exact matching, the code still arrives at a page, and that page can leak it. Redirects and authorization codes asked for a callback without third-party resources, and the reasons are concrete. Any script running on the callback page can read the full address, so an analytics or advertising script there would receive every code. Requests the page makes to other sites carry a Referer header. Current browsers send only the site's origin to other sites by default, but a page can choose a policy that sends the full address. The printer's callback therefore loads nothing from other sites, sets its own referrer policy, exchanges the code, and moves the browser to a clean address straight away:
HTTP/1.1 303 See Other
Location: https://printer.example/photo-book
Referrer-Policy: no-referrer
Cache-Control: no-store
The code's own properties help as well. It is single-use and short-lived, so a copy that reaches someone after the printer has redeemed it is worth very little.
Who checks what
Both sides share the work, and neither can do the other's part.
| Party | Responsibility |
|---|---|
| Authorization server | Compares the requested redirect URI with each registered value as an exact string, before showing anything or redirecting. The only exception is a loopback port for a native app. |
| Authorization server | On a mismatch or an unknown client, shows an error on its own page and never redirects, as Errors and denied access described. |
| Authorization server | Refuses redirect URIs that use plain http, apart from a native app's loopback address, and refuses wildcard registrations. A native app's private-use scheme remains allowed. |
| Authorization server | Stores the redirect URI with the code and checks the token request against it. |
| Client | Registers each exact callback, with test sites under their own registrations. |
| Client | Runs no open redirects on its domains and validates any return destination after the callback. |
| Client | Keeps the callback free of third-party content, sets a strict referrer policy, and moves to a clean address. |
| Client | Removes DNS records for services it no longer uses. |
The authorization server's check decides where a code can go, and the client's care decides whether it stays there. When both fail and a code for the printer's website escapes, the attacker still cannot redeem it without the printer's credentials. Code interception and injection follows what they do instead.