AuthClientFactory
Supabase::Rails::Web::AuthClientFactory — constructs the per-request Supabase::Auth::Client with auto_refresh_token disabled, PKCE flow, and request-scoped storage, cached on request.env.
Supabase::Rails::Web::AuthClientFactory is the single place every :web-mode component goes when it needs an Supabase::Auth::Client — sign-in, sign-up, OAuth start/callback, OTP request/verify, password reset, refresh. The factory builds one client per request, caches it in request.env["supabase.rails.auth_client"], and wires it with the invariants the cookie-session model requires: auto_refresh_token: false (no background timers in workers), flow_type: "pkce" (required for OAuth), storage: set to a per-request RequestScopedStorage.
You almost never call build directly. The gem-shipped controllers and CookieCredentialStrategy call it internally; hosts writing custom OAuth or admin flows can call it from their own controller actions to reuse the same per-request client (and the same PKCE storage, which matters for OAuth). The module is documented here because (a) the four constructor invariants are load-bearing for the cookie-session model and a hand-rolled Supabase::Auth::Client.new will almost certainly violate one of them, (b) the env-key cache is the only mechanism that keeps RequestScopedStorage shared within a request, and (c) the publishable-key resolution path is the one place the keys["default"] requirement surfaces as an EnvError.
# In a custom controller — get the per-request auth client.
client = Supabase::Rails::Web::AuthClientFactory.build(request)
client.sign_up(email: "alice@example.com", password: "...")AuthClientFactory.build(request, env: nil, supabase_options: nil)
| Returns | Raises |
|---|---|
Supabase::Auth::Client — the same instance for the duration of the request | Supabase::Rails::EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY) if no default publishable key is resolvable |
| Argument | Type | Default | Notes |
|---|---|---|---|
request | ActionDispatch::Request (or any duck-type with #env and #cookie_jar) | required | The request the client should be scoped to. RequestScopedStorage reads request.env for memoization and request.cookie_jar for PKCE-verifier fallback. |
env: | SupabaseEnv | Hash | nil | nil | Passed to Supabase::Rails::Env.resolve. When already a SupabaseEnv, it is used as-is. |
supabase_options: | Hash | nil | nil | Extra options merged into the client. Only :global => { :headers => Hash } is honoured today; future keys are silently passed through. |
Caching
The factory caches the constructed client in request.env[ENV_KEY] where ENV_KEY = "supabase.rails.auth_client". The second call within a request returns the cached instance:
def build(request, env: nil, supabase_options: nil)
cached = request.env[ENV_KEY]
return cached unless cached.nil?
request.env[ENV_KEY] = build_client(request, env: env, supabase_options: supabase_options)
endTwo consequences worth knowing:
- The
env:/supabase_options:arguments to the second call are ignored. Whoever calledbuildfirst locked in the client's configuration for the rest of the request. A controller that wants to override env/options has to callbuildbefore anything else in the pipeline does — or directly construct its ownSupabase::Auth::Clientand skip the factory. In practice the middleware always callsbuildfirst viaCookieCredentialStrategy, so subsequent controller calls inherit those options. - The client's
RequestScopedStorageis shared across callers within a request. This is the load-bearing property: whenOauthController#callbackcallsclient.exchange_code_for_session, the PKCE verifier written byOauthController#create(in a previous request) is read from the same storage instance the cookie fallback was bound to.
The cache is per-request: when the request finishes and the Rack env is discarded, the client and its storage are eligible for GC. There is no module-level state.
Invariants
The factory hard-codes four Supabase::Auth::Client.new options. They are documented as code constants only inline — the factory is the contract.
| Option | Value | Why |
|---|---|---|
auto_refresh_token | false | supabase-rb would otherwise spawn a background Timer thread per session that outlives the request, leaking across Puma worker fork cycles. Refresh is performed inline by CookieCredentialStrategy. This is enforced repo-wide by spec/supabase/rails/auto_refresh_token_invariant_spec.rb — see Codebase patterns. |
flow_type | "pkce" | Required for the OAuth round-trip. Without PKCE, sign_in_with_oauth falls back to the implicit flow, which doesn't work with the cookie-session model. |
persist_session | true | Tells the upstream client to call storage.set_item on the session. Because storage is request-scoped, "persist" means "within this request" — the encrypted sb-session cookie is what carries the session across requests, written by SessionStore from start_new_session_for. |
storage | RequestScopedStorage.new(request) | Per-request Supabase::Auth::SupportedStorage implementation. See RequestScopedStorage for the PKCE-verifier cookie fallback. |
auto_refresh_token: false is a hard invariant
Every Supabase::Auth::Client constructed by this gem must have auto_refresh_token: false. Setting config.supabase.auth = { auto_refresh_token: true } does not flip this — the factory's hard-coded value wins. A repo-wide invariant spec (spec/supabase/rails/auto_refresh_token_invariant_spec.rb) greps the gem source to catch any regression.
Auth URL derivation
def auth_url(base_url)
"#{base_url.to_s.chomp('/')}/auth/v1"
endThe factory takes the resolved env.url (your SUPABASE_URL, e.g. https://abcd1234.supabase.co) and appends /auth/v1. The trailing-slash normalisation handles either SUPABASE_URL=https://… or SUPABASE_URL=https://…/.
This is the same URL supabase-rb would derive internally, but pinning it here lets the factory route through proxies (via supabase_options) without re-deriving.
Publishable-key resolution
def resolve_publishable_key(keys)
key = keys["default"]
return key unless key.nil? || key.to_s.empty?
raise EnvError.missing_default_publishable_key
endThe factory pulls resolved_env.publishable_keys["default"] and raises EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY) if it's missing or empty. The "default" name comes from SupabaseEnv — the env reader normalises both the singular SUPABASE_PUBLISHABLE_KEY and the plural JSON-map SUPABASE_PUBLISHABLE_KEYS={"default":"sb_publishable_…", "internal":"…"} into a Hash keyed by name, with "default" populated from the singular when only the singular is set.
Unlike the user-facing :api path (which can resolve a per-auth_key_name publishable key), the auth-client factory always uses "default" — there is no concept of multiple Supabase projects per request in :web mode. The other entries in publishable_keys exist for verify_supabase_auth(key_name: ...) against a multi-key API surface and are ignored here.
Header construction
def build_headers(publishable_key, supabase_options)
base = {
"apikey" => publishable_key,
"Authorization" => "Bearer #{publishable_key}"
}
global = option_value(supabase_options, :global) || {}
extra = option_value(global, :headers) || {}
base.merge(stringify_keys(extra))
endThe factory sends the publishable key as both apikey and Authorization: Bearer — this is the supabase-rb idiom for unauthenticated auth-endpoint calls (sign-in, sign-up, OAuth start, password reset). Once a session is established, the upstream client overrides the Authorization header on subsequent calls with the user's access_token.
The supabase_options[:global][:headers] extension point lets a host inject custom headers (a tracing header, a per-environment routing header) that the factory merges over the base. stringify_keys normalises symbol keys to strings so :user_agent and "User-Agent" both work. Symbol-or-string indirection is consistent with how option_value resolves :global — either supabase_options[:global] or supabase_options["global"] is found.
When to call from custom code
The factory is the right entrypoint for any custom controller action that needs to call Supabase Auth — sign_up, reset_password_for_email, verify_otp, sign_in_with_oauth, and exchange_code_for_session. Three reasons to prefer it over a hand-rolled Supabase::Auth::Client.new:
- Sharing the per-request storage with the OAuth round-trip. Without the factory, your custom
OauthController#callbackwould have a freshRequestScopedStorage, the cookie fallback wouldn't fire, andexchange_code_for_sessionwould raise "verifier missing". - Sharing the same client with the middleware's refresh path. If the middleware already constructed a client (during refresh on this request), your controller reuses it via the env cache instead of allocating a second one — saving an allocation and ensuring options stay consistent.
- Inheriting the four invariants automatically. A hand-rolled client that forgets
auto_refresh_token: falsewould leak Timer threads. The factory takes that decision out of your hands.
# app/controllers/custom_otp_controller.rb
class CustomOtpController < ApplicationController
allow_unauthenticated_access
def create
client = Supabase::Rails::Web::AuthClientFactory.build(request)
client.sign_in_with_otp(email: params[:email])
flash.notice = "Check your email for a sign-in link."
redirect_to root_path
rescue Supabase::Auth::Errors::AuthError => e
error = Supabase::Rails::Web::AuthErrorMapper.translate(e)
flash.alert = "Sign-in failed: #{error.code}"
redirect_to root_path
end
endAuthErrorMapper translates the upstream errors to the gem's stable AuthError codes — see that page for the mapping.
Errors
| Condition | Exception |
|---|---|
No default publishable key resolvable from resolved_env.publishable_keys | Supabase::Rails::EnvError with code: "MISSING_DEFAULT_PUBLISHABLE_KEY", status: 500 |
request.env is nil or frozen (would not be caused by Rails directly) | NoMethodError / RuntimeError from request.env[ENV_KEY] = ... — defensive: don't call with a synthetic request that's missing an env Hash. |
The factory does not catch or translate upstream Supabase::Auth::Errors::* — those propagate from the client methods you call (sign_in_with_password, etc.). Use AuthErrorMapper.translate to convert them to Supabase::Rails::AuthError with stable codes for error-handling.
Examples
Default usage (no overrides)
client = Supabase::Rails::Web::AuthClientFactory.build(request)
# - url: <SUPABASE_URL>/auth/v1
# - headers: { "apikey" => <default publishable key>, "Authorization" => "Bearer <same>" }
# - storage: RequestScopedStorage tied to `request`
# - auto_refresh: false
# - flow_type: "pkce"
# - persist_session: trueCustom headers via supabase_options
client = Supabase::Rails::Web::AuthClientFactory.build(
request,
supabase_options: { global: { headers: { "X-Trace-Id" => request.request_id } } }
)Reusing within one request
# In :web mode, the middleware has already called .build during refresh check.
# This call returns the same client instance — no second allocation.
a = Supabase::Rails::Web::AuthClientFactory.build(request)
b = Supabase::Rails::Web::AuthClientFactory.build(request)
a.equal?(b) # => trueCustom OAuth state propagation
# The factory always constructs RequestScopedStorage WITHOUT setting oauth_state
# (it sets it to nil). To opt into the PKCE cookie fallback in a custom callback,
# fetch the storage and set the state before calling exchange_code_for_session.
client = Supabase::Rails::Web::AuthClientFactory.build(request)
client.storage.oauth_state = params[:state]
client.exchange_code_for_session(auth_code: params[:code])The gem-shipped OauthController#callback does exactly this — see RequestScopedStorage for the cookie-fallback details.
What this module does not do
- It does not configure refresh. Refresh is owned by
CookieCredentialStrategy, which callsclient.refresh_session(refresh_token)inline. The factory's job ends at constructing the client. - It does not handle session writes to the encrypted cookie. That's
SessionStore.write, called fromstart_new_session_forin theAuthenticationconcern after the controller obtains aTypes::Session. - It does not retry on
EnvError. The factory raises immediately on missing-key configuration. The middleware then surfaces the failure as a 500 with stablecode: MISSING_DEFAULT_PUBLISHABLE_KEY— an explicit operator error, not a transient. - It does not pool or recycle clients across requests. The cache is keyed on
request.env, which is destroyed at the end of each request. There is no module-level Hash of clients.
See also
- Web mode overview — the request lifecycle context.
RequestScopedStorage— the storage the factory wires into the client.CookieCredentialStrategy— the first per-request caller ofbuild, and the place refresh happens.AuthErrorMapper— the translator you'll pair with this factory when calling Supabase Auth from custom controllers.- Configuration → Environment variables —
SUPABASE_PUBLISHABLE_KEY/SUPABASE_PUBLISHABLE_KEYSand thedefaultkey resolution. - Configuration →
supabase_options— the source of theglobal[:headers]extension point. - supabase-rb: Initializing — the underlying
Supabase::Auth::Clientconstructor this factory wraps with the four:web-mode invariants.
RequestScopedStorage
Supabase::Rails::Web::RequestScopedStorage — per-request Supabase Auth storage with a signed-cookie fallback for PKCE verifiers that survive the OAuth round-trip.
AuthErrorMapper
Supabase::Rails::Web::AuthErrorMapper — translates Supabase::Auth::Errors::* into the gem-stable Supabase::Rails::AuthError surface with deterministic code and HTTP status.