Session store
Supabase::Rails::SessionStore — the encrypted-cookie wrapper used in :web mode, the surrounding Supabase::Rails::Middleware that reads it on every request, and the cookie attributes you can configure.
Supabase::Rails::SessionStore is the single source of truth for the encrypted session cookie used in :web mode. It wraps Rails' ActionDispatch::Cookies::EncryptedCookieJar (keyed by the host app's secret_key_base, so no new secret is introduced) so the middleware (read + refresh-rewrite) and the Authentication concern (start_new_session_for / terminate_session) read and write the same cookie consistently.
The Rack middleware that orchestrates the per-request work is documented at the bottom of this page — it is the entry point that calls SessionStore on every :web-mode request, hands off to CookieCredentialStrategy for refresh, and writes the resulting supabase_context to request.env.
# Typical lifecycle inside the Authentication concern:
store = Supabase::Rails::SessionStore.new(Rails.application.config.supabase.session)
session = store.read(request) # decrypt + return the session Hash, or nil
store.write(response, supabase_session) # encrypt + persist on sign-in
store.clear(response) # past-date the cookie on sign-outWhen you touch SessionStore directly
Almost never. The concern's start_new_session_for(session) and terminate_session already construct one from config.supabase.session and call write / clear for you. You only reach for SessionStore directly when:
- Writing a custom controller action that needs to peek at the raw cookie payload (e.g. surfacing the access token to a JS client during a one-off migration).
- Writing tests that need to read or seed the cookie without going through the full sign-in path.
- Implementing a custom Rack middleware that runs alongside the gem's middleware.
In all other cases, prefer the surface in authentication.
Supabase::Rails::SessionStore
class Supabase::Rails::SessionStore
DEFAULT_COOKIE_NAME = "sb-session"
DEFAULT_SAME_SITE = :lax
DEFAULT_PATH = "/"
endThe class exposes four constants, an initialiser that takes the gem's session config, and three instance methods (read, write, clear).
Constants
| Constant | Value | Notes |
|---|---|---|
DEFAULT_COOKIE_NAME | "sb-session" | Used when cookie_name is unset. Prefixed sb- so it does not collide with Rails' default _<app>_session cookie. |
DEFAULT_SAME_SITE | :lax | Used when same_site is unset. Allows top-level POSTs from the OAuth-provider callback while blocking cross-site image-tag / fetch-with-credentials attacks. |
DEFAULT_PATH | "/" | Used when path is unset. The cookie is scoped to the whole app by default. |
The constants are public — host apps that customise the cookie name in a custom middleware can write the same default elsewhere with Supabase::Rails::SessionStore::DEFAULT_COOKIE_NAME.
new(config = nil)
| Parameter | Type | Description |
|---|---|---|
config | Hash, ActiveSupport::OrderedOptions, anything respond_to?(:to_h), or nil | Cookie attributes (cookie_name, same_site, secure, domain, path). String / symbol keys are both accepted; passing nil uses defaults. |
The initialiser normalises config and exposes the resolved attributes as attr_reader:
store = Supabase::Rails::SessionStore.new(
cookie_name: "sb-session",
same_site: :lax,
secure: nil, # nil → derives from Rails.env.production?
domain: nil,
path: "/"
)
store.cookie_name # => "sb-session"
store.same_site # => :lax
store.secure # => false (in dev) / true (in prod)
store.domain # => nil
store.path # => "/"Resolved cookie attributes
| Attribute | Default | Notes |
|---|---|---|
cookie_name | "sb-session" (DEFAULT_COOKIE_NAME) | The Set-Cookie name. Renaming it after users have signed in invalidates existing sessions — they will need to sign in again. |
same_site | :lax (DEFAULT_SAME_SITE) | One of :lax, :strict, :none. :none requires secure: true. |
secure | Rails.env.production? | When nil, derives from Rails.env. Pass true / false explicitly to override. Cookies with secure: true are dropped silently by the browser on plain-HTTP requests. |
domain | nil | When nil, the browser scopes the cookie to the exact host. Set to ".example.com" to share the cookie across app.example.com and admin.example.com. |
path | "/" (DEFAULT_PATH) | Restricting path to a subtree is supported but rare — the gem's auth routes live at the root by default. |
Rails.application.config.supabase.session = {
cookie_name: "myapp-session",
same_site: :strict,
secure: true,
domain: ".example.com",
path: "/"
}httponly is always true and is not configurable — the encrypted cookie carries a verified JWT, and exposing it to JavaScript would defeat the purpose of :web mode's "cookie is the credential" model.
Why `secret_key_base` doubles as the encryption key
The encrypted cookie jar is keyed by Rails.application.secret_key_base (the value Rails already uses for signed/encrypted cookies, message verifiers, Active Storage URL signing, etc.). The gem deliberately does not introduce a separate secret — adding one would mean a second rotation story for hosts to maintain. Rotate secret_key_base and every existing Supabase session cookie is invalidated.
read(request)
| Returns | Notes |
|---|---|
Hash mirroring Supabase::Auth::Types::Session (access_token, refresh_token, expires_at, token_type, user, …) | On success. Keys are always strings. |
nil | Cookie missing, tampered, the decrypted payload is not a Hash, or any other read error. |
Decrypts and returns the cookie payload. The method never raises — a StandardError from the encrypted jar (invalid ciphertext, MAC failure, partial cookie) is rescued and treated as a missing cookie. This is intentional: a request that arrives with a cookie an attacker crafted by hand should produce the same "anonymous request" outcome as a request with no cookie at all, so an unauthenticated branch reading the payload cannot tell the two apart.
store = Supabase::Rails::SessionStore.new(Rails.application.config.supabase.session)
session = store.read(request)
session["access_token"] # => "eyJhbGciOi…"
session["expires_at"] # => 1730000000 (Unix epoch seconds — Numeric)
session["refresh_token"] # => "v1.MdGhJ…"
session["user"] # => { "id" => "f47a…", "email" => "alice@example.com", ... }request is an ActionDispatch::Request (or anything responding to #cookie_jar). In a Rack middleware where you only have the raw env Hash, build one with ActionDispatch::Request.new(env) first.
Payload shape
SessionStore does not enforce the inner schema beyond "must be a Hash" — it returns whatever was last written. In practice the writer is always Supabase::Auth::Types::Session#to_h, so the keys present are:
| Key | Type | Notes |
|---|---|---|
"access_token" | String | The verified JWT used as a Bearer credential for downstream Supabase calls. |
"refresh_token" | String | Sent to auth.refresh_session when the access token nears expiry. |
"expires_at" | Numeric (Unix epoch seconds) | Used by CookieCredentialStrategy to decide whether to refresh. |
"expires_in" | Numeric (seconds) | Upstream-reported TTL at issue time. |
"token_type" | String | Always "bearer". |
"user" | Hash | The upstream Supabase::Auth::Types::User payload at sign-in time. |
The middleware (via CookieCredentialStrategy) requires access_token (String, non-empty) and expires_at (Numeric) — a cookie that is missing either is treated as anonymous and discarded.
write(response, session)
| Parameter | Type | Notes |
|---|---|---|
response | An ActionDispatch::Response or anything responding to #cookie_jar | Rails resolves the cookie jar from the response when emitting Set-Cookie. |
session | Supabase::Auth::Types::Session, anything responding to #to_h, or Hash | Anything else raises ArgumentError. |
Serialises the session via #to_h, stringifies all keys, and writes the result into the encrypted cookie jar under cookie_name. The cookie is emitted with:
{
httponly: true,
same_site: store.same_site,
secure: store.secure,
path: store.path,
domain: store.domain # only included if set
}# After a successful sign-in:
session = response_from_auth.session # Supabase::Auth::Types::Session
store.write(response, session)Hash inputs go through the same path so a controller can hand-craft a session payload (e.g. when stitching together a multi-tenant impersonation flow):
store.write(response, {
"access_token" => new_access_token,
"refresh_token" => new_refresh_token,
"expires_at" => Time.now.to_i + 3600,
"expires_in" => 3600,
"token_type" => "bearer",
"user" => user_payload
})Inputs that are neither a Hash nor respond_to?(:to_h) raise ArgumentError("session must be a Hash or respond to #to_h (got <Class>)"). The intent is "don't silently swallow a coding error" — passing nil to write is almost always a bug.
clear(response)
Past-dates the cookie so the browser drops it on the next response. Deletion happens on the base cookie jar (not the encrypted layer — the encrypted wrapper only governs read/write encoding):
store.clear(response)
# response.cookie_jar.delete("sb-session", path: "/", domain: nil)The same path (and domain, if set) that write used must be passed to clear for the browser to honour the deletion — SessionStore does this for you by reading from its own attr_readers. Hand-rolling a cookies.delete("sb-session") call from a controller without those options leaves a stale cookie in the browser.
Expiry semantics
SessionStore itself sets no expires attribute on the cookie. The cookie is a session cookie at the browser level: it persists across browser tabs but is dropped when the browser quits. The authoritative expiry lives inside the payload as expires_at, and the gem enforces it on every request via CookieCredentialStrategy:
| Condition | Behaviour |
|---|---|
expires_at > now + 10s | Use the access token as-is — verify and build the user context. |
expires_at <= now + 10s and refresh_token is present | Inline-refresh via auth.refresh_session. On success, write the new session via SessionStore#write (replacing the cookie). On 4xx, clear the cookie. On 5xx, return a 503 so the browser retries. |
expires_at <= now + 10s and refresh_token is missing | Clear the cookie via SessionStore#clear. Treat the request as anonymous. |
| Cookie missing, tampered, or any read error | Treat the request as anonymous. |
The 10s refresh leeway (CookieCredentialStrategy::REFRESH_LEEWAY_SECONDS) is what keeps you from being signed out mid-request when a token is on the verge of expiring.
Long-lived sessions without browser-persistence
Because the cookie has no browser-level expires, sessions die when the user closes the browser unless their Supabase project's refresh-token lifetime is long enough to survive the gap. If you need "remember me across browser restarts", configure a longer refresh-token TTL in your Supabase project — the gem will keep refreshing inline as long as refresh_token remains valid.
Supabase::Rails::Middleware
The Rack middleware is the host of the per-request work that turns a Supabase session cookie (or a Bearer header) into the supabase_context your controllers read. It is inserted into the host's Rack stack by Supabase::Rails::Railtie on boot unless config.supabase.insert_middleware is explicitly false.
# config/initializers/supabase.rb does not need to declare this — the Railtie
# does it for you. Shown here for reference.
app.middleware.use Supabase::Rails::Middleware,
mode: cfg.mode, # :api | :web
auth: cfg.auth, # :user | :publishable | :secret | :none | Array
env: cfg.env, # nil or Hash override for Env.resolve
supabase_options: cfg.supabase_options,
cors: cfg.cors,
session: cfg.session, # SessionStore config
user_model: cfg.user_modelThe middleware enforces mode at construction time — VALID_MODES = %i[api web] and anything else raises Supabase::Rails::ConfigError(INVALID_MODE) from app.middleware.use. This is why bad config.supabase.mode values surface at boot rather than per-request.
Request lifecycle
-
OPTIONS preflight — if
corsis notfalseand the request method isOPTIONS, returns[204, CORS::DEFAULT_HEADERS or your overrides, []]immediately. Your controllers never see preflight requests. -
Already-resolved context — if
request.env[Supabase::Rails::CONTEXT_KEY]is already populated (e.g. a custom middleware upstream pre-built a context for testing or impersonation), the request passes through untouched. This makes the middleware re-entrant. -
Build context — dispatches on
mode::api→Supabase::Rails.create_context(request, auth:, env:, supabase_options:, user_model:)— readsAuthorization: Bearerandapikeyheaders, runs them through the auth modes in order, builds aSupabaseContextwith the verified JWT claims.:web→Web::CookieCredentialStrategy.new(env:, supabase_options:, session:, user_model:).call(env)— reads the cookie viaSessionStore, refreshes if needed, overlays the access token ontoHTTP_AUTHORIZATION, then runs the samecreate_contextpipeline.
-
Failure — if either path returns a
Result.failure, the middleware short-circuits with a JSON error response:{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }The HTTP status is the error's
status(401 forINVALID_CREDENTIALS, 503 forREFRESH_UNAVAILABLE, 500 forJWKS not configured). -
Success — assigns the
SupabaseContexttoenv[Supabase::Rails::CONTEXT_KEY]and calls the inner app. CORS headers are merged onto the downstream response ifcors_enabled?.
In :web mode, the failure-as-JSON branch is reached only for REFRESH_UNAVAILABLE (503) — an "Invalid credentials" outcome is instead converted to an anonymous context inside CookieCredentialStrategy, the cookie is cleared, and the request continues without Current.user so the Authentication concern's require_authentication before-action can redirect to new_session_path.
VALID_MODES = %i[api web]
| Symbol | When to use it | What the middleware does |
|---|---|---|
:api | JSON APIs, SPAs that send a JWT per request, server-to-server. | Reads Authorization: Bearer <token> and the apikey header, runs them through config.supabase.auth mode list (default [:user]). |
:web | Server-rendered Rails apps. | Reads the encrypted sb-session cookie via SessionStore, refreshes near-expired access tokens inline, and synthesizes a Bearer overlay for the rest of the pipeline. |
Other values raise ConfigError(INVALID_MODE). The hard rejection (rather than a silent default) is intentional — a typo'd :wb should fail at boot, not run an unknown auth strategy.
What it places on request.env
request.env[Supabase::Rails::CONTEXT_KEY]
# is a Supabase::Rails::SupabaseContext with:
# supabase: Supabase::Client (RLS-honouring, scoped to the verified user)
# supabase_admin: Supabase::Client (service-role, RLS-bypassing — only present
# when a secret key is configured)
# user_claims: Supabase::Rails::UserClaims Struct
# jwt_claims: Hash (the verified JWT payload, or {} on anonymous)
# auth_mode: :user | :publishable | :secret | :none
# auth_key_name: the matched key entry name, or nil
# current_user: Supabase::Rails::User value object, or nil when
# config.supabase.user_model is setThe constant Supabase::Rails::CONTEXT_KEY is the string "supabase.context". Both forms are interchangeable — request.env["supabase.context"] works too. See the supabase_context reference for what controllers do with it.
config.supabase.insert_middleware
| Type | Default |
|---|---|
Boolean | true |
When false, the Railtie skips app.middleware.use Supabase::Rails::Middleware. Use this when you need finer control over where the middleware sits in the stack, then insert it manually:
# config/application.rb
config.supabase.insert_middleware = false
config.middleware.insert_before(
Rails::Rack::Logger,
Supabase::Rails::Middleware,
mode: :web,
session: config.supabase.session
)This is rare. The default insertion point (the end of the Rack stack just before the Rails app) is correct for almost every app — it puts the middleware after Rails' cookie/session middleware so the cookie jar is available, and before your controllers so the context is set by the time before_actions fire.
See also
- Authentication — the concern that calls
SessionStore#writefromstart_new_session_forandSessionStore#clearfromterminate_session. - JWT verification — what
CookieCredentialStrategyruns on the access token after reading the cookie. web-mode/cookie-credential-strategy— the inline-refresh + clear-or-503 dispatch logic that callsSessionStoreon every:webrequest.- Configuration →
session— the Railtie key whose Hash is passed toSessionStore.new. - Configuration →
insert_middleware— toggle to opt out of automatic Rack stack insertion.
JWT verification
Supabase::Rails::JWT.verify — the access-token verifier behind every authenticated request, with JWKS resolution, in-memory caching, and the AuthError shape it raises.
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.