Redirect URIs
Why this page exists
From
internal/auth/oidc/redirect.go,
verbatim:
THIS IS THE CONTROL THE WHOLE AUTHORIZATION-CODE FLOW RESTS ON. The authorize endpoint delivers a user’s authorization code to whatever address the client names, and the ONLY thing stopping an attacker naming their own address is that it must already appear, character for character, in this client’s registered list (
Client.AllowsRedirect).
Everything below exists to make sure the entries in that list cannot be written so loosely that the exact match stops meaning anything.
The matching function, in full
func (c *Client) AllowsRedirect(uri string) bool { for _, u := range c.RedirectURIs { if u == uri { return true } } return false}That is the entire thing. A registered redirect_uri must equal the
requested one byte for byte.
- No wildcards.
https://rp.example/*is not a pattern; it is a literal string that will never match anything. - No prefix matching. A prefix-tolerant compare on
https://app.example.com/cbalso acceptshttps://app.example.com/cb.attacker.net, which hands the authorization code — and the user’s session — to whoever registered that host. “This one comparison is the difference between a safe IdP and a credential-forwarding service.” - No trailing-slash tolerance.
…/callbackand…/callback/are two different URIs. Register both if you use both. - No case folding, no query-string normalisation. Carry per-request
data in
state, never in the redirect URI.
It is checked twice: at /oauth/authorize before a code is issued, and
again at /oauth/token against the URI the code was actually issued for
(RFC 6749 §4.1.3) — which is what stops a code minted for one registered
URI being redeemed against another.
Registration rules
oidc.ValidateRedirectURIs is one implementation, shared by the
administrative and self-service registration paths. “Two copies of a rule
this sharp would eventually disagree, and the disagreement would be a hole.”
Every message below is returned verbatim to whoever is registering the client — they are written to be read by a developer trying to work out why their URI was refused.
| Rule | Message when broken | Why |
|---|---|---|
| No control characters | ”Redirect URIs must not contain tabs, line breaks or other control characters” | Response-splitting material; nothing legitimate contains it. Checked on the raw value, before trimming. |
| Not blank | ”Redirect URIs cannot be blank” | |
| ≤ 512 characters | ”Redirect URIs must be 512 characters or fewer” | Nothing legitimate is near this. |
| No spaces | ”Redirect URIs must not contain spaces” | |
No * | ”Wildcards are not allowed in redirect URIs — register each exact address separately” | A wildcard anywhere — scheme, host, path, query — defeats the exact match. |
No # | ”Redirect URIs must not contain a fragment (the part after #) — the browser never sends it, so it cannot be matched” | Forbidden by RFC 6749 §3.1.2, and invisible to the server at request time. Also catches a bare trailing #. |
| Parses as a URL | ”That is not a valid URL” | |
| Absolute | ”Redirect URIs must be absolute, starting with https:// (or http:// for localhost)“ | |
| No userinfo | ”Redirect URIs must not contain a username or password” | https://[email protected]/ is a reader-versus-parser trick: a human reviewing the registration sees good.example, the browser goes to evil.example. |
| Names a host | ”Redirect URIs must name a host” | |
https, or http on loopback | ”Redirect URIs must use https — plain http is allowed only for local development (http://localhost or http://127.0.0.1, with any port)“ | Plain http puts the authorization code on the wire in clear text. |
| No custom schemes | ”Redirect URIs must use https — custom schemes are not accepted” | |
| ≤ 10 per list | ”At most 10 redirect URIs per application” | Covers production, staging and a couple of local ports, while keeping the exact-match scan small. |
| ≥ 1 redirect URI | ”At least one redirect URI is required” | A client with none cannot complete a single login. |
The offending value is echoed back, truncated to 80 characters:
… — check "https://rp.example/cb#frag".
Any https host is accepted
There is no domain-ownership check. Proving control of the host is the registrant’s problem; exact matching is what makes that safe for us.
The loopback exception, and the trap in it
func isLoopbackHost(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1"}A developer has no way to obtain a certificate for their own machine, and
the code never leaves it — so plain http is permitted there, on any
port.
post_logout_uris
Same rules, one difference: an empty list is fine. Post-logout redirection is optional, and a client that does not use it should not be forced to invent an address.
Normalisation: trim and de-duplicate, nothing else
s := strings.Trim(raw, " ") // SPACES onlyNormalizeURIs trims surrounding spaces, drops exact duplicates, and
preserves order and the value otherwise byte for byte.
Two deliberate omissions:
- No lowercasing the host, no dropping a trailing slash. The stored
string is the one the authorize endpoint compares against, so
“helpfully” rewriting it would silently store something the client will
never send — and every login through it would fail with a
redirect_urimismatch the registrant did not cause. strings.Trim(raw, " "), notstrings.TrimSpace. TrimSpace would strip a trailing tab or CR before the validator ever saw it, so a control character would be silently accepted purely because normalisation runs first. A leading space is a paste artefact and costs nothing to absorb; a tab, CR or LF is not.
A mismatch never redirects
{"error":{"code":"INVALID_INPUT", "details":{"field":"redirect_uri"}, "message":"redirect_uri is not registered for this client"}}A local 400, rendered on the auth server. Refusing to bounce is the
point — echoing an unregistered URI back is precisely the open redirect
this check exists to prevent. Same for an unknown client_id
(“Unknown or inactive client”), because there is nowhere safe to send
them.
After the redirect URI validates, errors go back to the client the
normal way, as 302 redirect_uri?error=…&error_description=…&state=… —
by then we know where they belong.
At the token endpoint the second check produces
400 invalid_grant, “redirect_uri does not match”.
Redirect URIs are also the FedCM origin allow-list
FedCM has no redirect_uri — the browser hands the token straight to the
calling page — so the Origin header is the only thing identifying who is
asking, and Client.AllowsOrigin checks it against the origins of the
registered redirect_uris and post_logout_uris.
Derived, not a second allow-list. A separate fedcm_origins column
would be a second place to get this right, and the two would eventually
disagree. A FedCM-only relying party registers its origin as a
redirect_uri, which is a field every OIDC client already fills in.
Origin comparison drops the default port (:443 on https, :80 on http),
because a browser’s Origin header never carries one while a registered
redirect URI reasonably might — and treating those as different rejects a
legitimate relying party for a reason nothing in the response would
explain.