Skip to content

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_uri must match a registered value byte for byte. /cb and /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:

  1. Control one URL under that prefix, by registering their own subdomain or path or by compromising one downstream resource the prefix admits.
  2. Start an authorize request with that URL as the redirect_uri.
  3. Receive the authorization code in the redirect.
  4. Exchange the code for tokens.

RFC 9700 §4.1 closes this off:

RFC 9700 §4.1 (paraphrase) authorization servers MUST compare the redirect_uri parameter 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.

What "exact match" rejects
Only a byte-for-byte identical redirect_uri matches the registered value. A trailing slash, an added query parameter, a differently cased host, and a different port are each rejected.registered on the clienthttps://app.example.com/cbvalue sent on the authorize requestresultaccepted — every byte identicalhttps://app.example.com/cba trailing slash is a different pathhttps://app.example.com/cb/extra query parameters are not merged inhttps://app.example.com/cb?env=prodthe comparison never case-folds the hosthttps://APP.example.com/cba different port is a different originhttps://app.example.com:8443/cb
None of these are normalised before the comparison, and that is deliberate: every normalisation rule is a rule an attacker can aim a lookalike URI at. A string comparison has no such surface.

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_typeAccepted 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.
nativehttps (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 URIBehaviour
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 localhostRefused 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 hostNo port relaxation. Certificate authorities do not issue certificates for 127.0.0.1, so this combination is rare.
The loopback exception relaxes only the port
Decision flow for the redirect_uri on an authorize request. A value identical to a registered entry is accepted. Otherwise the loopback exception applies only to a public or native client whose registered URI is http on a loopback host and whose request differs only in the port. Anything else is rejected with an error page and no redirect.Identical to a registered entry?compared as a plain string, byte for byteyesAcceptedredirect to the registered URInoDoes the loopback exception apply?public or native client, http loopback URIsame scheme, host, path, query; port differsyesAcceptedredirect to the requested URIwith the port it sentnoRejectedOP shows an error page; no redirect happens
The only loopback exception is the port. A 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, with RedirectURIs on each seed (see Client types). The OP validates them in op.New and 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 /register and its redirect_uris. A violating entry is answered with 400 invalid_redirect_uri or 400 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 unless op.WithAllowLocalhostRedirectURIs() is set. Prefer http://127.0.0.1:<port>/..., which needs no option.
  • Trailing-slash drift. https://app.example.com/cb and https://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. /cb has 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.