CookieCredentialStrategy
Supabase::Rails::Web::CookieCredentialStrategy — the per-request strategy that reads the encrypted session cookie, validates expiry, refreshes inline, and routes the outcome into anonymous, user, or 503 contexts.
Supabase::Rails::Web::CookieCredentialStrategy is the credential-extraction strategy the Rack middleware uses when config.supabase.mode = :web. Where :api mode reads the Authorization: Bearer header, the cookie strategy reads the encrypted sb-session cookie via SessionStore, branches on the embedded expires_at, and either verifies the cached access token, performs an inline refresh, or downgrades to an anonymous context — all before the request reaches your controller.
You almost never instantiate this class yourself. The middleware does it once per request and discards it; the strategy is documented here because (a) its branching governs whether your controller sees Current.user, an anonymous request, or a 503 REFRESH_UNAVAILABLE JSON response, (b) the REFRESH_LEEWAY_SECONDS constant is the only knob that decides when refresh fires, and (c) the side effects (cookie writes, cookie clears, anonymous downgrade) are non-obvious from the middleware's source alone.
# Pseudocode for what the middleware runs on every :web request.
strategy = Supabase::Rails::Web::CookieCredentialStrategy.new(
env: config.supabase.env,
supabase_options: config.supabase.supabase_options,
session: config.supabase.session,
user_model: config.supabase.user_model
)
outcome = strategy.call(rack_env) # => Result.success(SupabaseContext) | Result.failure(AuthError)Constructor
CookieCredentialStrategy.new(
env: nil, # Hash | SupabaseEnv | nil — passed to Env.resolve
supabase_options: nil, # Hash | nil — extra Supabase::Client options
session: nil, # Hash | OrderedOptions | nil — session-cookie attributes
session_store: nil, # SessionStore | nil — inject a pre-built store (tests)
user_model: nil # Class | nil — config.supabase.user_model
)All arguments are keyword and optional. The middleware passes them straight through from config.supabase.*; you only pass them explicitly when constructing a strategy from a test or from custom middleware.
session_store is the only one that is not a config key — it lets a test inject a stubbed SessionStore so the strategy can be exercised without a real encrypted-cookie jar. When omitted, the constructor builds one from session: via SessionStore.new(session).
REFRESH_LEEWAY_SECONDS
| Constant | Value | Meaning |
|---|---|---|
REFRESH_LEEWAY_SECONDS | 10 | The strategy refreshes when expires_at <= Time.now.to_i + 10. |
A token is considered "near-expiry" 10 seconds before its exp. This window absorbs the upstream round-trip — a token with 8 seconds left would otherwise expire mid-request, even after a successful refresh, because the new access_token is verified after the network call returns. Ten seconds is generous enough to cover any realistic Supabase Auth response time plus a small safety margin.
The leeway lives on CookieCredentialStrategy (request-time) and is unrelated to the 30-second JWT::LEEWAY_SECONDS (signature-time tolerance for clock skew during verify). See JWT.verify → Time skew for the distinction.
#call(rack_env)
| Returns | Failure modes |
|---|---|
Supabase::Rails::Result.success(SupabaseContext) — anonymous or user context | Supabase::Rails::Result.failure(AuthError.refresh_unavailable) — only when an upstream 5xx/network error makes refresh impossible |
call is the strategy's only public instance method. It accepts the raw Rack env Hash (request.env) and returns a Result the middleware unwraps into either a context (placed on request.env["supabase.context"]) or a JSON 503 response. The strategy never raises — every error path is internalised.
Dispatch table
The branching is small but order-sensitive:
| Cookie state | Outcome | Side effect |
|---|---|---|
SessionStore.read returns nil (no cookie, unparseable cookie, decrypt failure) | Anonymous context | None |
Cookie present, access_token missing or empty | Anonymous context | None |
Cookie present, expires_at missing or non-numeric | Anonymous context | None |
expires_at > now + 10s | User context (JWT-verified) | None |
expires_at <= now + 10s, refresh_token missing/empty | Anonymous context | Cookie cleared |
expires_at <= now + 10s, refresh succeeds | User context (JWT-verified) | New cookie written |
expires_at <= now + 10s, refresh returns AuthApiError 400/401 | Anonymous context | Cookie cleared |
expires_at <= now + 10s, refresh returns upstream 5xx / network failure | Result.failure(AuthError.refresh_unavailable) (503) | Cookie not cleared |
The first three rows are the silent-failure cases — a missing or tampered cookie is not a 401. The strategy chooses to downgrade to anonymous instead of rejecting because the request might still be served (a public landing page, the sign-in form itself). Authorization-required controllers handle the rejection by way of require_authentication redirecting to new_session_path. See Anonymous downgrade vs. JSON 503 for the rationale.
What "user context" means
The fast path (cookie present, not near-expiry) does not duplicate the existing :api-mode pipeline — it shims the cookie's access_token onto rack_env as an HTTP_AUTHORIZATION Bearer header and then calls Supabase::Rails.create_context(...). From there, JWT.verify runs against the JWKS, the supabase_context is built with the per-mode supabase (user-RLS) and supabase_admin (service-role) clients, and Current.user populates downstream.
# Inside CookieCredentialStrategy#user_context (annotated)
shim_env = rack_env.merge("HTTP_AUTHORIZATION" => "Bearer #{access_token}")
Rails.create_context(
RackRequest.new(shim_env),
auth: :user,
env: @env_overrides,
supabase_options: @supabase_options,
user_model: @user_model
)The shim is one-way (the original rack_env is unmodified; only the merged copy is handed to create_context). Any header the host has placed in HTTP_AUTHORIZATION is overwritten by the cookie's token — in :web mode the cookie is the sole credential source.
Refresh path
When expires_at <= now + 10s and a refresh_token is present, the strategy hands off to RefreshCoordinator.synchronize keyed by SHA256(refresh_token). Inside the mutex, the block first re-reads the cookie — if a concurrent request already refreshed and wrote a fresher session within the window, the strategy reuses that result without a second outbound call. Otherwise it builds a per-request Supabase::Auth::Client via AuthClientFactory and calls client.refresh_session(refresh_token).
┌───────────────────────────┐
expires_at <= now + 10s ──► │ RefreshCoordinator │ ──► re-read cookie
│ .synchronize(refresh_tok) │ │
└───────────────────────────┘ ├─ fresh enough?
│ └─► reuse session (no HTTP call)
│
└─ stale?
└─► auth.refresh_session(refresh_token)
│
├─► Hash session ─► write cookie + user_context
├─► :invalid (4xx) ─► clear cookie + anonymous_context
└─► :transient (5xx) ─► Result.failure(REFRESH_UNAVAILABLE)fresh_enough? checks the same three predicates as the entry-point branching (access_token valid, expires_at numeric, not near-expiry). Cooperative refresh matters because the :web-mode cookie is the only credential the browser carries — two concurrent tabs that fire at the same time would otherwise both refresh, and the second-arriving call could land on an already-rotated refresh token.
Outcome classification
perform_refresh rescues by Supabase exception class:
| Exception | Classification | Result |
|---|---|---|
Returns a Supabase::Auth::Types::Session (or Hash) | Hash | Write cookie + user context |
AuthApiError with status in [400, 401] | :invalid | Clear cookie + anonymous context |
AuthSessionMissing | :invalid | Clear cookie + anonymous context |
AuthApiError with any other status | :transient | Result.failure(REFRESH_UNAVAILABLE) |
AuthError (any other subclass) | :transient | Result.failure(REFRESH_UNAVAILABLE) |
Any other StandardError (timeout, DNS, TLS) | :transient | Result.failure(REFRESH_UNAVAILABLE) |
The :invalid vs. :transient split is the load-bearing distinction: :invalid means the credentials are gone (revoked / expired refresh token) and the user must sign in again; :transient means Supabase is unreachable and the user should not be signed out, because retrying once Supabase recovers will succeed against the still-valid refresh token. This is why :transient does not clear the cookie.
Session shape coercion
The upstream Supabase::Auth::Client#refresh_session returns a Types::AuthResponse whose .session is a Types::Session Data class. The strategy calls session_to_hash to coerce that to the String-keyed Hash the cookie payload uses:
| Upstream return | Coerced to |
|---|---|
Hash with symbol or string keys | transform_keys(&:to_s) |
Data / struct with #to_h | to_h.transform_keys(&:to_s) |
nil | nil |
| Anything else | nil (treated as :invalid) |
nil from session_to_hash falls through apply_outcome's else branch, which clears the cookie and downgrades to anonymous — defensive cleanup if the upstream API ever returns an unexpected shape.
Anonymous downgrade vs. JSON 503
:web mode is asymmetric in how it surfaces failures to the browser:
- Bad/missing/expired cookie → anonymous context. The strategy returns
Result.success(SupabaseContext)withauth_mode: :none. The middleware places the context onrequest.env, andCurrent.userisnildownstream. Authorization-required controllers redirect tonew_session_pathviarequire_authentication; public pages render normally. The browser sees no 401 JSON. - Refresh blocked by upstream 5xx → JSON 503. The strategy returns
Result.failure(AuthError.refresh_unavailable). The middleware short-circuits the dispatch withContent-Type: application/jsonand body{ "message": "Supabase Auth is temporarily unavailable. Please try again.", "code": "REFRESH_UNAVAILABLE" }. The cookie is not cleared, so once Supabase recovers the next request will succeed.
The asymmetry is deliberate. Anonymous downgrade is the right answer for "your credentials are bad — sign in again" because it preserves Rails' full redirect-and-flash UX. A JSON 503 is the right answer for "your credentials are fine but we couldn't refresh them" because silently signing the user out would (a) destroy their session for a transient infrastructure failure, and (b) hide the outage from any pager/monitoring that watches for 503s.
Customising the 503 response
The REFRESH_UNAVAILABLE JSON is hardcoded in Supabase::Rails::Middleware, not in the strategy. A host that wants an HTML retry page can rescue AuthError in a Rack middleware upstream of Supabase::Rails::Middleware and translate the failure response. The cookie is intact at this point.
Logging
The strategy logs four events through Supabase::Rails::Logging (which writes to Supabase::Rails.logger, not Rails.logger):
| Level | Tag | When |
|---|---|---|
info | [supabase.rails.web.refresh] refresh starting | Every refresh attempt (before the upstream call) |
warn | [supabase.rails.web.refresh] clearing session cookie (no refresh_token) | Cookie near-expiry but refresh_token missing/empty |
warn | [supabase.rails.web.refresh] clearing session cookie (refresh invalid) | Upstream returned 400/401 (refresh token revoked/expired) |
error | [supabase.rails.web.refresh] upstream refresh unavailable (5xx/network) | Upstream 5xx or network failure |
warn | [supabase.rails.web.refresh] clearing session cookie (refresh unknown outcome) | Defensive — apply_outcome else branch (should not fire in practice) |
The error-level line is the one to watch in production. A spike in [supabase.rails.web.refresh] upstream refresh unavailable signals Supabase Auth degradation; users are seeing 503 retry pages but their cookies are intact, so the moment Supabase recovers traffic resumes.
The fast path (cookie valid, no refresh) logs nothing — :web mode is a hot path and logging every successful request would drown the real signals.
Side-effect summary
The strategy can write or clear cookies as part of call. Reasoning about the cookie state after call returns:
| Branch | Cookie after call |
|---|---|
| Anonymous (no cookie / unparseable / missing fields) | Unchanged |
| User context (cookie valid, no refresh) | Unchanged |
| Refresh success | Replaced with the new session |
Refresh :invalid (400/401) | Cleared (past-dated Set-Cookie) |
Refresh :transient (5xx/network) | Unchanged |
Cookie writes go through SessionStore.write, which itself catches every StandardError and returns nil. A failed cookie write does not abort the request — the user context is still returned with the (just-fetched) access_token, but the cookie carrying the new tokens didn't make it back to the browser, so the next request will go through the refresh path again. This is degraded but not broken.
Disabling the strategy
There is no flag to disable the cookie strategy from within :web mode — the strategy is :web mode's credential source. Switch config.supabase.mode = :api to use header-based auth instead. To bypass the cookie on a per-controller basis (e.g. an API surface inside a :web monolith), use verify_supabase_auth(mode: :api) inside the controller.
Calling from a test
RSpec.describe Supabase::Rails::Web::CookieCredentialStrategy do
let(:session_store) { instance_double(Supabase::Rails::SessionStore) }
let(:strategy) do
described_class.new(
env: Supabase::Rails::Env.resolve,
session_store: session_store
)
end
it "returns an anonymous context when the cookie is missing" do
allow(session_store).to receive(:read).and_return(nil)
result = strategy.call("HTTP_HOST" => "example.com")
expect(result).to be_success
expect(result.value.auth_mode).to eq(:none)
end
it "returns a 503 failure when refresh hits an upstream 5xx" do
allow(session_store).to receive(:read).and_return(
"access_token" => "near-expiry-jwt",
"refresh_token" => "rt",
"expires_at" => Time.now.to_i # immediately expired
)
# ...stub AuthClientFactory.build to raise AuthApiError with status 502...
result = strategy.call("HTTP_HOST" => "example.com")
expect(result).to be_failure
expect(result.error.code).to eq(Supabase::Rails::AuthError::REFRESH_UNAVAILABLE)
end
endInjecting a session_store: is the cleanest test seam — the strategy never touches Rails' encrypted-cookie jar in your specs.
See also
- Web mode overview — the full request/refresh/sign-out flow that this strategy implements one branch of.
RefreshCoordinator— the mutex pool the refresh branch runs inside.AuthClientFactory— builds the per-requestSupabase::Auth::Clientthe refresh path uses.AuthErrorMapper— the central upstream-to-AuthErrormapping (not used by this strategy directly, but consistent with the samecode/statussurface).- Session store — the encrypted-cookie I/O the strategy reads, writes, and clears.
- JWT verification — the verifier the user-context branch shims the cookie's
access_tokeninto. - Authentication →
require_authentication— the controller-side gate that redirects to sign-in when the strategy returns an anonymous context. - Configuration →
session— the cookie attributesSessionStore.new(session)reads.
Web mode
How supabase-rails :web mode swaps the Rack middleware into a cookie-backed authentication pipeline for Rails — sign-in, refresh, sign-out, and how it differs from :api mode.
RefreshCoordinator
Supabase::Rails::Web::RefreshCoordinator — the per-worker mutex pool that serializes concurrent token refreshes keyed by SHA256(refresh_token), with refcounted entry cleanup.