One string comparison decides who gets the login
The redirect URI is where your login credential gets delivered. Loose matching hands it to an attacker. Why exact matching is the rule, with real bypasses.
- security
- vibe coding
- engineering
Every OAuth flow ends with the authorization server making one decision: where to send the thing it just minted. That decision is not made by your code, not by your session, and not by the user. It is made by comparing a string in the request against a string in a configuration table, and then trusting the result enough to deliver a credential to it.
There is no second gate behind that comparison. If the string wins, the browser goes there, and the authorization code or the token goes with it. Which means the security of the entire flow, including the part where a user carefully typed a password and a six-digit code, reduces to whether two strings were compared correctly.
Teams get this wrong in a very specific way. Nobody forgets to validate the redirect URI. They validate it with a pattern, because exact strings are annoying during development, and a pattern is a small program whose behavior on hostile input nobody has checked.
The redirect URI is a delivery address, not a preference
It helps to be precise about what arrives at that address, because the three possibilities have wildly different consequences, and on the stack most of these posts are about, the client library’s default is the worst of them.
An authorization code. A short-lived string that has to be exchanged at the token endpoint, using the client’s own credentials or a PKCE verifier. Leaking it is bad and often still fatal, but it is a step away from an account.
An access token, in the URL fragment. This is the implicit flow, and here the leak is the account. RFC 9700, the current OAuth 2.0 security best practice, is direct about it in section 2.1.2. It states the problem first: “The implicit grant (response type token) and other response types causing the authorization server to issue access tokens in the authorization response are vulnerable to access token leakage and access token replay as described in Sections 4.1, 4.2, 4.3, and 4.6.” Then the instruction: “In order to avoid these issues, clients SHOULD NOT use the implicit grant (response type token) or other response types issuing access tokens in the authorization response, unless access token injection in the authorization response is prevented and the aforementioned token leakage vectors are mitigated.”
A whole session. On a managed backend, the same redirect machinery carries password reset links, magic links and email confirmations. What lands at the address is not a code to be exchanged, it is a credential that signs somebody in. That is the case worked through in password reset poisoning, and it is why this parameter deserves more attention than an implementation detail normally gets.
Note that the third case does not require the reader to have implemented OAuth at all. If your app has a login and a “forgot password” flow on Supabase or Firebase, you already own a redirect allowlist, whether or not you have ever looked at it.
The rule is one sentence, and it is boring on purpose
RFC 9700 section 2.1 states the requirement without hedging: “When comparing client redirection URIs against pre-registered URIs, authorization servers MUST utilize exact string matching except for port numbers in localhost redirection URIs of native apps (see Section 4.1.3).”
Exact string matching. Not normalized, not prefix, not “same origin,” not a regular expression. The one carve-out is a native app on a loopback address, which cannot know in advance which port the operating system will hand it, and that exception is written narrowly enough to be useless as a precedent.
Section 4.1.3 explains the reasoning, and the wording is unusually blunt for a standards document: “The complexity of implementing and managing pattern matching correctly obviously causes security issues.” Then: “This document therefore advises simplifying the required logic and configuration by using exact redirection URI matching.”
Read that as an engineering claim rather than a security one. The argument is not that patterns are risky in the abstract. It is that a pattern introduces a second URL parser, written by you, that has to agree with the browser’s parser on every input an attacker can construct. Exact matching does not make that parser safer. It deletes it.
The same section of the RFC closes a companion hole that people treat as unrelated: “Clients and authorization servers MUST NOT expose URLs that forward the user’s browser to arbitrary URIs obtained from a query parameter (open redirectors) as described in Section 4.11. Open redirectors can enable exfiltration of authorization codes and access tokens.” Those two requirements are one requirement. An exactly matched redirect URI pointing at a route on your site that forwards wherever a query parameter says is an exactly matched redirect URI to the attacker’s server, in two hops. Section 4.1.2 says how far that reaches, and the second half of the sentence is the argument for this whole post: the chain “allows circumvention even of very narrow redirection URI patterns, but not of strict URL matching.” Everything in open redirect applies directly here, and this is the payload that makes that bug severe rather than annoying.
Two published bypasses, and both were the pattern engine
Neither of these is an obscure library. Both are identity products maintained by teams who build authorization servers for a living, which is the point: if pattern matching were manageable, it would have been managed here.
authentik, CVE-2024-52289. The
advisory
is worth quoting exactly, because the mechanism is four words long. “Redirect
URIs in the OAuth2 provider in authentik are checked by RegEx comparison.” Then
the consequence: “Given a provider with the Redirect URIs set to
https://foo.example.com, an attacker can register a domain fooaexample.com,
and it will correctly pass validation.”
The dot. In a regular expression, . matches any character, so a configured URI
is a pattern that matches every sibling domain with the separator swapped for a
letter. An attacker registers one, and the authorization server delivers the code
there while agreeing that validation passed.
The second half of that advisory is the part to take personally: “When no
Redirect URIs are configured in a provider, authentik will automatically use the
first redirect_uri value received as an allowed redirect URI, without escaping
characters that have a special meaning in RegEx.” An empty configuration
populated itself from the first request that arrived. The advisory adds that “the
documentation did not take this into consideration either.”
The fix landed in 2024.8.5 and 2024.10.3, and the shape of it is a good measure of how load-bearing this comparison is: it changed the storage format so each entry declares whether it is strict or a regex, which made it “a backwards-incompatible database change and API change,” with the note that “Manual action is required if any provider is intended to use RegEx for Redirect URIs because the migration will set the comparison type to strict for every Redirect URI.” Breaking every deployment was the cheaper option.
Keycloak, CVE-2026-3872. Same family, different parser.
NVD carries Red Hat’s
description: “A flaw was found in Keycloak. This issue allows an attacker, who
controls another path on the same web server, to bypass the allowed path in
redirect Uniform Resource Identifiers (URIs) that use a wildcard.” It is scored
7.3 high (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N) and classified
CWE-601, open redirect,
published 2026-04-02 and fixed in Red Hat build of Keycloak 26.2.15 and 26.4.11.
The precondition is the whole story: it applies to redirect URIs “that use a
wildcard.” A pattern intended to allow a set of paths under one host allowed a
path outside that set, and Keycloak’s own tracking
issue names the mechanism in
its title, “Redirect URI validation bypass via ..;/ path traversal in OIDC auth
endpoint.” (The issue body is empty, so treat the title as the only detail the
project published there.) Red Hat’s
CVE page gives a
mitigation that is really a restatement of RFC 9700: “Restricting redirect_uri
to explicit, fully qualified URIs prevents the bypass of validation logic.”
What your generated app is actually running
Almost nobody reading this operates an authorization server. What you have is a managed backend with a redirect allowlist, and it is worth reading the code rather than the dashboard, because the behavior is more interesting than the setting suggests.
Supabase validates in IsRedirectURLValid, in internal/utilities/request.go in
supabase/auth:
// Allow redirects back to the site: scheme, host and port must match. The port
// check is skipped for loopback addresses, since per RFC 8252 Section 7.3 native
// apps must be allowed to use variable port numbers.
if base.Hostname() == refurl.Hostname() &&
base.Scheme == refurl.Scheme &&
(base.Port() == refurl.Port() || isLocalhost(refurl.Hostname())) {
return true
}
// ...
// For case when user came from mobile app or other permitted resource - redirect back
for _, pattern := range config.URIAllowListMap {
// only match without the fragment
matchAgainst, _, _ := strings.Cut(redirectURL, "#")
if pattern.Match(matchAgainst) {
return true
}
}
return false
Three facts in that function are worth holding onto.
Your own origin is allowed unconditionally. Any URL sharing the scheme, host
and port of SITE_URL returns true before the allowlist is consulted, which is
correct and also means the allowlist is not what protects you from your own site.
It is exactly the composition RFC 9700 warns about: one open redirect anywhere on
your origin, and there is no pattern left to defeat.
The allowlist is globs, not strings. URIAllowListMap is built in
internal/conf/configuration.go with
gobwas/glob, compiled with two separator
characters:
for _, uri := range config.URIAllowList {
g := glob.MustCompile(uri, '.', '/')
config.URIAllowListMap[uri] = g
}
The documentation states
the semantics plainly: * “matches any sequence of non-separator characters,”
** “matches any sequence of characters,” ? “matches any single non-separator
character,” and “the separator characters in a URL are defined as . and /.”
It also gives the recommendation, which is the same one the RFC gives: “While the
‘globstar’ (**) is useful for local development and preview URLs, we recommend
setting the exact redirect URL path for your site URL in production.”
Failure is silent. The caller is GetReferrer, and it does not error:
func GetReferrer(r *http.Request, config *conf.GlobalConfiguration) string {
// try get redirect url from query or post data first
reqref := getRedirectTo(r)
if IsRedirectURLValid(config, reqref) {
return reqref
}
// instead try referrer header value
reqref = r.Referer()
if IsRedirectURLValid(config, reqref) {
return reqref
}
return config.SiteURL
}
A rejected redirect_to falls back to SITE_URL. That is the safe behavior and
it is also why allowlist mistakes do not surface as errors during development.
The flow completes, the user lands somewhere plausible, and nothing tells you
that the value your code sent was thrown away. The fastest way to make the
redirect stick is to widen the entry until it does, which is precisely how a **
ends up in the production list.
Compare that with the newer code in the same repository, where Supabase acts as
an authorization server itself. In internal/api/oauthserver/authorize.go:
func (s *Server) isValidRedirectURI(client *models.OAuthServerClient, redirectURI string) bool {
registeredURIs := client.GetRedirectURIs()
for _, registeredURI := range registeredURIs {
// exact string matching per OAuth2 spec
if registeredURI == redirectURI {
return true
}
}
return false
}
And on a mismatch it refuses to redirect at all, with the reason in the comment: “Invalid redirect_uri should NOT redirect per OAuth2 spec since we can’t trust it.” Exact comparison, no pattern, 400 rather than a bounce.
Both behaviors are defensible for their jobs. One is an OAuth authorization server bound by the spec; the other is a convenience layer that has to serve mobile deep links and preview deployments. The thing to notice is which one is standing behind the login button in a generated app, and that its documentation is the only place the difference is written down.
The arithmetic of one wildcard
Work out what a single entry actually admits, because the separator rule makes the answer less obvious than it looks.
https://*.yourapp.example/** allows exactly one label in the host position,
because * cannot cross a ., and any path at all, because ** can cross
everything. So https://staging.yourapp.example/anything matches and
https://a.b.yourapp.example/ does not. That sounds tight until you count what
“one label” covers: every subdomain you have ever created, including the ones
pointing at a CDN, a status page, a marketing tool or a hosting provider you
stopped paying. A dangling CNAME on any of them is a redirect URI you have
already authorized, and the attacker’s work is claiming the name rather than
defeating the matcher.
RFC 9700 works through this exact entry in section 4.1.1, and its framing is the
one to keep: “It is important to note that redirection URI validation
vulnerabilities can also exist if the authorization server handles wildcards
properly.” Its example is https://*.somesite.example/*, read as “allow
redirection URIs pointing to any host residing in the domain somesite.example,”
and its conclusion is stated as a plain consequence: “If an attacker manages to
establish a host or subdomain in somesite.example, the attacker can impersonate
the legitimate client.” The RFC then names the delivery mechanism, which is worth
quoting because it is the failure nobody schedules time for: “this could be caused
by a subdomain takeover attack […] where an outdated CNAME record (say,
external-service.somesite.example) points to an external DNS name that no longer
exists (say, customer-abc.service.example) and can be taken over by an attacker
(e.g., by registering as customer-abc with the external service).” So the matcher
can be flawless and the entry still wrong.
https://**.yourapp.example/** drops the label limit entirely. And the entries
the documentation itself suggests for preview deployments,
https://**--my_org.netlify.app/** and
https://*-<team-or-account-slug>.vercel.app/**, are worth reading twice: the
part before the literal suffix is a wildcard on a hostname under a domain you
share with every other customer of that platform. The list is a project setting,
so an entry added on the afternoon someone was wiring up preview builds is in
force for production traffic until somebody removes it.
Now the payload, which is where the numbers stop being about matching. The
auth-js client defaults, in DEFAULT_OPTIONS in GoTrueClient.ts, are:
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
flowType: 'implicit',
Supabase’s own implicit flow documentation says what lands at the redirect URI under that default: “The access and refresh tokens are contained in the URL fragment.” Not a code. Both tokens.
So price the leak. The access token’s default lifetime is one hour, and the session documentation says “most applications should use the default expiration time of 1 hour.” But a refresh token is in the fragment too, and refreshing exchanges it for “a new access and refresh token pair,” so an attacker holding the pair holds a renewable session rather than a one-hour window. The value of a leaked implicit-flow fragment is not measured in the token’s lifetime. It is measured in how long until somebody revokes the session, which for a product with no session list in its UI is indefinite.
Under PKCE the same leak is worth close to nothing, and RFC 9700 section 4.5.3.1 says why: “When the attacker attempts to inject an authorization code, the check of the code_verifier fails: the client uses its correct verifier, but the code is associated with a code_challenge that does not match this verifier.” Section 2.1.1 makes it an obligation rather than advice: “Public clients MUST use PKCE [RFC7636] to this end, as motivated in Section 4.5.3.1.”
There is one more argument against the implicit flow that comes from Supabase’s
own docs and applies to leaks generally, not just to redirect URI bugs: “GET
requests and their full URLs are often logged.” A fragment is not sent to the
server, which is the reason it was chosen, and it does end up in browser history,
in extensions, and in anything a page passes its own location to.
Fixing it, in the order that helps
The order matters, because two of these are configuration changes with no code and they remove the majority of the surface.
One: delete the wildcards from the production allowlist. Every entry becomes a fully qualified URL including the path, matching the callback route your app actually uses. Development entries go in the local config file for the local project, not into the project your users hit. This is what both the RFC and Supabase’s own documentation ask for, and it is the only step that removes the pattern parser rather than trying to outwit it.
Two: switch off the implicit flow. One word, and it changes the leak from a renewable session to a code that needs a verifier this browser generated:
export const supabase = createClient(url, anonKey, {
auth: {
// Overrides DEFAULT_OPTIONS, where flowType is 'implicit' and the tokens
// therefore arrive in the URL fragment rather than as a code to exchange.
flowType: "pkce",
},
});
Three: have one callback route, and never let it forward to a URL. The reason
a next parameter exists is that people want to land where they were, and the
mistake is treating that as a destination URL. It is a path. Resolve it against a
sentinel origin and refuse anything that escapes:
// A host under .invalid can never resolve, per RFC 2606, so nothing an attacker
// registers can ever equal this origin.
const SENTINEL = "https://redirect.invalid";
export function safeNext(next: string | null, fallback = "/dashboard"): string {
if (!next) return fallback;
let parsed: URL;
try {
parsed = new URL(next, SENTINEL);
} catch {
return fallback;
}
if (parsed.origin !== SENTINEL) return fallback;
// Return what was checked, not what was received: the caller navigates to the
// parsed result, so there is no second parse that could disagree with this one.
return parsed.pathname + parsed.search + parsed.hash;
}
The origin check is doing all the work, and it is doing it with the browser’s own
URL parser instead of a hand-written one, which is the entire point. Running it
over the list from
the open redirect post is instructive:
https://evil.com/, //evil.com/, //evil.com, javascript:alert(1),
http:evil.com and https://redirect.invalid.evil.com/ all fall back, because
each one resolves to an origin that is not the sentinel. The backslash variants
are the interesting ones. /\evil.com/ and https:/\evil.com/ come back as the
relative path /evil.com/ rather than as a host, because the WHATWG parser
resolves them that way, and the browser will resolve them the same way for the
same reason. Returning pathname + search + hash is what guarantees that: the
value handed to the router is the value that was inspected.
Four: send Referrer-Policy: strict-origin-when-cross-origin. It does not
fix a bad redirect URI. It removes the adjacent leak, where a URL carrying a code
or a token is handed to every third-party resource the landing page loads.
Five: audit your own origin for redirectors. Since your origin is allowed
unconditionally, any route on it that forwards to a query parameter is a valid
redirect URI that goes anywhere. That includes routes you did not write:
analytics click-trackers, marketing tools mounted on a subpath, an old
/out?url= from a previous version of the site.
What a scan can see, and what it cannot
Be exact here, because the honest answer is narrow.
No passive scan can read your redirect allowlist. It is server-side configuration in a provider’s dashboard or in an environment variable, and nothing about it is observable from outside. There is no request that returns it and no header that reflects it. A scanner that claimed to grade your redirect URI policy would be guessing.
What is observable is the other half of the pair. Our
free surface scan reads the entry page and the same-origin script
bundles that page loads, which is where the client configuration lives: the
project URL, the publishable key, and often the redirectTo value the app sends.
That is also what the tech stack checker and
the Supabase key checker read. So a scan sees the
request and never the policy. It can tell you which authorization server your
app talks to and which address it asks for, and it
cannot tell you how many other addresses that server would accept. Verifying that
requires opening the setting, which takes about a minute and is worth doing right
now if the answer is not already known.
Three findings from the passive layer are genuinely adjacent, and it is worth knowing which fact each one carries:
no-referrer-policyis LOW, and its text names this exact leak: “The full URL of the page a visitor was on is sent to every site they click through to, which leaks anything you keep in a path or a query string, such as reset tokens and internal identifiers.” Checkable with the security headers checker.source-maps-referencedis LOW andsource-map-exposedis HIGH, because a published map means “anyone can read your unminified code, comments and internal logic,” which includes the auth client construction and therefore which flow you selected. A leftoverflowTypedefault is not a secret, but a map turns reading it from work into a download.no-https-redirectis HIGH. A code or token in a URL arriving over plain HTTP before the redirect to HTTPS is readable on the network, and the redirect happens after the request that carried it.
Static analysis reaches no further, for a reason worth stating rather than
implying a gap we could close. The allowlist is not in the repository. An exact
comparison and a glob comparison are both two lines of ordinary code, and a rule
that flagged one would flag correct implementations of the other constantly. A
flowType that is absent is a default rather than a mistake, and no rule can
know whether the reader intended it. This is a class where
the five-layer scan reads the code,
dependencies, secrets, configuration and running surface and still cannot answer
the question, because the question is about a setting held by somebody else. The
platform-specific starting points are in the
Lovable and
Bolt writeups, and the pattern is consistent: the
allowlist gets widened during the first week of building, when the deploy URL
keeps changing, and never narrowed afterwards.
The address is the credential
The reason this bug keeps recurring in mature products is that the redirect URI does not look like a security control. It looks like routing. It is configured next to the site name, it changes when the deploy URL changes, and the person who edits it is usually trying to make a login work rather than reasoning about an attacker.
But nothing else in the flow constrains it. Authentication has already happened by the time the redirect URI is used. The password was correct, the second factor was presented, the session is real, and the authorization server is doing what it was told: delivering the result to the address in the request, because that address passed the check. Whoever controls the address controls the outcome, which is why session fixation and MFA bypass both turn out to have a version of this bug in them, and why a stolen credential’s lifetime is the only thing limiting the damage once it lands.
The fix has no cleverness in it, which is probably why it is unpopular. Write
down the exact URLs your application redirects to, character for character, and
compare with ==. Every mechanism more flexible than that is a program you now
have to defend, against an attacker whose only job is to find one string it reads
differently from the browser.