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_base — there 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 = :webWhen 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 source | Encrypted sb-session cookie | Authorization: Bearer + apikey headers |
| Who holds the token | The browser, via HttpOnly cookie the server writes | The client (SPA, mobile app, server-to-server) sends it on every request |
| Sign-in flow | POST /session → server writes cookie → 302 to landing | Client calls Supabase Auth directly (e.g. supabase-js), then sends the JWT on every API call |
| Token refresh | Inline in middleware — transparent, no client-side timer | Client is responsible for refresh (or uses supabase-js's auto-refresh) |
| Sign-out | DELETE /session → cookie cleared + best-effort auth.sign_out upstream | Client discards the token + optionally calls /logout |
| Controllers inherit | < ::ApplicationController (full Rails view/form/CSRF stack) | < ActionController::API is typical |
| CSRF | protect_from_forgery with: :exception on forms | Skipped per ActionController::API defaults |
Default current_user exposure | expose_current_user = true — current_user is a helper_method | expose_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/v1 | Optional, depending on config.supabase.auth |
| Background timers in workers | None (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.
The cookie-session flow
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.
2. Subsequent request — cookie is valid
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.
3. Refresh — cookie is within 10 s of expiry
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-Cookiein the response), the newaccess_tokenis verified, and the request continues as a normal authenticated request. AuthApiError400/401 (revoked or expired refresh token) → the cookie is cleared with a past-datedSet-Cookie, the context downgrades to anonymous, and the request continues.require_authenticationwill redirect to the sign-in page if the route requires auth. No exception bubbles up.- 5xx / network error → the middleware returns
503 REFRESH_UNAVAILABLEas 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.
| Component | Role |
|---|---|
Supabase::Rails::Middleware | Rack middleware. Dispatches by mode, places supabase_context on request.env, merges CORS headers. |
SessionStore | Encrypted-cookie I/O. read / write / clear. |
CookieCredentialStrategy | The phase-2/3 dispatch logic above — cookie read, expiry check, inline refresh, clear-or-503 outcome routing. |
RefreshCoordinator | SHA256(refresh_token)-keyed mutex pool that serializes concurrent refresh attempts within a worker. |
AuthClientFactory | Builds one Supabase::Auth::Client per request — auto_refresh_token: false, flow_type: "pkce", request-scoped storage. Cached in request.env["supabase.rails.auth_client"]. |
RequestScopedStorage | Implements Supabase::Auth::SupportedStorage against a per-request Hash so PKCE verifiers and session state never leak across users in a multi-threaded worker. |
AuthErrorMapper | Translates Supabase::Auth::Errors::* to Supabase::Rails::AuthError with a stable code and HTTP status. Centralises the 401/422/503/etc. mapping. |
RedirectValidator | Validates OAuth and password-reset redirect_to targets against config.supabase.allowed_redirect_origins. Raises AuthError(INVALID_REDIRECT) on mismatch. |
JWT.verify | Same JWKS-backed verifier as :api mode. The cookie's access_token is passed through verbatim. |
Authentication concern | The controller-side API: authenticated?, require_authentication, current_user, start_new_session_for, terminate_session. |
Cookie payload
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:
| Attribute | Default | Notes |
|---|---|---|
HttpOnly | always | Hardcoded — JavaScript cannot read the cookie. |
Secure | Rails.env.production? when secure: nil | Set config.supabase.session.secure to force. |
SameSite | Lax | Set config.supabase.session.same_site to :strict or :none (requires Secure). |
Path | / | Set config.supabase.session.path. |
Domain | host-only | Set config.supabase.session.domain for subdomain sharing. |
expires | not set | The 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) }
endmode: :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 error | code | status |
|---|---|---|
AuthInvalidCredentialsError | INVALID_CREDENTIALS | 401 |
AuthSessionMissing | SESSION_MISSING | 401 |
AuthApiError 4xx | AUTH_API_ERROR | (preserved 4xx) |
AuthApiError 5xx | AUTH_UPSTREAM_ERROR | 503 |
AuthWeakPassword | WEAK_PASSWORD | 422 |
AuthPKCEError | PKCE_ERROR | 400 |
AuthInvalidJwtError | INVALID_CREDENTIALS | 401 |
AuthRetryableError | AUTH_RETRYABLE | 503 |
AuthUnknownError | AUTH_GENERIC_ERROR | 500 |
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 starting—info, 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 bySHA256(refresh_token).AuthClientFactory— per-requestSupabase::Auth::Clientconstruction withauto_refresh_token: falseand PKCE flow.RequestScopedStorage—SupportedStorageadapter that keeps PKCE verifiers and auth state scoped to a single Rack request.AuthErrorMapper— the central mapping fromSupabase::Auth::Errors::*toAuthErrorwith stablecodeandstatus.RedirectValidator— validates OAuth and password-resetredirect_totargets againstconfig.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:apiand: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
:webmode by default.
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.
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.