Skip to content
rw3iss Auth

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/cb also accepts https://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. …/callback and …/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.

RuleMessage when brokenWhy
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 only

NormalizeURIs 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_uri mismatch the registrant did not cause.
  • strings.Trim(raw, " "), not strings.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.