Skip to content

Redirect URIs match exactly, with a loopback-only port exception

The v5 security audit flagged the redirect-URI comparator (urlsAreSameIgnoringPort) as a deviation from RFC 6749 §3.1.2.3 and RFC 9700 §2.1: it compared only protocol + hostname + pathname, so port and query variance was accepted for every host — a registered https://app.example.com/cb matched a requested https://app.example.com:8443/cb, which on shared/multi-tenant hosts delivers authorization codes (and implicit tokens) to a different origin. RFC 8252 §7.3 permits port flexibility for loopback interfaces only. We decided: a requested redirect URI matches a registered one only when both normalize to the same URI under WHATWG URL parsing (new URL(x).href equality, plain string equality as the fallback for unparseable values), except that the port — and only the port — may differ when the registered URI is a loopback redirect: http scheme, hostname localhost, 127.0.0.1, or [::1], with the requested hostname identical. Alongside this, a missing redirect_uri parameter is defaulted only when the client has exactly one registered URI; with zero or multiple, the authorization request is rejected with invalid_request, enforced in the grants' validation seam (getRedirectUri), not in the public AuthorizationRequest constructor. This ships as a hard cutover in v5.0.0 with no compatibility option.

Considered Options

  • Byte-for-byte string comparison (the literal "simple string comparison" of RFC 6749 §3.1.2.3) — rejected: passing both sides through the same WHATWG normalizer cannot reintroduce host/path/query variance, but byte equality would newly reject default-port (:443), hostname-case, and bare-host-vs-/ differences that the old parser-based comparator tolerated — consumer breakage with no security gain.
  • Loopback = IP literals only (the letter of RFC 8252 §7.3; §8.3 discourages localhost) — rejected: §8.3's NOT RECOMMENDED is guidance to clients choosing a URI — its risks (listening on non-loopback interfaces, client-side firewalls, hostname resolution) all live on the user's device — while RFC 9700 §2.1, the text addressed to servers, words the exception as "localhost redirection URIs". localhost:<port> is the dominant dev-redirect pattern, and registration is the per-client control: an operator who doesn't want localhost simply doesn't register it. A treatLocalhostAsLoopback server option was added during review and removed for the same reasons — it duplicated the registration-level control globally and protected nobody (a fixed port is as bindable by a local attacker as an ephemeral one; PKCE is the real mitigation). Cross-host matching is still refused — localhost never matches 127.0.0.1.
  • A redirectUriMatchMode: "legacy" opt-out, mirroring v5's implicitRedirectMode: "query" — rejected: the lenient comparator is a code-interception vulnerability, not a stylistic default; an opt-out preserves the hole permanently and invites cargo-culting. The RC window is precisely when the hard cutover belongs (same reasoning as ADR 0006).
  • Port leniency for private-use schemes (com.example.app://callback:3000 previously matched com.example.app://callback, and a named e2e test asserted it) — rejected: RFC 8252 §7.1 grants no port allowance to private-use schemes; the OS dispatches by scheme and a port there is inert. The test documented an accident of the old comparator, not a feature. The exception is therefore gated on the http scheme (§7.3 defines loopback redirects as http://127.0.0.1:{port} / http://[::1]:{port}), which also keeps a loopback-named host on a private-use scheme (myapp://localhost) or on https under exact matching.

Consequences

  • Clients that append dynamic query parameters to their redirect_uri at request time break; they must register each variant or carry per-request data in state.
  • The public AuthorizationRequest constructor still defaults to redirectUris[0]. Consumers who hand-construct it (e.g. rebuilding from a session between login redirects) bypass the multiple-URI requirement — deliberate: validation has already run by then, and changing the constructor would break the session-rebuild pattern.
  • A mismatched redirect_uri still throws invalid_client (401), which is itself questionable under RFC 6749 §4.1.2.1 — left untouched here on purpose; it belongs to the authorization-endpoint error-delivery rework (audit finding 12).
  • The comparator is internal (no public export), so replacing urlsAreSameIgnoringPort outright is not an API break; the documented matching rules on tsoauth2server.com become the contract.