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
| Constant | Value | Meaning |
|---|---|---|
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_SECONDS | 600 | 10-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)
| Returns | Behaviour |
|---|---|
| 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)
endThe 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)
| Returns | Side 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
endThe mirrored cookie is written with these attributes:
| Attribute | Value |
|---|---|
value | The PKCE verifier (a base64-url-safe random string). |
expires | Time.now + 600 (10 minutes). |
httponly | true — JavaScript cannot read the verifier. |
same_site | :lax — the cookie is sent on the OAuth provider's top-level redirect back to /callback. |
path | "/". |
secure | Rails.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)
| Returns | Side 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
endThe 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-verifier cookie fallback
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_itemThe 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
| Returns | Visibility |
|---|---|
Hash — the per-request storage Hash | Public (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"}Customising the cookie attributes
There is no config knob — same_site: :lax, httponly: true, the prefix, and the TTL are hardcoded. The reasoning:
same_site: :laxis required: an OAuth provider's302back to/callbackis a top-level navigation, which:laxallows.:strictwould silently drop the cookie and PKCE would fail with "verifier missing".httponly: trueprevents 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 bySessionStore, 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: trueflag tellsSupabase::Auth::Clientto callset_itemon the session, but persistence ends at the request boundary. The encryptedsb-sessioncookie is written bySessionStorefromstart_new_session_for, not by this storage.
See also
- Web mode overview — the request lifecycle, including the OAuth round-trip.
AuthClientFactory— constructs theSupabase::Auth::Clientand wiresRequestScopedStorageinto itsstorage: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.
RedirectValidator
Supabase::Rails::Web::RedirectValidator — validates redirect targets (e.g. ?redirect_to=) against config.supabase.allowed_redirect_origins to prevent open-redirect vulnerabilities.
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.