supabase-rb-rb
Web mode

RequestScopedStorage

Supabase::Rails::Web::RequestScopedStorage — per-request Supabase Auth storage with a signed-cookie fallback for PKCE verifiers that survive the OAuth round-trip.

Supabase::Rails::Web::RequestScopedStorage implements the Supabase::Auth::SupportedStorage duck-type (get_item / set_item / remove_item) against a per-request Hash stored in request.env["supabase.rails.auth_storage"]. It's what the upstream Supabase::Auth::Client calls when it needs to persist transient state (PKCE code verifiers, in-flight session shapes) during a single request — without leaking that state to a concurrent request on the same Puma thread or a different user on the next request.

You almost never instantiate this directly. AuthClientFactory constructs one per request and passes it as storage: to Supabase::Auth::Client.new. The class is documented here because (a) the per-request scoping is the load-bearing safety property for a multi-threaded Puma worker, (b) the PKCE-verifier cookie fallback is the only mechanism that lets the OAuth round-trip work at all (the verifier is written on one request and read on another), and (c) hosts writing custom OAuth flows need the oauth_state accessor to opt into the cookie fallback.

# Pseudocode for what AuthClientFactory does on every :web request.
storage = Supabase::Rails::Web::RequestScopedStorage.new(request)
storage.oauth_state = params[:state] if params[:state] # opt into cookie fallback

::Supabase::Auth::Client.new(
  storage: storage,
  auto_refresh_token: false,
  flow_type: "pkce",
  # ...
)

Per-request scoping

The backing Hash lives in request.env[ENV_KEY] where ENV_KEY = "supabase.rails.auth_storage". It's lazy: backing_hash allocates an empty {} only on first access, so requests that never touch storage cost nothing. Subsequent RequestScopedStorage.new(request) calls for the same request observe the same Hash via the env key — memoized across instantiations within one request.

Because the storage is keyed off request.env, two requests served concurrently by the same worker get two independent Hashes. A PKCE verifier written by request A is invisible to request B; a session shape written by request B never bleeds into request A. There is no module-level state — every read and write goes through the request's env.

              ┌────────────────────────────────────────────┐
