supabase-rb-rb
Authentication

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-out

When 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        = "/"
end

The class exposes four constants, an initialiser that takes the gem's session config, and three instance methods (read, write, clear).

Constants

ConstantValueNotes
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:laxUsed 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)

ParameterTypeDescription
configHash, ActiveSupport::OrderedOptions, anything respond_to?(:to_h), or nilCookie 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        # => "/"
AttributeDefaultNotes
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.
secureRails.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.
domainnilWhen 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)

ReturnsNotes
Hash mirroring Supabase::Auth::Types::Session (access_token, refresh_token, expires_at, token_type, user, …)On success. Keys are always strings.
nilCookie 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:

KeyTypeNotes
"access_token"StringThe verified JWT used as a Bearer credential for downstream Supabase calls.
"refresh_token"StringSent 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"StringAlways "bearer".
"user"HashThe 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)

ParameterTypeNotes
responseAn ActionDispatch::Response or anything responding to #cookie_jarRails resolves the cookie jar from the response when emitting Set-Cookie.
sessionSupabase::Auth::Types::Session, anything responding to #to_h, or HashAnything 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:

ConditionBehaviour
expires_at > now + 10sUse the access token as-is — verify and build the user context.
expires_at <= now + 10s and refresh_token is presentInline-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 missingClear the cookie via SessionStore#clear. Treat the request as anonymous.
Cookie missing, tampered, or any read errorTreat 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_model

The 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

  1. OPTIONS preflight — if cors is not false and the request method is OPTIONS, returns [204, CORS::DEFAULT_HEADERS or your overrides, []] immediately. Your controllers never see preflight requests.

  2. 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.

  3. Build context — dispatches on mode:

    • :apiSupabase::Rails.create_context(request, auth:, env:, supabase_options:, user_model:) — reads Authorization: Bearer and apikey headers, runs them through the auth modes in order, builds a SupabaseContext with the verified JWT claims.
    • :webWeb::CookieCredentialStrategy.new(env:, supabase_options:, session:, user_model:).call(env) — reads the cookie via SessionStore, refreshes if needed, overlays the access token onto HTTP_AUTHORIZATION, then runs the same create_context pipeline.
  4. 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 for INVALID_CREDENTIALS, 503 for REFRESH_UNAVAILABLE, 500 for JWKS not configured).

  5. Success — assigns the SupabaseContext to env[Supabase::Rails::CONTEXT_KEY] and calls the inner app. CORS headers are merged onto the downstream response if cors_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]

SymbolWhen to use itWhat the middleware does
:apiJSON 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]).
:webServer-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 set

The 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

TypeDefault
Booleantrue

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

On this page