NamoID public betaBuilding customer authentication? Get setup help and share feedback with other builders.Join the Slack community
NamoID
All posts
NamoID Blog

Exact Redirect URI Matching: Why One Character Matters

An OAuth redirect URI answers a sensitive question: after the authorization server finishes, where may it send the authorization result?

If the answer is “anything that looks close enough,” an attacker can turn a legitimate sign-in into a code-delivery service for an attacker-controlled endpoint. One character can change the host, path, port, query, or interpretation of a URL. That is why current OAuth security guidance chooses exact string matching over clever patterns.

The redirect carries a credential

In the authorization code flow, the browser returns to a client callback with a short-lived code:

https://app.example.com/auth/callback?code=...&state=...

The code is not the final access token, but it is still a credential. A client that receives it can attempt the token exchange. PKCE limits what a stolen code can do, but redirect validation remains mandatory: it prevents leakage, reduces mix-up risk, and preserves the binding between the authorization request and the intended client endpoint.

RFC 9700, OAuth 2.0 Security Best Current Practice, says authorization servers must use exact string matching against pre-registered redirect URIs, with one narrow exception for loopback ports used by native applications.

Why pattern matching fails

Teams often want convenient rules such as:

allow anything under example.com
allow any path beginning with /auth
allow any callback whose string starts with https://app.example.com

Each rule creates parsing questions that are easy to answer incorrectly.

Prefix matching

Registered:

https://app.example.com/auth/callback

Attacker supplies:

https://app.example.com.evil.example/auth/callback

The attacker's string begins with the expected characters, but its host is app.example.com.evil.example, not app.example.com.

Suffix matching

A rule that accepts anything ending in example.com may also accept a hostname such as notexample.com unless the implementation understands DNS labels. Adding an expected dot helps one case and creates more complexity around ports, case, trailing dots, and internationalised names.

Wildcard subdomains

This registration:

https://*.example.com/auth/callback

trusts every matching subdomain, including forgotten preview deployments, user-controlled tenant names, and DNS records vulnerable to takeover. The authorization server cannot know which sibling application has the same security posture as the intended callback.

Path prefixes

Allowing every path under /auth/ can expose an open redirector elsewhere on the same host:

https://app.example.com/auth/continue?next=https://attacker.example

The authorization server sends the response to the legitimate host, which immediately forwards the browser and its parameters to the attacker.

URL normalisation disagreements

Libraries may disagree about encoded characters, duplicate slashes, dot segments, ports, case, query ordering, and Unicode hostnames. If registration normalises one way and runtime matching another way, an attacker searches for a URL the two components interpret differently.

Exact strings remove most of this custom comparison logic.

What exact matching means

Register the complete callback:

https://app.example.com/auth/callback

At the authorization endpoint, accept only that exact string. These are different values and should be separately registered or rejected:

http://app.example.com/auth/callback       # scheme differs
https://www.app.example.com/auth/callback  # host differs
https://app.example.com:8443/auth/callback # port differs
https://app.example.com/Auth/callback      # path case differs
https://app.example.com/auth/callback/     # trailing slash differs
https://app.example.com/auth/callback?v=1  # query differs

OAuth 2.0's original RFC 6749 requires simple string comparison when the full redirect URI was registered. RFC 9700 strengthens the operational conclusion: complete registration and exact matching should be the normal rule.

Do not parse, modify, and reconstruct the candidate before comparison at runtime. Validate and canonicalise the value at registration according to a documented client-type policy, store the accepted full string, and compare the authorization request to that stored string.

Validate at registration and match at runtime

Registration and runtime checks serve different purposes.

At registration, parse the URL and enforce policy:

  • web clients use https, except explicit localhost development entries;
  • the URI has no fragment;
  • credentials in the URL userinfo component are rejected;
  • host, path, and port are explicit and valid;
  • wildcard hosts and wildcard paths are rejected;
  • private or loopback destinations are accepted only for the intended client type;
  • each environment has a bounded number of callbacks;
  • the complete accepted string is stored.

At authorization time, use exact membership:

def redirect_is_allowed(candidate: str, registered: list[str]) -> bool:
    return candidate in registered

The deliberately boring comparison is the security feature.

NamoID performs this exact membership check for OAuth applications. It also requires the redirect URI at the token endpoint to equal the URI bound to the authorization code, preventing a code initiated for one callback from being redeemed with another.

The token endpoint must preserve the binding

The authorization server should bind these values to the code:

authorization code
  ├── client_id
  ├── redirect_uri
  ├── PKCE code_challenge
  ├── user and authorization context
  └── expiry and single-use state

