supabase-rb-rb
Web mode

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)

ReturnsRaises
Supabase::Auth::Client — the same instance for the duration of the requestSupabase::Rails::EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY) if no default publishable key is resolvable
ArgumentTypeDefaultNotes
requestActionDispatch::Request (or any duck-type with #env and #cookie_jar)requiredThe request the client should be scoped to. RequestScopedStorage reads request.env for memoization and request.cookie_jar for PKCE-verifier fallback.
env:SupabaseEnv | Hash | nilnilPassed to Supabase::Rails::Env.resolve. When already a SupabaseEnv, it is used as-is.
supabase_options:Hash | nilnilExtra 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)
end

Two consequences worth knowing:

  • The env: / supabase_options: arguments to the second call are ignored. Whoever called build first locked in the client's configuration for the rest of the request. A controller that wants to override env/options has to call build before anything else in the pipeline does — or directly construct its own Supabase::Auth::Client and skip the factory. In practice the middleware always calls build first via CookieCredentialStrategy, so subsequent controller calls inherit those options.
  • The client's RequestScopedStorage is shared across callers within a request. This is the load-bearing property: when OauthController#callback calls client.exchange_code_for_session, the PKCE verifier written by OauthController#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.

OptionValueWhy
auto_refresh_tokenfalsesupabase-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_sessiontrueTells 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.
storageRequestScopedStorage.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"
end

The 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
end

The 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))
end

The 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:

  1. Sharing the per-request storage with the OAuth round-trip. Without the factory, your custom OauthController#callback would have a fresh RequestScopedStorage, the cookie fallback wouldn't fire, and exchange_code_for_session would raise "verifier missing".
  2. 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.
  3. Inheriting the four invariants automatically. A hand-rolled client that forgets auto_refresh_token: false would 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
end

AuthErrorMapper translates the upstream errors to the gem's stable AuthError codes — see that page for the mapping.

Errors

ConditionException
No default publishable key resolvable from resolved_env.publishable_keysSupabase::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:   true

Custom 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) # => true

Custom 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 calls client.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 from start_new_session_for in the Authentication concern after the controller obtains a Types::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 stable code: 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

On this page