Redirect URI
A redirect URI is the URL the OP sends the user back to after the authorization endpoint (/auth, or /oidc/auth when the provider is mounted at /oidc) finishes, with ?code=...&state=... on success or ?error=...&state=... on failure. The RP registers one or more when the client is created, and the OP compares the redirect_uri query parameter of every authorize request against them. A mismatch rejects the request before the user is sent anywhere.
Mental model in 30 seconds
- The
redirect_urimust match a registered value byte for byte./cband/cb/are different URIs. - The one relaxation is the RFC 8252 loopback port, which applies only to public and native clients.
- The OP rejects a bad request before redirecting anywhere, because the redirect itself is the attack vector.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 §3.1.2.3 (redirect URI registration and matching)
- RFC 7591 — Dynamic Client Registration
- RFC 8252 — OAuth 2.0 for Native Apps (§7.3 loopback, §7.1 custom URI schemes, §8.3 DNS rebinding)
- RFC 9700 — OAuth 2.0 Security Best Current Practice (§4.1 redirect URI handling)
- OpenID Connect Registration 1.0 — §2 (
redirect_uris,application_type)
Why exact-match
Wildcard and prefix matching on redirect_uri caused a whole class of vulnerabilities in OAuth's early years. If the OP redirects to any URL under a registered prefix, an attacker can:
- Control one URL under that prefix, by registering their own subdomain or path or by compromising one downstream resource the prefix admits.
- Start an authorize request with that URL as the
redirect_uri. - Receive the authorization code in the redirect.
- Exchange the code for tokens.
RFC 9700 §4.1 closes this off:
RFC 9700 §4.1 (paraphrase) authorization servers MUST compare the
redirect_uriparameter against the registered URIs using simple string comparison; pattern matching, wildcards, and substring matching MUST NOT be used.
The library implements this literally. If you registered https://app.example.com/cb, the request must use exactly that string.
Every rejected row above differs from the registered string by a character or two, and the OP normalises none of them. It compares the two strings and stops. The one exception is the loopback carve-out below, where the port may vary.
What this library refuses
Registration (DCR or op.WithStaticClients) applies the same validator. redirect_uris must be non-empty, every entry must be a non-empty absolute URL that url.Parse accepts, and no entry may contain a fragment, which has no role in the redirect and which the spec forbids. The scheme rules depend on application_type:
application_type | Accepted schemes |
|---|---|
web (default) | https with a host and no userinfo. Also http on the loopback IP literals 127.0.0.1 and [::1]; http://localhost only with op.WithAllowLocalhostRedirectURIs(). Custom schemes are refused. |
native | https (claimed links), loopback http as above, and private-use schemes such as com.example.app:/cb. |
Private-use schemes must contain a dot (reverse-DNS form), because RFC 8252 §7.1 warns that short names like myapp: collide across applications. javascript:, data:, file:, ftp:, ws: and wss: are refused outright.
At authorize time the registered list is checked by string comparison (plus the loopback rule below). A request that matches nothing gets an error response from the OP itself before the browser is redirected anywhere, so the user sees a server-side error page and never an attacker-controlled URL.
Loopback (RFC 8252)
A CLI tool listens on a port the OS assigns at run time, so it cannot register a fixed redirect: http://127.0.0.1:54321/cb today would be http://127.0.0.1:62114/cb tomorrow. RFC 8252 §7.3 carves out an exception:
RFC 8252 §7.3 (paraphrase) native applications using loopback redirect MUST be allowed to specify a port at request time even when the registered port differs.
The library honours the carve-out for public clients and application_type=native clients only, and tightens it against DNS rebinding (RFC 8252 §8.3):
| Registered redirect URI | Behaviour |
|---|---|
http on 127.0.0.1 or [::1] | A public or native client may use any port at authorize time. Scheme, host, path and query must still match exactly. A confidential web client gets exact matching, port included. |
http on the textual localhost | Refused at registration unless op.WithAllowLocalhostRedirectURIs() is set, for web and native clients alike. localhost resolves through DNS, which can be hijacked. Once admitted, it follows the same any-port rule as the IP literals. |
https on a loopback host | No port relaxation. Certificate authorities do not issue certificates for 127.0.0.1, so this combination is rare. |
localhost registration reaches the second test only if op.WithAllowLocalhostRedirectURIs() let it through at registration.DNS rebinding is the reason localhost is special
The textual localhost is a name the OS resolves through DNS. An attacker who can poison DNS for localhost (a hijacked LAN resolver, a malicious resolver, hostile WiFi) can make the user's browser land on a host the attacker controls while the OP believes it sent the user to loopback. The IP literals 127.0.0.1 and [::1] skip DNS entirely, so they are the default loopback form and localhost needs an explicit opt-in. See Design judgments — RFC 8252 loopback handling.
How clients are registered
A redirect URI reaches the OP by one of two paths, and both use the same validator:
- Static seeds. Pass
op.WithStaticClients(seeds...)at construction, withRedirectURIson each seed (see Client types). The OP validates them inop.Newand refuses to start on a malformed value. This suits a small, known fleet: internal tools, single-tenant SaaS, vetted FAPI clients. - Dynamic Client Registration (RFC 7591). An RP registers itself at runtime with
POST /registerand itsredirect_uris. A violating entry is answered with400 invalid_redirect_urior400 invalid_client_metadata. This suits multi-tenant SaaS, marketplaces, and any OP whose operator does not know the clients in advance. See Dynamic Client Registration.
Common mistakes
- Registering
http://localhost/...for development. Registration fails unlessop.WithAllowLocalhostRedirectURIs()is set. Preferhttp://127.0.0.1:<port>/..., which needs no option. - Trailing-slash drift.
https://app.example.com/cbandhttps://app.example.com/cb/are different URIs. Register one form and have the RP framework send it verbatim. - Mixed case in the host. An RP framework fed a pasted URL may upper-case the host. The comparison is case-sensitive, so the request no longer matches.
- Path-only registration.
/cbhas no scheme or host, so it is not a valid redirect URI, and registration rejects it. - Expecting wildcards.
*in a path does not work; RFC 9700 forbids wildcards.
Read next
- Authorization Code + PKCE flow — where the redirect URI travels and what the OP checks at each step.
- Client types — public and native clients are the ones the loopback port exception applies to.
- Dynamic Client Registration — runtime registration and how the same validation applies.
- Design judgments — RFC 8252 loopback handling — the explicit reasoning for the loopback rules.