RedirectValidator
Supabase::Rails::Web::RedirectValidator — validates redirect targets (e.g. ?redirect_to=) against config.supabase.allowed_redirect_origins to prevent open-redirect vulnerabilities.
Supabase::Rails::Web::RedirectValidator is the open-redirect guard for every place the gem honours a caller-supplied redirect target — OAuth start (?redirect_to=…), OAuth callback (?next=…), password-reset email links, and any controller that wants to round-trip a return URL after sign-in. A target is accepted only when it is either a same-origin path (/dashboard, /foo?a=1) or an absolute URL whose origin (scheme://host:port) matches an entry in config.supabase.allowed_redirect_origins.
You may call this module directly from custom controllers that pass through a ?redirect_to= param; the gem-shipped controllers (OauthController, PasswordsController) already call it before issuing the redirect_to. The module is documented here because (a) misconfiguring allowed_redirect_origins is the single most common way to brick OAuth in production, (b) the validator's exception is what surfaces to the user as a 400, and (c) the path-vs-origin rules are subtle enough (protocol-relative URLs, javascript: URIs, port handling) that a hand-rolled check is almost always wrong.
# config/initializers/supabase.rb
Rails.application.config.supabase.allowed_redirect_origins = [
"https://myapp.com",
"https://staging.myapp.com"
]
# app/controllers/sessions_controller.rb (custom override)
def create
# ...sign in...
target = Supabase::Rails::Web::RedirectValidator.validate(
params[:redirect_to],
allowed_origins: Rails.application.config.supabase.allowed_redirect_origins
)
redirect_to target
rescue Supabase::Rails::AuthError => e
flash.alert = e.message
redirect_to root_path
endRedirectValidator.validate(uri, allowed_origins:)
| Returns | Raises |
|---|---|
The input uri.to_s, unchanged, when valid | Supabase::Rails::AuthError with code: "INVALID_REDIRECT", status: 400 |
uri can be a String, a URI::Generic, or nil. The validator coerces to a string via to_s and parses with URI.parse. A nil, empty, or unparseable input raises immediately — the caller is responsible for catching the AuthError and translating it into whatever response is appropriate (a flash + redirect, a 400 JSON body, etc.).
allowed_origins is an Array<String> — typically the value of config.supabase.allowed_redirect_origins. Each entry is parsed as an absolute URL (URI.parse("https://myapp.com")) and compared by origin (scheme + host + port). Entries that don't parse, or parse without both a scheme and a host, are silently skipped — they can never match anything.
Acceptance rules
A target is accepted in exactly two cases:
| Shape | Example | Accepted because |
|---|---|---|
| Path-only (no scheme, no host, non-empty path) | /dashboard, /foo?a=1#bar | Same-origin by definition — the browser will resolve it against the current host. |
| Absolute URL with scheme + host whose origin matches an entry | https://myapp.com/dashboard (with ["https://myapp.com"] in the allowlist) | The origin (scheme://host:port) matches an allowed origin exactly. |
Everything else is rejected.
Rejection rules
| Shape | Example | Rejected because |
|---|---|---|
nil | nil | Defensive — callers passing nil haven't read a real param. |
| Empty string | "" | Same as nil. |
| Unparseable URL | "http://[invalid" | URI.parse raises URI::InvalidURIError, which is caught and treated as invalid. |
| Protocol-relative URL | "//evil.com/x" | URI.parse sets host = "evil.com", so it's not path-only. Origin check fails (assuming evil.com is not allowlisted) and the target is rejected. |
| Scheme-only URI | "javascript:alert(1)", "data:text/html,..." | scheme is non-nil, host is nil — not path-only. Origin check fails (no host to compare). |
| Absolute URL with an off-allowlist origin | "https://evil.com/take-over" | Origin does not match any entry. |
| Absolute URL with a different scheme on an allowlisted host | "http://myapp.com" (allowlist has https://myapp.com) | Scheme is part of the origin — http:// and https:// are distinct origins. |
| Absolute URL with a non-default port on an allowlisted host | "https://myapp.com:8443" (allowlist has https://myapp.com) | Port mismatches — myapp.com:443 ≠ myapp.com:8443. |
The protocol-relative case is the load-bearing one for attack mitigation. A naive params[:redirect_to].start_with?("/") check accepts //evil.com/x because it starts with / — the validator's path_only? predicate is scheme.nil? && host.nil? && !path.empty?, which correctly rejects protocol-relative targets.
The javascript: case is similarly load-bearing for XSS prevention. javascript:alert(1) parses as scheme: "javascript", host: nil, path: "alert(1)". It's not path-only (scheme is set) and fails origin matching (host is nil), so it's rejected before reaching redirect_to.
Origin matching
The validator normalises both sides before comparing:
| Component | Normalisation |
|---|---|
scheme | Lowercased ("HTTPS" → "https") |
host | Lowercased ("MyApp.com" → "myapp.com") |
port | uri.port if present, else the scheme's default port (URI.scheme_list[scheme.upcase].default_port) |
The origin string is then "#{scheme}://#{host}:#{port}". Two origins match iff their normalised string is identical.
# Equivalent — both yield "https://myapp.com:443"
origin_of(URI.parse("https://myapp.com")) # port defaults to 443
origin_of(URI.parse("https://myapp.com:443")) # explicit port
# Distinct — "https://myapp.com:8443" ≠ "https://myapp.com:443"
origin_of(URI.parse("https://myapp.com:8443"))A few practical consequences:
"https://myapp.com"and"https://myapp.com/"(trailing slash) match — only the origin is compared, not the path."https://myapp.com:443"and"https://myapp.com"match — the validator fills in scheme-default ports on both sides."https://myapp.com:8443"and"https://myapp.com"do not match — the explicit non-default port is part of the origin."HTTPS://MYAPP.com"(any casing) matches"https://myapp.com"— scheme and host are case-insensitive per RFC 3986.- Path, query, and fragment in the allowlist entries are ignored.
"https://myapp.com/auth"and"https://myapp.com"allow the same set of targets.
What the allowlist should contain
The allowlist is the origin of your Rails app, not the origin of Supabase. The values you put in config.supabase.allowed_redirect_origins are the hosts you return to after OAuth or password reset — i.e. the hosts that serve your Rails controllers. Supabase Auth's URL never appears here.
# config/initializers/supabase.rb
Rails.application.config.supabase.allowed_redirect_origins = [
"https://myapp.com", # production
"https://staging.myapp.com", # staging
"http://localhost:3000" # local dev (HTTPS not required for loopback)
]In production, every entry should be HTTPS — an HTTP origin in the allowlist invites a man-in-the-middle to swap the redirect target. The validator does not enforce this (it would break local dev), but lint and review should.
AuthError(INVALID_REDIRECT)
The single exception the module raises is Supabase::Rails::AuthError:
| Field | Value |
|---|---|
message | %(redirect target #{uri.inspect} is not in config.supabase.allowed_redirect_origins) |
code | "INVALID_REDIRECT" |
status | 400 |
The code and status are stable — error-handling code in upstream middleware can match on e.code == Supabase::Rails::AuthError::INVALID_REDIRECT without parsing the message. The message itself includes the rejected uri.inspect so logs can pinpoint the offending value (no credentials are in a redirect URL, so logging the input is safe).
rescue Supabase::Rails::AuthError => e
case e.code
when Supabase::Rails::AuthError::INVALID_REDIRECT
Rails.logger.warn("Open-redirect attempt blocked: #{e.message}")
flash.alert = "That redirect target isn't allowed."
redirect_to root_path
else
raise
end
endWhere the gem calls this
The gem-shipped controllers call RedirectValidator.validate in three places, all of which fall back to [request.host] when config.supabase.allowed_redirect_origins is empty so a vanilla install still functions:
| Caller | What's validated |
|---|---|
OauthController#create | The ?redirect_to= query param the host app passes to sign_in_with_oauth. |
OauthController#callback | The ?next= query param Supabase echoes back from the OAuth provider. |
PasswordsController#create (forgot-password) | The redirect_to (the URL the password-reset email link should land on). |
The fallback to [request.host] is a guardrail, not a recommendation — production apps should configure allowed_redirect_origins explicitly so the allowlist doesn't grow implicitly by virtue of whatever hostname the request happened to arrive on.
Examples
Path-only target
Supabase::Rails::Web::RedirectValidator.validate(
"/dashboard",
allowed_origins: ["https://myapp.com"]
)
# => "/dashboard"Allowed absolute URL
Supabase::Rails::Web::RedirectValidator.validate(
"https://myapp.com/welcome",
allowed_origins: ["https://myapp.com"]
)
# => "https://myapp.com/welcome"Blocked off-allowlist URL
Supabase::Rails::Web::RedirectValidator.validate(
"https://evil.com/x",
allowed_origins: ["https://myapp.com"]
)
# => raises Supabase::Rails::AuthError(
# code: "INVALID_REDIRECT",
# status: 400,
# message: 'redirect target "https://evil.com/x" is not in `config.supabase.allowed_redirect_origins`'
# )Blocked protocol-relative URL
Supabase::Rails::Web::RedirectValidator.validate(
"//evil.com/x",
allowed_origins: ["https://myapp.com"]
)
# => raises AuthError(INVALID_REDIRECT) — protocol-relative URLs have a host and are NOT path-onlyBlocked javascript: URI
Supabase::Rails::Web::RedirectValidator.validate(
"javascript:alert(1)",
allowed_origins: ["https://myapp.com"]
)
# => raises AuthError(INVALID_REDIRECT) — scheme is set but host is nil, fails both branchesCross-port mismatch
Supabase::Rails::Web::RedirectValidator.validate(
"https://myapp.com:8443/x",
allowed_origins: ["https://myapp.com"] # default port (443)
)
# => raises AuthError(INVALID_REDIRECT)What this module does not do
- It does not normalise paths.
"/dashboard/../admin"is accepted as-is — the browser will resolve..itself, but if the next hop is your Rails app, the request hits/admin. Rails' router enforces path validity; the validator's job is the host, not the path. - It does not strip credentials from URLs.
"https://user:pass@myapp.com/x"matches ifhttps://myapp.comis allowlisted (the userinfo is not part of the origin). Browsers strip these before navigating; if you want to forbid them anyway, post-process the return value. - It does not validate scheme equality for path-only targets. A path-only target is always accepted regardless of the current request's scheme —
https://myapp.com/fooredirecting to/barlands onhttps://myapp.com/bar, which is the right answer. - It does not consult
allowed_redirect_originsfor path-only targets. A path-only target is always same-origin and bypasses the origin check entirely. If your allowlist is empty, path-only targets still work. - It is not a CSRF guard. Open-redirect and CSRF are separate threats. CSRF is handled by Rails'
protect_from_forgery; the validator only addresses "should I redirect to this URL?".
See also
- Web mode overview — the section landing page.
- Configuration →
allowed_redirect_origins— the config key the gem reads. AuthErrorMapper— the centralAuthErrortranslator (note thatINVALID_REDIRECTis raised by this module directly, not via the mapper, because it's a gem-side check, not an upstream error).- Authentication → Errors — the controller-side error-handling story.
RefreshCoordinator
Supabase::Rails::Web::RefreshCoordinator — the per-worker mutex pool that serializes concurrent token refreshes keyed by SHA256(refresh_token), with refcounted entry cleanup.
RequestScopedStorage
Supabase::Rails::Web::RequestScopedStorage — per-request Supabase Auth storage with a signed-cookie fallback for PKCE verifiers that survive the OAuth round-trip.