At exchange time, validate the client, exact redirect URI, PKCE verifier, expiry, and single-use status atomically. A correct redirect check only at /authorize is incomplete if the token endpoint accepts a different value later.

RFC 6749 requires the token request's redirect URI, when used, to be identical to the one in the authorization request. RFC 9700 explains that this check can also detect tampering and some code-injection scenarios.

PKCE does not replace redirect validation

PKCE binds the authorization code to a verifier held by the client. If an attacker steals only the code, they should not be able to redeem it without the verifier.

Exact redirect matching still matters because:

  • the authorization response may contain errors, state, issuer, or other sensitive context;
  • implementation mistakes can expose the verifier too;
  • a malicious endpoint can phish or manipulate the browser after receiving the redirect;
  • redirect validation helps prevent mix-up and open-redirect chains;
  • security controls should not depend on one mitigation absorbing every failure.

Use authorization code flow, PKCE, exact redirects, state, issuer validation, and single-use codes together.

The native-app loopback exception

Desktop native applications often start a temporary listener on a random loopback port:

http://127.0.0.1:51004/oauth/callback

The port cannot always be known at registration. RFC 8252 therefore requires authorization servers supporting loopback redirects to allow variable ports while still matching the loopback IP literal, scheme, path, and other components.

This is not a general localhost wildcard. Apply it only to registered native public clients and loopback IP redirect URIs. RFC 8252 recommends IP literals such as 127.0.0.1 or [::1] rather than localhost because hostname resolution can behave unexpectedly.

For mobile apps, prefer claimed HTTPS links where the operating system verifies domain ownership. Private-use URI schemes should use reverse-domain notation and PKCE because another app may attempt to claim the same scheme.

Development callbacks should be explicit

Do not loosen production matching to make local development convenient. Register separate complete values:

http://localhost:3000/auth/callback
https://preview-123.example.dev/auth/callback
https://app.example.com/auth/callback

Better, keep Test and Live OAuth applications or environments separate. A production client should not carry a long list of localhost and temporary preview callbacks.

Preview deployments create a lifecycle problem: each exact callback must be registered and later removed. Automate that through a controlled deployment integration with bounded domains and expiry rather than introducing a wildcard.

Redirect destinations must not be open redirectors

Exact registration can still point to a client endpoint that forwards users to arbitrary destinations. RFC 9700 says clients and authorization servers must not expose redirectors that accept an arbitrary target from a query parameter.

Unsafe:

GET /auth/callback?next=https://attacker.example

Safer:

  • complete callback processing first;
  • map an opaque server-side state value to a prevalidated internal destination;
  • allow only relative application paths;
  • reject schemes, hosts, backslashes, control characters, and protocol-relative values;
  • use a fixed default when the destination is missing or invalid.

Do not put the desired post-login URL directly into redirect_uri. Use transaction state that the client signs or stores server-side.

Handle invalid redirects without redirecting

If an authorization request contains an invalid or unregistered redirect URI, do not send the browser to it with an OAuth error. Render an error on the authorization server's own origin.

Otherwise the validation failure itself becomes an open redirect:

invalid redirect supplied
→ server reports error by redirecting to invalid destination
→ attacker wins anyway

Show a safe page that identifies the client where appropriate, says the callback is not configured, and tells the developer how to restart or contact the application owner. Keep internal matching details out of the browser response.

Test the comparison boundary

For every registered callback, add negative tests for:

  • HTTP instead of HTTPS;
  • changed host and deceptive suffix host;
  • added or removed www;
  • changed port;
  • path case and trailing slash;
  • path prefix and dot segments;
  • added query or fragment;
  • encoded path separators and control characters;
  • username/password URL syntax;
  • wildcard and unregistered subdomain;
  • an open redirect on the legitimate callback host;
  • token exchange using a redirect different from authorization;
  • invalid redirect errors staying on the authorization-server origin;
  • loopback port variation accepted only for eligible native clients.

Fuzz the registration parser, but keep runtime matching simple.

One character is part of the trust decision

Redirect URIs are not navigation preferences. They are registered credential-delivery endpoints. Scheme, host, port, path, and query all participate in that decision.

Exact string matching gives developers a little more configuration work and removes an entire category of ambiguous security logic. Register every real callback, separate Test from Live, bind the chosen value to the authorization code, and reject everything else on the authorization server's own page.

NamoID OAuth applications use complete registered redirect URIs and exact matching at authorization and code exchange. Create a NamoID account to test an integration in an isolated Test environment.

Related posts