Request A ──► │ request.env["supabase.rails.auth_storage"] │ → {} (A's slot)
              └────────────────────────────────────────────┘

              ┌────────────────────────────────────────────┐
Request B ──► │ request.env["supabase.rails.auth_storage"] │ → {} (B's slot, different object)
              └────────────────────────────────────────────┘

This is the multi-threaded-worker safety property: even with Puma's threads 5,5, no thread can observe another thread's storage Hash.

Constructor

RequestScopedStorage.new(
  request,                # ActionDispatch::Request or any object responding to #env
  oauth_state: nil        # String — opt-in to the PKCE-verifier cookie fallback
)

oauth_state is also writeable via the public attr_accessor:

storage = RequestScopedStorage.new(request)
storage.oauth_state = "abc123"

When oauth_state is nil or an empty string, the cookie fallback is disabled — only the in-memory Hash is used. When it's a non-empty string, writes to PKCE-verifier keys (those ending in code-verifier) mirror into a signed cookie sb-oauth-state-<state>; reads consult the cookie if the Hash misses.

The gem-shipped OauthController#create sets oauth_state to the state Supabase Auth returns from sign_in_with_oauth; OauthController#callback sets it to params[:state] to read the verifier back. Hosts writing custom OAuth flows must do the same — without oauth_state, the verifier dies with the request.

Constants

ConstantValueMeaning
ENV_KEY"supabase.rails.auth_storage"Rack env key the backing Hash is memoized under.
COOKIE_NAME_PREFIX"sb-oauth-state-"Signed-cookie name prefix; the full name is "sb-oauth-state-#{oauth_state}".
COOKIE_TTL_SECONDS60010-minute TTL on the PKCE-verifier cookie.
PKCE_KEY_SUFFIX"code-verifier"Suffix the storage matches on to decide whether a key is a PKCE verifier.

COOKIE_TTL_SECONDS = 600 is generous for an OAuth round-trip — typical IdP latency is under 30 seconds end-to-end, including user interaction. Ten minutes covers the slowest realistic flow (the user paused on the IdP's consent page) without keeping verifiers alive long enough to be useful to an attacker.

#get_item(key)

ReturnsBehaviour
The stored value (any type the upstream client wrote)First consults the per-request Hash; if missing AND the key is a PKCE verifier AND oauth_state is set, falls back to the signed cookie.
def get_item(key)
  value = backing_hash[key]
  return value unless value.nil?

  read_pkce_cookie(key)
end

The cookie fallback only fires for missing values. A value of false or 0 in the Hash is returned as-is (only nil triggers the fallback). The cookie read goes through request.cookie_jar.signed[cookie_name] — Rails' signed-jar tampering protection applies, so a forged sb-oauth-state-<state> cookie is rejected at decode time.

#set_item(key, value)

ReturnsSide effects
value (unchanged)Writes to the per-request Hash. If the key is a PKCE verifier AND oauth_state is set, mirrors the value into the signed cookie.
def set_item(key, value)
  backing_hash[key] = value
  write_pkce_cookie(value) if pkce_key?(key) && oauth_state_present?
  value
end

The mirrored cookie is written with these attributes:

AttributeValue
valueThe PKCE verifier (a base64-url-safe random string).
expiresTime.now + 600 (10 minutes).
httponlytrue — JavaScript cannot read the verifier.
same_site:lax — the cookie is sent on the OAuth provider's top-level redirect back to /callback.
path"/".
secureRails.env.production? — required in production, optional locally.

The signed jar adds a tamper-evident HMAC over the value using the host's secret_key_base. The cookie is readable as plaintext base64 in the browser; the HMAC just prevents the user from forging a different verifier. This is fine — the verifier is single-use and bound to the state param.

#remove_item(key)

ReturnsSide effects
The deleted value (or nil if the key was absent)Deletes from the per-request Hash. If the key is a PKCE verifier AND oauth_state is set, also deletes the signed cookie.
def remove_item(key)
  existed = backing_hash.key?(key)
  value = backing_hash.delete(key)
  clear_pkce_cookie if pkce_key?(key) && oauth_state_present?
  existed ? value : nil
end

The existed flag preserves the standard Hash#delete semantics — returning nil for a key that wasn't present, even if the same key happened to map to a stored nil (which would never happen in practice for PKCE verifiers, but matters for the upstream client's contract).

The PKCE cookie is cleared via jar.delete(cookie_name, path: "/") on the base (unsigned) jar — Rails's cookie jar's delete always operates on the base jar, since the signed wrapper is only for reads/writes.

PKCE (Proof Key for Code Exchange) requires the verifier written during sign_in_with_oauth to be readable when the IdP redirects back to your app's /callback. Those are two different HTTP requests — the in-memory Hash from the first request is long gone by the time the second arrives. The signed-cookie fallback bridges that gap.

Request 1: POST /oauth/start
   ├─► Supabase::Auth::Client#sign_in_with_oauth
   │     ├─► storage.set_item("supabase.auth.code-verifier", "VERIFIER_X")
   │     │     ├─► backing_hash["supabase.auth.code-verifier"] = "VERIFIER_X"
   │     │     └─► (oauth_state set) cookies.signed["sb-oauth-state-ABC"] = "VERIFIER_X"
   │     └─► returns redirect URL to IdP
   └─► browser redirected to IdP

Request 2: GET /oauth/callback?state=ABC&code=...
   ├─► OauthController#callback
   │     ├─► storage.oauth_state = "ABC"
   │     └─► Supabase::Auth::Client#exchange_code_for_session
   │           └─► storage.get_item("supabase.auth.code-verifier")
   │                 ├─► backing_hash[...] → nil (new request, empty Hash)
   │                 └─► cookies.signed["sb-oauth-state-ABC"] → "VERIFIER_X" ✓
   └─► session established, cookie cleared via remove_item

The state param binds the verifier to a specific round-trip. An attacker who triggers a parallel OAuth flow for the same user gets a different state, hits a different sb-oauth-state-<state> cookie, and cannot consume the legitimate verifier. Concurrent OAuth attempts by the same user (multiple browser tabs) each get their own state-keyed verifier and don't collide.

Why a Hash key suffix instead of an exact key

pkce_key?(key) matches any string ending with "code-verifier". The upstream Supabase::Auth::Client writes verifiers under keys like "supabase.auth.code-verifier" — the prefix is the upstream library's namespace, which the storage doesn't depend on. Matching by suffix keeps RequestScopedStorage insulated from upstream renames; any new code-verifier-like key the upstream introduces is automatically mirrored.

Non-PKCE keys ("supabase.auth.token", session shapes, etc.) hit only the in-memory Hash and never touch a cookie — they're transient by design and don't need to survive beyond the request.

#backing_hash

ReturnsVisibility
Hash — the per-request storage HashPublic (the implementation needs it externally callable on the duck-type, though hosts almost never read it directly).

Allocates env[ENV_KEY] ||= {} on first access and returns it. Inspecting backing_hash in a debugger is a useful smoke test:

binding.pry # mid-request
storage = Supabase::Rails::Web::RequestScopedStorage.new(request)
storage.backing_hash
# => {"supabase.auth.token" => {...}, "supabase.auth.code-verifier" => "VERIFIER_X"}

There is no config knob — same_site: :lax, httponly: true, the prefix, and the TTL are hardcoded. The reasoning:

  • same_site: :lax is required: an OAuth provider's 302 back to /callback is a top-level navigation, which :lax allows. :strict would silently drop the cookie and PKCE would fail with "verifier missing".
  • httponly: true prevents a same-origin XSS from exfiltrating in-flight verifiers.
  • The sb-oauth-state- prefix avoids collisions with the host app's cookies and groups the gem's cookies for easy inspection.
  • The 10-minute TTL is the maximum reasonable round-trip window. Tighter would break slow IdPs (some enterprise SSO setups round-trip through MFA); looser would keep verifier material alive past its useful life.

If you need different attributes, the supported path is to subclass RequestScopedStorage and override write_pkce_cookie — though no production deployment has needed this so far.

Concurrency

The class is thread-safe by construction: every instance is bound to a single request, and Rails serves one thread per request. Two threads in the same Puma worker cannot share a RequestScopedStorage because they can't share a request. The signed-cookie jar is similarly per-request.

There is no module-level mutex because there is no module-level state. The @request instance variable and the request.env Hash are owned by the request.

What this class does not do

  • It does not encrypt the cookie. The PKCE-verifier cookie is signed (tamper-evident), not encrypted. The verifier is visible to the user in their cookie store — that's fine because it's single-use, state-bound, and short-lived. The encrypted-session cookie (sb-session) is handled by SessionStore, which is a different module.
  • It does not implement a TTL on the in-memory Hash. The Hash lives for the duration of the request and is discarded with the Rack env when the request completes. No expiry needed.
  • It does not survive Puma worker restarts. The cookie fallback survives across requests to the same browser. It does not survive a worker restart in any meaningful way (verifiers are bound to a specific OAuth round-trip, which a worker restart wouldn't normally interrupt), but the cookie itself stays valid until the 10-minute TTL elapses regardless of restarts.
  • It does not handle session persistence. The upstream client's persist_session: true flag tells Supabase::Auth::Client to call set_item on the session, but persistence ends at the request boundary. The encrypted sb-session cookie is written by SessionStore from start_new_session_for, not by this storage.

See also

  • Web mode overview — the request lifecycle, including the OAuth round-trip.
  • AuthClientFactory — constructs the Supabase::Auth::Client and wires RequestScopedStorage into its storage: option.
  • Session store — the long-lived encrypted-cookie wrapper, distinct from this per-request storage.
  • Configuration → oauth_providers — the config key that drives whether OAuth buttons appear in the gem's views.
  • CookieCredentialStrategy — the strategy that wraps everything :web-mode does, including the OAuth round-trip's per-request storage lifecycle.

On this page