supabase-rb-rb
Web mode

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.

config.supabase.mode = :web swaps the Rack middleware's credential-extraction strategy: instead of reading an Authorization: Bearer header, the gem reads an encrypted Rails cookie (sb-session), inline-refreshes the token when it nears expiry, and exposes the resulting supabase_context on request.env exactly as :api mode does. The cookie is encrypted with the host's existing secret_key_basethere is no new secret to manage.

This page is the conceptual orientation for :web mode: how the cookie session moves through a request, when :web is the right choice over :api, and where to drill into each component. For the configuration keys themselves see Configuration; for the controller-side helpers see Authentication.

# config/initializers/supabase.rb
Rails.application.config.supabase.mode = :web

When to use :web vs. :api

supabase-rails 0.2 ships both modes from the same gem. The choice is about who holds the JWT.

:web mode:api mode
Credential sourceEncrypted sb-session cookieAuthorization: Bearer + apikey headers
Who holds the tokenThe browser, via HttpOnly cookie the server writesThe client (SPA, mobile app, server-to-server) sends it on every request
Sign-in flowPOST /session → server writes cookie → 302 to landingClient calls Supabase Auth directly (e.g. supabase-js), then sends the JWT on every API call
Token refreshInline in middleware — transparent, no client-side timerClient is responsible for refresh (or uses supabase-js's auto-refresh)
Sign-outDELETE /session → cookie cleared + best-effort auth.sign_out upstreamClient discards the token + optionally calls /logout
Controllers inherit< ::ApplicationController (full Rails view/form/CSRF stack)< ActionController::API is typical
CSRFprotect_from_forgery with: :exception on formsSkipped per ActionController::API defaults
Default current_user exposureexpose_current_user = truecurrent_user is a helper_methodexpose_current_user = false — controllers call Current.user explicitly
Reads the secret service-role key at request time?No — sign-in / sign-up / refresh go through publishable-key-fronted /auth/v1Optional, depending on config.supabase.auth
Background timers in workersNone (auto_refresh_token: false enforced)None (same invariant)

Use :web when you are building a server-rendered Rails monolith — ERB views, form_with, redirects, flash — and you want Supabase to back sign-in, OAuth, password reset, and OTP without writing any client-side JavaScript to track tokens. The sign-in form posts to your Rails controller, and the response is a redirect with a Set-Cookie header. Everything past sign-in works because Current.user is populated from the cookie's verified JWT claims.

Use :api when the client owns the token — an SPA, a mobile app, a webhook receiver, an internal service. The client signs in by calling Supabase Auth directly (typically supabase-js) and then sends the resulting access_token as Authorization: Bearer <jwt> on every request to your Rails API. The gem verifies it, builds the supabase_context, and the request reaches your controller with Current.user populated.

Both modes can run in the same app. A :web monolith that also exposes /api/v1/* to a mobile client can override per controller — see Hybrid :web + :api below.

A :web-mode request always travels through Supabase::Rails::Middleware, which dispatches to Supabase::Rails::Web::CookieCredentialStrategy. The strategy reads the encrypted cookie via SessionStore, decides whether the token is still valid, and writes the resulting supabase_context to request.env["supabase.context"] before handing off to your controller.

The four phases — sign-in, normal request, refresh, sign-out — share that same middleware path; the only difference is which branch of CookieCredentialStrategy runs.

1. Sign-in — POST /session

Browser                     SessionsController                  Supabase Auth
   │                                │                                │
   │  POST /session                 │                                │
   │  email=…&password=…&CSRF=…     │                                │
   │ ─────────────────────────────► │                                │
   │                                │  authenticate_with_supabase    │
   │                                │  (calls sign_in_with_password) │
   │                                │ ──────────────────────────────►│
   │                                │                                │
   │                                │           Types::Session       │
   │                                │  ◄──────────────────────────── │
   │                                │                                │
   │                                │  start_new_session_for(sess)   │
   │                                │  → SessionStore.write          │
   │                                │  → Current.user = …            │
   │                                │                                │
   │  302 /                         │                                │
   │  Set-Cookie: sb-session=…      │                                │
   │  HttpOnly; Secure; SameSite=Lax│                                │
   │ ◄───────────────────────────── │                                │

The SessionsController body (shipped in the gem) calls authenticate_with_supabase(email:, password:) which returns a Supabase::Auth::Types::Session (or nil on bad credentials). On success it calls start_new_session_for(session), which serializes the session into the encrypted cookie via SessionStore and populates Current.user / Current.session for the rest of the request. The browser receives a 302 with a Set-Cookie: sb-session=…; HttpOnly; SameSite=Lax header.

OAuth and OTP follow the same shape — the difference is who produces the session. OAuth round-trips to the IdP and back via OauthController#callback, OTP collects a one-time code in OtpController#verify. Both end at start_new_session_for. PKCE state for the OAuth round-trip is held in a short-lived sb-oauth-state-<state> signed cookie via RequestScopedStorage.

Browser                     Middleware                         Controller
   │                            │                                    │
   │  GET /dashboard            │                                    │
   │  Cookie: sb-session=…      │                                    │
   │ ─────────────────────────► │                                    │
   │                            │  SessionStore.read → Hash          │
   │                            │  expires_at > now + 10s            │
   │                            │  JWT.verify(access_token)          │
   │                            │  → supabase_context (auth_mode:    │
   │                            │     :user, supabase: RLS client)   │
   │                            │  → request.env["supabase.context"] │
   │                            │ ─────────────────────────────────► │
   │                            │                                    │
   │                            │                                    │ Current.user populated
   │                            │                                    │ render dashboard.html.erb
   │                            │ ◄───────────────────────────────── │
   │  200 OK                    │                                    │
   │ ◄───────────────────────── │                                    │

The hot path is one cookie decrypt and one JWT verify — no upstream call to Supabase Auth. The verified user claims populate Current.user via the Authentication concern's populate_current_attributes before-action. The browser sees no Set-Cookie because the existing cookie is still good.

When the access token's expires_at is within REFRESH_LEEWAY_SECONDS = 10 of Time.now, the middleware refreshes inline. The request is held for the upstream round-trip and served with the fresh token.

Browser                     Middleware                         Supabase Auth
   │                            │                                    │
   │  GET /dashboard            │                                    │
   │  Cookie: sb-session=…      │                                    │
   │ ─────────────────────────► │                                    │
   │                            │  SessionStore.read → Hash          │
   │                            │  expires_at <= now + 10s           │
   │                            │  refresh_token present             │
   │                            │  RefreshCoordinator.synchronize    │
   │                            │   (mutex keyed by SHA256(refresh)) │
   │                            │  auth.refresh_session(refresh_tok) │
   │                            │ ─────────────────────────────────► │
   │                            │                                    │
   │                            │       Types::AuthResponse(session) │
   │                            │ ◄───────────────────────────────── │
   │                            │  SessionStore.write(new_session)   │
   │                            │  JWT.verify(new_access_token)      │
   │                            │  → supabase_context, dispatch      │
   │  200 OK                    │                                    │
   │  Set-Cookie: sb-session=…  │                                    │
   │ ◄───────────────────────── │                                    │

The RefreshCoordinator wraps the refresh call in an in-process mutex keyed by SHA256(refresh_token) so two threads holding the same cookie cooperate on a single outbound call. Across clustered Puma workers two simultaneous requests can both trigger refresh — this is acceptable because browsers serialize cookie-bearing requests, and Supabase's rotating-refresh-token model treats the second-arriving refresh as a no-op rather than an error.

Three outcomes shape the response:

  • Success → the new session is written to the cookie (Set-Cookie in the response), the new access_token is verified, and the request continues as a normal authenticated request.
  • AuthApiError 400/401 (revoked or expired refresh token) → the cookie is cleared with a past-dated Set-Cookie, the context downgrades to anonymous, and the request continues. require_authentication will redirect to the sign-in page if the route requires auth. No exception bubbles up.
  • 5xx / network error → the middleware returns 503 REFRESH_UNAVAILABLE as JSON { message:, code: } so the page can render a "try again" UI rather than silently signing the user out. The cookie is not cleared in this case — the refresh token may still be valid.

4. Sign-out — DELETE /session

Browser                     SessionsController                  Supabase Auth
   │                                │                                │
   │  DELETE /session               │                                │
   │  Cookie: sb-session=…&CSRF=…   │                                │
   │ ─────────────────────────────► │                                │
   │                                │  terminate_session(scope:      │
   │                                │   :local)                      │
   │                                │  auth.sign_out(scope: :local)  │
   │                                │ ──────────────────────────────►│
   │                                │                                │
   │                                │           204 (best-effort)    │
   │                                │  ◄──────────────────────────── │
   │                                │                                │
   │                                │  SessionStore.clear            │
   │                                │  Current.user = nil            │
   │                                │  Current.session = nil         │
   │                                │                                │
   │  302 /                         │                                │
   │  Set-Cookie: sb-session=;      │                                │
   │   expires=Thu, 01 Jan 1970     │                                │
   │ ◄───────────────────────────── │                                │

terminate_session(scope:) calls auth.sign_out(scope:) best-effort (any AuthError is swallowed), then clears the encrypted cookie via SessionStore.clear. The cookie clear is the source of truth — even if the upstream call fails, the browser is signed out locally on this request. scope accepts :local (this session only — the default), :global (every device for this user), or :others (every other device, leave this one signed in).

What lives where

The :web-mode pipeline is intentionally split into small, single-purpose pieces so each is testable and replaceable. The pieces in the table below are loaded automatically when mode = :web; you do not wire them up explicitly.

ComponentRole
Supabase::Rails::MiddlewareRack middleware. Dispatches by mode, places supabase_context on request.env, merges CORS headers.
SessionStoreEncrypted-cookie I/O. read / write / clear.
CookieCredentialStrategyThe phase-2/3 dispatch logic above — cookie read, expiry check, inline refresh, clear-or-503 outcome routing.
RefreshCoordinatorSHA256(refresh_token)-keyed mutex pool that serializes concurrent refresh attempts within a worker.
AuthClientFactoryBuilds one Supabase::Auth::Client per request — auto_refresh_token: false, flow_type: "pkce", request-scoped storage. Cached in request.env["supabase.rails.auth_client"].
RequestScopedStorageImplements Supabase::Auth::SupportedStorage against a per-request Hash so PKCE verifiers and session state never leak across users in a multi-threaded worker.
AuthErrorMapperTranslates Supabase::Auth::Errors::* to Supabase::Rails::AuthError with a stable code and HTTP status. Centralises the 401/422/503/etc. mapping.
RedirectValidatorValidates OAuth and password-reset redirect_to targets against config.supabase.allowed_redirect_origins. Raises AuthError(INVALID_REDIRECT) on mismatch.
JWT.verifySame JWKS-backed verifier as :api mode. The cookie's access_token is passed through verbatim.
Authentication concernThe controller-side API: authenticated?, require_authentication, current_user, start_new_session_for, terminate_session.

The encrypted cookie's plaintext is a JSON object that mirrors Supabase::Auth::Types::Session:

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "token_type": "bearer",
  "expires_at": 1717000000,
  "provider_token": null,
  "provider_refresh_token": null
}

Cookie attributes:

AttributeDefaultNotes
HttpOnlyalwaysHardcoded — JavaScript cannot read the cookie.
SecureRails.env.production? when secure: nilSet config.supabase.session.secure to force.
SameSiteLaxSet config.supabase.session.same_site to :strict or :none (requires Secure).
Path/Set config.supabase.session.path.
Domainhost-onlySet config.supabase.session.domain for subdomain sharing.
expiresnot setThe cookie is a session cookie at the browser level. The authoritative TTL lives inside the encrypted payload as expires_at, enforced by CookieCredentialStrategy.

See Configuration → session for the full key reference.

Hybrid :web + :api

A :web monolith can still expose a JWT-authenticated /api/v1/* surface to mobile / SPA clients without booting a separate process. Override the mode per controller:

# config/initializers/supabase.rb
Rails.application.config.supabase.mode = :web

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
  include Supabase::Rails::Controller
  before_action -> { verify_supabase_auth(mode: :api) }
end

mode: :api discards the cookie context the middleware installed and re-runs credential extraction against the request's Authorization: Bearer header. Requests without a Bearer raise AuthError(INVALID_CREDENTIALS) (401) — the cookie is ignored entirely on API endpoints, so an attacker who somehow exfiltrated the cookie still cannot reach the JSON API. mode: :web is a no-op that returns the existing cookie-derived context; useful for symmetry. Any other value raises ConfigError(INVALID_MODE).

Errors and observability

All upstream Supabase::Auth::Errors::* are routed through Web::AuthErrorMapper so they carry a stable code and HTTP status:

supabase-rb errorcodestatus
AuthInvalidCredentialsErrorINVALID_CREDENTIALS401
AuthSessionMissingSESSION_MISSING401
AuthApiError 4xxAUTH_API_ERROR(preserved 4xx)
AuthApiError 5xxAUTH_UPSTREAM_ERROR503
AuthWeakPasswordWEAK_PASSWORD422
AuthPKCEErrorPKCE_ERROR400
AuthInvalidJwtErrorINVALID_CREDENTIALS401
AuthRetryableErrorAUTH_RETRYABLE503
AuthUnknownErrorAUTH_GENERIC_ERROR500

In :web mode, most of these never reach the browser — CookieCredentialStrategy downgrades 4xx outcomes to anonymous contexts internally so the controller's require_authentication can redirect to the sign-in page instead. The only failure path the middleware surfaces as JSON is REFRESH_UNAVAILABLE (503), produced when an upstream 5xx makes refresh impossible.

Logging tags every interesting event with a stable prefix:

  • [supabase.rails.web.refresh] refresh startinginfo, at the start of every refresh attempt.
  • [supabase.rails.web.refresh] clearing session cookie (refresh invalid)warn, on 400/401 from the upstream refresh.
  • [supabase.rails.web.refresh] upstream refresh unavailable (5xx/network)error, on 5xx / network refusal.
  • [supabase.rails.sign_in_failure] code=<MAPPED_CODE> email=<first-char>***@<domain>warn, on sign-in form submission failure (email is redacted; raw tokens are never logged).

The sink is Supabase::Rails.logger (separate from Rails.logger) so volume can be controlled or redirected without touching the main app logger — see Configuration → Logging.

See also

  • CookieCredentialStrategy — phase-by-phase dispatch logic for cookie read, expiry check, refresh, and clear-or-503 outcome routing.
  • RefreshCoordinator — the per-worker mutex pool that serializes concurrent refresh calls keyed by SHA256(refresh_token).
  • AuthClientFactory — per-request Supabase::Auth::Client construction with auto_refresh_token: false and PKCE flow.
  • RequestScopedStorageSupportedStorage adapter that keeps PKCE verifiers and auth state scoped to a single Rack request.
  • AuthErrorMapper — the central mapping from Supabase::Auth::Errors::* to AuthError with stable code and status.
  • RedirectValidator — validates OAuth and password-reset redirect_to targets against config.supabase.allowed_redirect_origins.
  • Authentication — the controller-side API (authenticated?, current_user, start_new_session_for, terminate_session).
  • Session store — the encrypted-cookie wrapper and the Rack middleware that reads it on every :web-mode request.
  • JWT verification — the JWKS-backed verifier the strategy delegates to.
  • Configuration → mode — the config flag that flips between :api and :web.
  • Configuration → session — the cookie-attribute keys.
  • Configuration → allowed_redirect_origins — the OAuth and password-reset redirect allowlist.
  • Getting started — the end-to-end install path that lands you on :web mode by default.

On this page