# API Reference (/reference/billing/api-reference) This page is generated by `pnpm run gen:billing-api` from the supabase-billing source. Edit the gem (then re-run the script) — do not hand-edit this file. See `docs/scripts/gen-billing-api.mjs` for the generator. The script reads: * `supabase-billing/lib/` — every `.rb` file (excluding model templates, which are rendered separately below) * `lib/generators/supabase_billing/install/templates/app/models/billing` — model declarations (associations, table mappings) Sections below mirror the source-file layout. Module / class headings use their fully-qualified Ruby names; method headings prefix instance methods with `#` and class methods with `.` (matching YARD convention). # Library [#library] ## `lib/generators/supabase_billing/install/install_generator.rb` [#libgeneratorssupabase_billinginstallinstall_generatorrb] ### `class SupabaseBilling::Generators::InstallGenerator < ::Rails::Generators::Base` [#class-supabasebillinggeneratorsinstallgenerator--railsgeneratorsbase] `rails g supabase_billing:install` (US-001). Emits the canonical billing schema migration: a single timestamped migration under `db/migrate/` that creates the eight canonical tables (`billing_customers`, `plans`, `entitlements`, `plan_entitlements`, `subscriptions`, `subscription_items`, `usage_limits`, `usage_events`) plus the four provider-mapping tables (`provider_customers`, `provider_subscriptions`, `provider_products`, `provider_events`), along with the Supabase RLS policies that scope reads to the authenticated user via `auth.uid()`. Preflights that `supabase-rails` is installed (Gemfile + initializer) before doing anything — the gem builds on `supabase-rails` and refuses to run without it. **Constants** * `SUPPORTED_PROVIDERS` = `%w[stripe adapty].freeze` * `SUPPORTED_STRIPE_INGESTION` = `%w[webhook sync_engine].freeze` * `POST_INSTALL_SEPARATOR` = `("=" * 72).freeze` * `BILLING_MODEL_FILES` = `(multi-line literal)` **Methods** #### `.next_migration_number` [#next_migration_number] ```ruby def self.next_migration_number(_dirname) ``` Rails' built-in timestamp for migration filenames. Mirrors `ActiveRecord::Generators::Migration#next_migration_number` so the generator works without ActiveRecord loaded. #### `#preflight_supabase_rails` [#preflight_supabase_rails] ```ruby def preflight_supabase_rails ``` #### `#preflight_providers` [#preflight_providers] ```ruby def preflight_providers ``` #### `#create_migration_file` [#create_migration_file] ```ruby def create_migration_file ``` #### `#create_models` [#create_models] ```ruby def create_models ``` #### `#create_initializer` [#create_initializer] ```ruby def create_initializer ``` #### `#create_yaml_config` [#create_yaml_config] ```ruby def create_yaml_config ``` #### `#include_acts_billable_in_user_model` [#include_acts_billable_in_user_model] ```ruby def include_acts_billable_in_user_model ``` Wires the entitlement engine into the host app's User model (US-005). The model is generated by `rails g supabase:user_model` and lives at app/models/user.rb; the `include Acts::Billable` line is the one-shot bridge that exposes `user.entitled?`, `user.subscribed?`, etc. Idempotent: if the line is already present we skip the injection, so reruns don't double-include the concern. #### `#mount_engine_in_host_routes` [#mount_engine_in_host_routes] ```ruby def mount_engine_in_host_routes ``` Mounts the `Supabase::Billing::Engine` at `/supabase_billing` so the host app exposes the webhook endpoints. Idempotent: if a mount line is already present, we skip the injection. In `:sync_engine` mode the engine is still mounted, but its `config/routes.rb` draws zero routes (see AC: "in :sync\_engine mode, the /supabase\_billing/webhooks/stripe route is not mounted"). The mount itself is cheap; what controls visibility is the engine's internal routeset. #### `#print_post_install_checklist` [#print_post_install_checklist] ```ruby def print_post_install_checklist ``` #### `#providers` [#providers] ```ruby def providers ``` Memoized, parsed and validated provider list. Raises (or prompts interactively if no flag) when zero providers would be selected. #### `#parse_providers` [#parse_providers] ```ruby def parse_providers(raw) ``` #### `#prompt_for_providers` [#prompt_for_providers] ```ruby def prompt_for_providers ``` #### `#validate_stripe_ingestion!` [#validate_stripe_ingestion] ```ruby def validate_stripe_ingestion! ``` #### `#include_rls?` [#include_rls] ```ruby def include_rls? ``` \--- Template hooks (referenced from create\_supabase\_billing\_schema.rb.tt) --- #### `#stripe_ingestion` [#stripe_ingestion] ```ruby def stripe_ingestion ``` #### `#provider_list` [#provider_list] ```ruby def provider_list ``` #### `#supabase_rails_in_gemfile?` [#supabase_rails_in_gemfile] ```ruby def supabase_rails_in_gemfile? ``` \--- Preflight helpers -------------------------------------------------- #### `#supabase_rails_initializer_present?` [#supabase_rails_initializer_present] ```ruby def supabase_rails_initializer_present? ``` ## `lib/supabase/billing.rb` [#libsupabasebillingrb] ### `module Supabase::Billing` [#module-supabasebilling] **Methods** #### `#configure` [#configure] ```ruby def configure ``` #### `#config` [#config] ```ruby def config ``` #### `#reset_config!` [#reset_config] ```ruby def reset_config! ``` #### `#debug` [#debug] ```ruby def debug(user) ``` #### `#sync_status` [#sync_status] ```ruby def sync_status(user) ``` ### `module SupabaseBilling` [#module-supabasebilling-1] Top-level convenience module so `SupabaseBilling.configure` reads as well as `Supabase::Billing.configure`. Declared as a distinct module (not a constant alias) so the generator's existing `module SupabaseBilling` namespace keeps inferring `supabase_billing:install`. Declared *before* the rails-dependent requires below so that the engine's `isolate_namespace ::SupabaseBilling` (loaded via the railtie) finds the constant already in place. **Methods** #### `.configure` [#configure-1] ```ruby def self.configure(&block) ``` #### `.config` [#config-1] ```ruby def self.config ``` #### `.debug` [#debug-1] ```ruby def self.debug(user) ``` #### `.sync_status` [#sync_status-1] ```ruby def self.sync_status(user) ``` ## `lib/supabase/billing/acts/billable.rb` [#libsupabasebillingactsbillablerb] `Acts::Billable` is the entitlement engine's public API: include it on the host app's User model (the one generated by `rails g supabase:user_model`) and the four AC methods — `subscribed?`, `plan`, `entitled?(:key)`, `limit(:key)` — read from the canonical `Billing::*` tables, with safe defaults when the user has no subscription. Results cache per-request via `Supabase::Billing::Current` so a single controller action that calls `entitled?(:foo)` multiple times only hits the DB once. Disable via `Supabase::Billing.config.cache_entitlements_per_request = false` for apps that need always-fresh reads. Defined as a top-level `Acts::Billable` module so the AC-literal `include Acts::Billable` form works in a host User model. The `Supabase::Billing::Acts::Billable` constant below is a one-way alias so internal code can resolve via the gem's namespace. ### `module Acts` [#module-acts] `Acts::Billable` is the entitlement engine's public API: include it on the host app's User model (the one generated by `rails g supabase:user_model`) and the four AC methods — `subscribed?`, `plan`, `entitled?(:key)`, `limit(:key)` — read from the canonical `Billing::*` tables, with safe defaults when the user has no subscription. Results cache per-request via `Supabase::Billing::Current` so a single controller action that calls `entitled?(:foo)` multiple times only hits the DB once. Disable via `Supabase::Billing.config.cache_entitlements_per_request = false` for apps that need always-fresh reads. Defined as a top-level `Acts::Billable` module so the AC-literal `include Acts::Billable` form works in a host User model. The `Supabase::Billing::Acts::Billable` constant below is a one-way alias so internal code can resolve via the gem's namespace. ### `module Acts::Billable` [#module-actsbillable] **Associations** * `has_one :billing_customer` **Constants** * `ACTIVE_SUBSCRIPTION_STATUSES` = `%w[active trialing].freeze` **Methods** #### `#subscribed?` [#subscribed] ```ruby def subscribed? ``` #### `#plan` [#plan] ```ruby def plan ``` #### `#entitled?` [#entitled] ```ruby def entitled?(entitlement_key) ``` #### `#limit` [#limit] ```ruby def limit(entitlement_key) ``` #### `#usage` [#usage] ```ruby def usage(entitlement_key) ``` Returns the total usage recorded against `entitlement_key` in the current billing period (the active subscription's `current_period_start` / `current_period_end`). Returns 0 when the user has no billing\_customer, no active subscription, or the entitlement doesn't exist. #### `#remaining` [#remaining] ```ruby def remaining(entitlement_key) ``` Returns `limit(:key) - usage(:key)`, or `nil` when the entitlement has no numeric cap (i.e. unlimited, no subscription, or boolean entitlement). #### `#record_usage` [#record_usage] ```ruby def record_usage(entitlement_key, amount: 1, recorded_at: nil) ``` Records a usage event against the user's billing\_customer and invalidates the per-request cache for the entitlement so a subsequent `limit(:key)` / `entitled?(:key)` / `usage(:key)` / `remaining(:key)` re-reads from the DB. #### `#acts_billable_invalidate_cache` [#acts_billable_invalidate_cache] ```ruby def acts_billable_invalidate_cache(entitlement_key = nil) ``` Drops the cached entitlement values for this user. Pass an entitlement key to drop only that key's entries (both `entitled?` and `limit`); pass nil to drop everything for this user. #### `#acts_billable_active_subscription` [#acts_billable_active_subscription] ```ruby def acts_billable_active_subscription ``` #### `#acts_billable_usage_for` [#acts_billable_usage_for] ```ruby def acts_billable_usage_for(entitlement_key) ``` Sums `usage_events.amount` for the user's current billing period. Period boundaries come from the active subscription's `current_period_start` / `current_period_end`; without a subscription or without a configured period\_start, we fall back to "no lower bound" so usage is still observable for self-hosted flows that haven't wired the period anchor yet. #### `#acts_billable_plan_entitlement_for` [#acts_billable_plan_entitlement_for] ```ruby def acts_billable_plan_entitlement_for(entitlement_key) ``` #### `#acts_billable_cached` [#acts_billable_cached] ```ruby def acts_billable_cached(method, entitlement_key = nil) ``` #### `#acts_billable_const` [#acts_billable_const] ```ruby def acts_billable_const(name) ``` ## `lib/supabase/billing/adapters/adapty/event_processor.rb` [#libsupabasebillingadaptersadaptyevent_processorrb] ### `class Supabase::Billing::Adapters::Adapty::EventProcessor` [#class-supabasebillingadaptersadaptyeventprocessor] Processes a parsed Adapty webhook event payload (Hash) by: 1. Storing the raw event in `provider_events` for replay/debugging. 2. Upserting the matching `provider_subscriptions` row keyed off the Adapty `profile_id` (subscription events) or `transaction_id` (non-subscription purchases). 3. Reflecting state into the canonical `subscriptions` row. Handles the seven AC-mandated events: * subscription\_started * subscription\_renewed * subscription\_cancelled * subscription\_expired * subscription\_refunded * non\_subscription\_purchase * access\_level\_updated Adapty's `customer_user_id` is the Supabase `auth.users.id` UUID string (mobile clients call `Adapty.identify(session.user.id.uuidString)` after sign-in). We normalize incoming UUIDs to lowercase before the `billing_customers.user_id` lookup — Adapty's server-to-server calls send uppercase per their convention, but Postgres uuid comparisons are case-insensitive in storage; we lowercase explicitly so a string comparison against `users.id` (uuid) coerces cleanly. Unknown event types / unknown users / unmapped products are *logged and skipped* rather than raised so a single bad row never poisons the webhook endpoint. **Constants** * `HANDLED_EVENT_TYPES` = `(multi-line literal)` * `DEFAULT_MODEL_NAMES` = `(multi-line literal)` * `PROVIDER` = `"adapty"` * `EVENT_STATUS_MAP` = `(multi-line literal)` **Attributes** * `attr_reader :logger` * `attr_reader :config` **Methods** #### `#initialize` [#initialize] ```ruby def initialize(models: nil, logger: nil, config: Supabase::Billing.config, now: nil) ``` #### `#call` [#call] ```ruby def call(event) ``` Process a single Adapty event Hash (already parsed from JSON). Returns true if the event was handled (or intentionally skipped), false only when the event payload is malformed. #### `#now` [#now] ```ruby def now ``` #### `#models` [#models] ```ruby def models ``` #### `#resolve_models` [#resolve_models] ```ruby def resolve_models ``` #### `#extract_event_id` [#extract_event_id] ```ruby def extract_event_id(event) ``` \---- event id + storage --------------------------------------------- Adapty supplies an `event_id` field on every server-to-server webhook; fall back to a deterministic synthetic id derived from (profile\_id, event\_type, event\_datetime) when absent so the `provider_events.provider_event_id` unique index still gives us idempotency. #### `#store_provider_event` [#store_provider_event] ```ruby def store_provider_event(event_id, event_type, event) ``` #### `#mark_event_processed` [#mark_event_processed] ```ruby def mark_event_processed(event_id) ``` #### `#event_payload_hash` [#event_payload_hash] ```ruby def event_payload_hash(event) ``` #### `#handle_event` [#handle_event] ```ruby def handle_event(event_type, event) ``` \---- dispatch ------------------------------------------------------- #### `#handle_subscription_event` [#handle_subscription_event] ```ruby def handle_subscription_event(event_type, billing_customer, plan, props) ``` \---- subscription event handling ------------------------------------ #### `#upsert_provider_subscription` [#upsert_provider_subscription] ```ruby def upsert_provider_subscription(provider_subscription_id, event_type, props) ``` #### `#upsert_canonical_subscription` [#upsert_canonical_subscription] ```ruby def upsert_canonical_subscription(event_type:, billing_customer:, plan:, props:) ``` #### `#find_or_initialize_subscription_for` [#find_or_initialize_subscription_for] ```ruby def find_or_initialize_subscription_for(billing_customer, props) ``` #### `#canonical_status` [#canonical_status] ```ruby def canonical_status(event_type, props) ``` #### `#handle_non_subscription_purchase` [#handle_non_subscription_purchase] ```ruby def handle_non_subscription_purchase(billing_customer, plan, props) ``` \---- non-subscription purchase -------------------------------------- #### `#lookup_billing_customer` [#lookup_billing_customer] ```ruby def lookup_billing_customer(customer_user_id, props) ``` \---- lookups + linking ---------------------------------------------- #### `#lookup_plan_for_product` [#lookup_plan_for_product] ```ruby def lookup_plan_for_product(vendor_product_id, access_level_id) ``` #### `#link_provider_customer` [#link_provider_customer] ```ruby def link_provider_customer(billing_customer, props) ``` #### `#normalize_user_id` [#normalize_user_id] ```ruby def normalize_user_id(raw) ``` \---- helpers -------------------------------------------------------- Webhook payloads are normalized to lowercase before the DB lookup (Adapty's server-to-server API calls send uppercase per their convention; mobile SDKs typically send mixed/uppercase). Returns nil for blank/invalid input. #### `#event_properties` [#event_properties] ```ruby def event_properties(event) ``` #### `#parse_time` [#parse_time] ```ruby def parse_time(value) ``` #### `#truthy?` [#truthy] ```ruby def truthy?(value) ``` #### `#log` [#log] ```ruby def log(level, message) ``` ## `lib/supabase/billing/adapters/adapty/signature_verifier.rb` [#libsupabasebillingadaptersadaptysignature_verifierrb] ### `class Supabase::Billing::Adapters::Adapty::SignatureVerifier` [#class-supabasebillingadaptersadaptysignatureverifier] Verifies the `Authorization` header on an incoming Adapty webhook against the configured Adapty webhook secret using a constant-time comparison. Adapty's server-to-server webhook scheme is a shared secret passed verbatim in `Authorization` (no HMAC over the body), so verification here is a fixed-length secret equality check. Implemented in-gem (not via the `adapty` gem) so the gem stays dependency-free at runtime. **Methods** #### `.verify!` [#verify] ```ruby def self.verify!(authorization_header:, secret:) ``` #### `#initialize` [#initialize-1] ```ruby def initialize(authorization_header:, secret:) ``` #### `#verify!` [#verify-1] ```ruby def verify! ``` #### `#secure_compare` [#secure_compare] ```ruby def secure_compare(a, b) ``` ## `lib/supabase/billing/adapters/stripe/event_processor.rb` [#libsupabasebillingadaptersstripeevent_processorrb] ### `class Supabase::Billing::Adapters::Stripe::EventProcessor` [#class-supabasebillingadaptersstripeeventprocessor] Processes a parsed Stripe event payload (Hash) by: 1. Storing the raw event in `provider_events` for replay/debugging. 2. Upserting the matching `provider_subscriptions` row. 3. Reflecting state into the canonical `subscriptions` row. Currently handles the four AC-mandated events: * customer.subscription.created * customer.subscription.updated * customer.subscription.deleted * invoice.payment\_succeeded Unknown event types / unknown Stripe customers / unknown Stripe prices are *logged and skipped* rather than raised so a single bad row never poisons the webhook endpoint. Models are resolved lazily via constant lookup at call-time so the gem stays decoupled from the host app's autoloader; specs inject explicit `models:` to run against an isolated AR setup. **Constants** * `HANDLED_EVENT_TYPES` = `(multi-line literal)` * `DEFAULT_MODEL_NAMES` = `(multi-line literal)` * `PROVIDER` = `"stripe"` * `STATUS_MAP` = `(multi-line literal)` **Attributes** * `attr_reader :logger` * `attr_reader :config` **Methods** #### `#initialize` [#initialize-2] ```ruby def initialize(models: nil, logger: nil, config: Supabase::Billing.config, now: nil) ``` #### `#call` [#call-1] ```ruby def call(event) ``` Process a single Stripe event Hash (already parsed from JSON). Returns true if the event was handled (or intentionally skipped), false only when the event payload is malformed. #### `#now` [#now-1] ```ruby def now ``` #### `#models` [#models-1] ```ruby def models ``` #### `#resolve_models` [#resolve_models-1] ```ruby def resolve_models ``` #### `#store_provider_event` [#store_provider_event-1] ```ruby def store_provider_event(event_id, event_type, event) ``` \---- event storage --------------------------------------------------- #### `#mark_event_processed` [#mark_event_processed-1] ```ruby def mark_event_processed(event_id) ``` #### `#event_payload_hash` [#event_payload_hash-1] ```ruby def event_payload_hash(event) ``` #### `#handle_subscription_upsert` [#handle_subscription_upsert] ```ruby def handle_subscription_upsert(event) ``` \---- customer.subscription.created / .updated ----------------------- #### `#upsert_provider_subscription` [#upsert_provider_subscription-1] ```ruby def upsert_provider_subscription(stripe_sub_id, sub_object) ``` #### `#upsert_canonical_subscription` [#upsert_canonical_subscription-1] ```ruby def upsert_canonical_subscription(billing_customer:, plan:, sub_object:) ``` #### `#find_or_initialize_subscription_for` [#find_or_initialize_subscription_for-1] ```ruby def find_or_initialize_subscription_for(billing_customer, sub_object) ``` The provider\_subscriptions row links to the canonical row by `subscription_id`. On the *first* event for a Stripe sub, the provider row exists but has no `subscription_id`, so we look up by the most recent existing canonical sub for this customer, or build a fresh one. #### `#handle_subscription_deleted` [#handle_subscription_deleted] ```ruby def handle_subscription_deleted(event) ``` \---- customer.subscription.deleted ---------------------------------- #### `#handle_invoice_payment_succeeded` [#handle_invoice_payment_succeeded] ```ruby def handle_invoice_payment_succeeded(event) ``` \---- invoice.payment\_succeeded -------------------------------------- #### `#lookup_billing_customer` [#lookup_billing_customer-1] ```ruby def lookup_billing_customer(stripe_customer_id) ``` \---- lookups + helpers ---------------------------------------------- #### `#lookup_plan_for_price` [#lookup_plan_for_price] ```ruby def lookup_plan_for_price(price_id) ``` #### `#first_price_id` [#first_price_id] ```ruby def first_price_id(sub_object) ``` #### `#dig` [#dig] ```ruby def dig(hash, *keys) ``` #### `#epoch_to_time` [#epoch_to_time] ```ruby def epoch_to_time(value) ``` #### `#log` [#log-1] ```ruby def log(level, message) ``` ## `lib/supabase/billing/adapters/stripe/signature_verifier.rb` [#libsupabasebillingadaptersstripesignature_verifierrb] ### `class Supabase::Billing::Adapters::Stripe::SignatureVerifier` [#class-supabasebillingadaptersstripesignatureverifier] Verifies the `Stripe-Signature` header on an incoming webhook against the configured webhook secret using Stripe's documented scheme: HMAC-SHA256 of `"#{timestamp}.#{payload}"` keyed by the secret, compared in constant time, with a tolerance window on the timestamp to reject obviously-replayed events. Implemented in-gem (not via the `stripe` gem) so the gem stays dependency-free at runtime. **Constants** * `DEFAULT_TOLERANCE` = `300 # 5 minutes, matching Stripe's recommendation` **Methods** #### `.verify!` [#verify-2] ```ruby def self.verify!(payload:, signature_header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now) ``` #### `#initialize` [#initialize-3] ```ruby def initialize(payload:, signature_header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now) ``` #### `#verify!` [#verify-3] ```ruby def verify! ``` #### `#parse_header` [#parse_header] ```ruby def parse_header(header) ``` #### `#compute_signature` [#compute_signature] ```ruby def compute_signature(timestamp, payload, secret) ``` #### `#secure_compare` [#secure_compare-1] ```ruby def secure_compare(a, b) ``` ## `lib/supabase/billing/adapters/stripe/sync_engine_preflight.rb` [#libsupabasebillingadaptersstripesync_engine_preflightrb] ### `class Supabase::Billing::Adapters::Stripe::SyncEnginePreflight` [#class-supabasebillingadaptersstripesyncenginepreflight] Verifies that the `stripe.*` schema produced by `stripe/stripe-sync-engine` exists with the columns this adapter depends on. Run once at boot when `stripe_ingestion = :sync_engine`. If drift is detected, raises `SchemaDriftError` naming the supported sync-engine version range so the developer can pin their `stripe-sync-engine` deployment to a compatible release rather than ship wrong entitlements silently. **Constants** * `SUPPORTED_SYNC_ENGINE_VERSIONS` = `">= 0.62.0, < 1.0.0"` * `SCHEMA_NAME` = `"stripe"` * `EXPECTED_SCHEMA` = `(multi-line literal)` **Attributes** * `attr_reader :connection` **Methods** #### `.run!` [#run] ```ruby def self.run!(connection) ``` #### `#initialize` [#initialize-4] ```ruby def initialize(connection) ``` #### `#run!` [#run-1] ```ruby def run! ``` #### `#ensure_schema_present!` [#ensure_schema_present] ```ruby def ensure_schema_present! ``` #### `#existing_tables` [#existing_tables] ```ruby def existing_tables ``` #### `#columns_for` [#columns_for] ```ruby def columns_for(table) ``` #### `#raise_drift!` [#raise_drift] ```ruby def raise_drift!(detail) ``` ## `lib/supabase/billing/adapters/stripe/sync_engine_reflector.rb` [#libsupabasebillingadaptersstripesync_engine_reflectorrb] ### `class Supabase::Billing::Adapters::Stripe::SyncEngineReflector` [#class-supabasebillingadaptersstripesyncenginereflector] In `:sync_engine` mode, no webhook endpoint is mounted. `stripe/stripe-sync-engine` populates the `stripe.*` schema asynchronously from Stripe's API; this reflector reads from those tables and projects state into the canonical `subscriptions` / `provider_subscriptions` rows so the entitlement API (`user.entitled?`) returns the same answer in both ingestion modes. Unknown / unmapped customers and prices are *logged and skipped*, never raised — matching the webhook adapter's behavior so swapping modes can't break existing apps. **Constants** * `DEFAULT_MODEL_NAMES` = `(multi-line literal)` * `PROVIDER` = `"stripe"` **Attributes** * `attr_reader :logger` **Methods** #### `#initialize` [#initialize-5] ```ruby def initialize(connection:, models: nil, logger: nil) ``` #### `#call` [#call-2] ```ruby def call ``` #### `#models` [#models-2] ```ruby def models ``` #### `#resolve_models` [#resolve_models-2] ```ruby def resolve_models ``` #### `#reflect_subscription` [#reflect_subscription] ```ruby def reflect_subscription(row) ``` #### `#first_price_id_for` [#first_price_id_for] ```ruby def first_price_id_for(stripe_sub_id) ``` #### `#upsert_provider_subscription` [#upsert_provider_subscription-2] ```ruby def upsert_provider_subscription(stripe_sub_id, row) ``` #### `#upsert_canonical_subscription` [#upsert_canonical_subscription-2] ```ruby def upsert_canonical_subscription(billing_customer:, plan:, row:, status:) ``` #### `#lookup_billing_customer` [#lookup_billing_customer-2] ```ruby def lookup_billing_customer(stripe_customer_id) ``` #### `#lookup_plan_for_price` [#lookup_plan_for_price-1] ```ruby def lookup_plan_for_price(price_id) ``` #### `#to_time` [#to_time] ```ruby def to_time(value) ``` #### `#log` [#log-2] ```ruby def log(msg) ``` ## `lib/supabase/billing/configuration.rb` [#libsupabasebillingconfigurationrb] ### `class Supabase::Billing::Configuration` [#class-supabasebillingconfiguration] **Constants** * `STRIPE_INGESTION_MODES` = `%i[webhook sync_engine].freeze` **Attributes** * `attr_accessor :adapty_webhook_secret` * `attr_accessor :stripe_webhook_secret` * `attr_reader :stripe_ingestion` * `attr_reader :plans` **Methods** #### `#initialize` [#initialize-6] ```ruby def initialize ``` #### `#stripe_ingestion=` [#stripe_ingestion-1] ```ruby def stripe_ingestion=(mode) ``` #### `#plan` [#plan-1] ```ruby def plan(key, name: nil, **metadata, &block) ``` #### `#plan_keys` [#plan_keys] ```ruby def plan_keys ``` #### `#fetch_plan` [#fetch_plan] ```ruby def fetch_plan(key) ``` ## `lib/supabase/billing/current.rb` [#libsupabasebillingcurrentrb] ### `class Supabase::Billing::Current < ActiveSupport::CurrentAttributes` [#class-supabasebillingcurrent--activesupportcurrentattributes] Request-scoped storage for the entitlement cache. Rails resets `ActiveSupport::CurrentAttributes` subclasses between requests via the executor, so the cache is automatically cleared at the request boundary — no manual reset hook required. The cache is keyed on `[user_id, method, entitlement_key]` (or `[user_id, method]` for `subscribed?` / `plan`). `Acts::Billable` owns the keying scheme; this class is just the storage. **Methods** #### `#entitlement_cache` [#entitlement_cache] ```ruby def entitlement_cache ``` ## `lib/supabase/billing/debug.rb` [#libsupabasebillingdebugrb] ### `module Supabase::Billing::Debug` [#module-supabasebillingdebug] Console-friendly inspectors for a single user's canonical billing state. Built for the "I got a support ticket, what's actually in the DB for this user?" workflow — every value is JSON-serializable (Strings / Hashes / Arrays / Numerics / Booleans / nil / ISO-8601 timestamps) so the output can be pasted into a ticket or piped through `JSON.generate` without further marshalling. Safe defaults everywhere: missing billing\_customer, missing subscription, missing plan, missing entitlement link all degrade to `nil` / `[]` / `{}` rather than raising. A nil `user` is also tolerated so a console session can probe the helpers without having a User instance handy. **Constants** * `PROVIDERS` = `%w[stripe adapty].freeze` * `ACTIVE_SUBSCRIPTION_STATUSES` = `%w[active trialing].freeze` **Methods** #### `#debug` [#debug-2] ```ruby def debug(user) ``` #### `#sync_status` [#sync_status-2] ```ruby def sync_status(user) ``` #### `#billing_customer_for` [#billing_customer_for] ```ruby def billing_customer_for(user) ``` #### `#active_subscriptions_for` [#active_subscriptions_for] ```ruby def active_subscriptions_for(customer) ``` #### `#entitlements_for` [#entitlements_for] ```ruby def entitlements_for(plan) ``` #### `#usage_for` [#usage_for] ```ruby def usage_for(customer, subscription, entitlements) ``` #### `#sync_status_for_provider` [#sync_status_for_provider] ```ruby def sync_status_for_provider(customer, provider) ``` #### `#last_provider_customer_timestamp` [#last_provider_customer_timestamp] ```ruby def last_provider_customer_timestamp(customer, provider) ``` #### `#last_provider_subscription_timestamp` [#last_provider_subscription_timestamp] ```ruby def last_provider_subscription_timestamp(customer, provider) ``` #### `#serialize_customer` [#serialize_customer] ```ruby def serialize_customer(customer, user) ``` #### `#serialize_subscription` [#serialize_subscription] ```ruby def serialize_subscription(subscription) ``` #### `#serialize_plan` [#serialize_plan] ```ruby def serialize_plan(plan) ``` #### `#entitlement_value` [#entitlement_value] ```ruby def entitlement_value(link) ``` #### `#safe_const` [#safe_const] ```ruby def safe_const(name) ``` #### `#stringify_id` [#stringify_id] ```ruby def stringify_id(id) ``` #### `#iso` [#iso] ```ruby def iso(time) ``` ## `lib/supabase/billing/engine.rb` [#libsupabasebillingenginerb] ### `class Supabase::Billing::Engine < ::Rails::Engine` [#class-supabasebillingengine--railsengine] Mountable Rails engine that exposes the Stripe (and, in later stories, Adapty) webhook endpoints. The engine is isolated so `supabase_billing` lives in its own URL/helper namespace. The actual route mount is *conditional* on `Supabase::Billing.config.stripe_ingestion`: * `:webhook` — mounts `POST /webhooks/stripe`. * `:sync_engine` — no Stripe webhook route is mounted; reflection reads from the `stripe.*` schema instead. AC: the two modes are mutually exclusive — there is no hybrid ingestion path in the MVP. ## `lib/supabase/billing/plan.rb` [#libsupabasebillingplanrb] ### `class Supabase::Billing::Plan` [#class-supabasebillingplan] **Constants** * `VALID_ENTITLEMENT_VALUE_DESC` = `"Numeric, true, or false"` **Attributes** * `attr_reader :key` * `attr_reader :name` * `attr_reader :metadata` **Methods** #### `#initialize` [#initialize-7] ```ruby def initialize(key, name: nil, **metadata) ``` #### `#entitlements` [#entitlements] ```ruby def entitlements(hash = nil) ``` #### `#entitlement` [#entitlement] ```ruby def entitlement(entitlement_key, value:) ``` #### `#stripe_prices` [#stripe_prices] ```ruby def stripe_prices(prices = nil) ``` #### `#adapty_products` [#adapty_products] ```ruby def adapty_products(products = nil) ``` #### `#to_h` [#to_h] ```ruby def to_h ``` #### `#add_entitlement` [#add_entitlement] ```ruby def add_entitlement(entitlement_key, value) ``` #### `#valid_entitlement_value?` [#valid_entitlement_value] ```ruby def valid_entitlement_value?(value) ``` #### `#coerce_provider_ids` [#coerce_provider_ids] ```ruby def coerce_provider_ids(value, field) ``` ## `lib/supabase/billing/railtie.rb` [#libsupabasebillingrailtierb] ### `class Supabase::Billing::Railtie < ::Rails::Railtie` [#class-supabasebillingrailtie--railsrailtie] Exposes the `supabase_billing:*` rake tasks to the host Rails app and wires the boot-time sync-engine preflight (US-006) when the adapter is configured for `:sync_engine` ingestion. ## `lib/supabase/billing/sync.rb` [#libsupabasebillingsyncrb] ### `class Supabase::Billing::Sync` [#class-supabasebillingsync] Reconciles the DSL-declared plans + entitlements into the host app's ActiveRecord tables. Idempotent: rerunning makes no changes if nothing in the DSL has changed since the last sync. Plans that disappear from the DSL are *archived* (their `archived_at` column is stamped) rather than hard-deleted, so historical subscriptions still resolve their plan reference. Models are resolved lazily via constant lookup at call-time so the gem can be required outside a Rails app; tests inject explicit model classes to run against an isolated AR setup. **Constants** * `DEFAULT_MODEL_NAMES` = `(multi-line literal)` **Attributes** * `attr_reader :config` * `attr_reader :logger` **Methods** #### `#total` [#total] ```ruby def total ``` #### `#initialize` [#initialize-8] ```ruby def initialize(config: Supabase::Billing.config, logger: nil, models: nil, now: nil) ``` #### `#call` [#call-3] ```ruby def call ``` #### `#result` [#result] ```ruby def result ``` #### `#now` [#now-2] ```ruby def now ``` #### `#models` [#models-3] ```ruby def models ``` #### `#resolve_models` [#resolve_models-3] ```ruby def resolve_models ``` #### `#ar_transaction` [#ar_transaction] ```ruby def ar_transaction(&block) ``` #### `#sync_entitlements` [#sync_entitlements] ```ruby def sync_entitlements ``` \---- entitlements ------------------------------------------------------ #### `#entitlement_kinds` [#entitlement_kinds] ```ruby def entitlement_kinds ``` Hash of \{ entitlement\_key(Symbol) => "numeric" | "boolean" } collected across every plan in the DSL. Raises if the same key appears with incompatible kinds (numeric in one plan, boolean in another). #### `#kind_for` [#kind_for] ```ruby def kind_for(value) ``` #### `#sync_plans_and_links` [#sync_plans_and_links] ```ruby def sync_plans_and_links ``` \---- plans + plan\_entitlements + provider\_products -------------------- #### `#upsert_plan` [#upsert_plan] ```ruby def upsert_plan(dsl_plan) ``` #### `#sync_plan_entitlements` [#sync_plan_entitlements] ```ruby def sync_plan_entitlements(plan_record, dsl_plan, entitlement_records_by_key) ``` #### `#sync_provider_products` [#sync_provider_products] ```ruby def sync_provider_products(plan_record, dsl_plan) ``` #### `#archive_removed_plans` [#archive_removed_plans] ```ruby def archive_removed_plans ``` \---- archival --------------------------------------------------------- #### `#dsl_plans` [#dsl_plans] ```ruby def dsl_plans ``` \---- shared helpers --------------------------------------------------- #### `#log_action` [#log_action] ```ruby def log_action(action, resource, key, details = nil) ``` ## `lib/supabase/billing/version.rb` [#libsupabasebillingversionrb] ### `module Supabase::Billing` [#module-supabasebilling-2] **Constants** * `VERSION` = `"0.1.0"` # Models [#models-4] ## `lib/generators/supabase_billing/install/templates/app/models/billing/application_record.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingapplication_recordrbtt] ### `class Billing::ApplicationRecord < ::ApplicationRecord` [#class-billingapplicationrecord--applicationrecord] ## `lib/generators/supabase_billing/install/templates/app/models/billing/billing_customer.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingbilling_customerrbtt] ### `class Billing::BillingCustomer < ApplicationRecord` [#class-billingbillingcustomer--applicationrecord] **Table:** `billing_customers` **Associations** * `belongs_to :user` → `::User` * `has_many :subscriptions` → `Billing::Subscription` * `has_many :provider_customers` → `Billing::ProviderCustomer` * `has_many :usage_events` → `Billing::UsageEvent` ## `lib/generators/supabase_billing/install/templates/app/models/billing/entitlement.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingentitlementrbtt] ### `class Billing::Entitlement < ApplicationRecord` [#class-billingentitlement--applicationrecord] **Table:** `entitlements` **Associations** * `has_many :plan_entitlements` → `Billing::PlanEntitlement` * `has_many :plans` → `Billing::Plan` * `has_many :usage_limits` → `Billing::UsageLimit` * `has_many :usage_events` → `Billing::UsageEvent` ## `lib/generators/supabase_billing/install/templates/app/models/billing/plan.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingplanrbtt] ### `class Billing::Plan < ApplicationRecord` [#class-billingplan--applicationrecord] **Table:** `plans` **Associations** * `has_many :plan_entitlements` → `Billing::PlanEntitlement` * `has_many :entitlements` * `has_many :subscriptions` → `Billing::Subscription` * `has_many :provider_products` → `Billing::ProviderProduct` ## `lib/generators/supabase_billing/install/templates/app/models/billing/plan_entitlement.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingplan_entitlementrbtt] ### `class Billing::PlanEntitlement < ApplicationRecord` [#class-billingplanentitlement--applicationrecord] **Table:** `plan_entitlements` **Associations** * `belongs_to :plan` → `Billing::Plan` * `belongs_to :entitlement` → `Billing::Entitlement` ## `lib/generators/supabase_billing/install/templates/app/models/billing/provider_customer.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingprovider_customerrbtt] ### `class Billing::ProviderCustomer < ApplicationRecord` [#class-billingprovidercustomer--applicationrecord] **Table:** `provider_customers` **Associations** * `belongs_to :billing_customer` → `Billing::BillingCustomer` ## `lib/generators/supabase_billing/install/templates/app/models/billing/provider_event.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingprovider_eventrbtt] ### `class Billing::ProviderEvent < ApplicationRecord` [#class-billingproviderevent--applicationrecord] **Table:** `provider_events` ## `lib/generators/supabase_billing/install/templates/app/models/billing/provider_product.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingprovider_productrbtt] ### `class Billing::ProviderProduct < ApplicationRecord` [#class-billingproviderproduct--applicationrecord] **Table:** `provider_products` **Associations** * `belongs_to :plan` → `Billing::Plan` ## `lib/generators/supabase_billing/install/templates/app/models/billing/provider_subscription.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingprovider_subscriptionrbtt] ### `class Billing::ProviderSubscription < ApplicationRecord` [#class-billingprovidersubscription--applicationrecord] **Table:** `provider_subscriptions` **Associations** * `belongs_to :subscription` → `Billing::Subscription` ## `lib/generators/supabase_billing/install/templates/app/models/billing/subscription.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingsubscriptionrbtt] ### `class Billing::Subscription < ApplicationRecord` [#class-billingsubscription--applicationrecord] **Table:** `subscriptions` **Associations** * `belongs_to :billing_customer` → `Billing::BillingCustomer` * `belongs_to :plan` → `Billing::Plan` * `has_many :subscription_items` → `Billing::SubscriptionItem` * `has_many :usage_limits` → `Billing::UsageLimit` * `has_many :provider_subscriptions` → `Billing::ProviderSubscription` ## `lib/generators/supabase_billing/install/templates/app/models/billing/subscription_item.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingsubscription_itemrbtt] ### `class Billing::SubscriptionItem < ApplicationRecord` [#class-billingsubscriptionitem--applicationrecord] **Table:** `subscription_items` **Associations** * `belongs_to :subscription` → `Billing::Subscription` ## `lib/generators/supabase_billing/install/templates/app/models/billing/usage_event.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingusage_eventrbtt] ### `class Billing::UsageEvent < ApplicationRecord` [#class-billingusageevent--applicationrecord] **Table:** `usage_events` **Associations** * `belongs_to :billing_customer` → `Billing::BillingCustomer` * `belongs_to :entitlement` → `Billing::Entitlement` ## `lib/generators/supabase_billing/install/templates/app/models/billing/usage_limit.rb.tt` [#libgeneratorssupabase_billinginstalltemplatesappmodelsbillingusage_limitrbtt] ### `class Billing::UsageLimit < ApplicationRecord` [#class-billingusagelimit--applicationrecord] **Table:** `usage_limits` **Associations** * `belongs_to :subscription` → `Billing::Subscription` * `belongs_to :entitlement` → `Billing::Entitlement` # Entitlements (the `entitled?` API) (/reference/billing/entitlements) `supabase-billing` exposes a tiny, opinionated entitlement API on your `User` model. Once `include Acts::Billable` is in `app/models/user.rb` (the install generator inserts it automatically — see [Getting Started](/reference/billing/getting-started)), every controller, view, mailer, and background job that already has access to a `User` can ask the four questions that matter: ```ruby current_user.subscribed? # => true / false current_user.plan # => # | nil current_user.entitled?(:projects) # => true / false current_user.limit(:projects) # => 100 | nil ``` Everything else on this page is a refinement of those four methods — what they read, how they cache, the data model they sit on top of, and the patterns for gating controllers, views, and metered usage. ## The four core methods [#the-four-core-methods] All four methods are added to your `User` model by `include Acts::Billable`. They read from the canonical `Billing::*` ActiveRecord models — never a provider SDK — and return safe defaults when the user has no `billing_customer` or no active subscription. ### `user.subscribed?` [#usersubscribed] Returns `true` when the user has a `Billing::Subscription` whose `status` is `"active"` or `"trialing"`, and `false` otherwise. ```ruby current_user.subscribed? # => true ``` `subscribed?` does **not** consider entitlements — it answers the broad question "is this user a paying customer right now?". Use it for top-level gates (e.g. "logged-in dashboard vs. upgrade page") and reach for `entitled?` for per-feature checks. ### `user.plan` [#userplan] Returns the `Billing::Plan` ActiveRecord row associated with the user's active subscription, or `nil` if they have no active subscription: ```ruby current_user.plan # => # current_user.plan&.key # => "pro" current_user.plan&.name # => "Pro" ``` The returned `Billing::Plan` is the canonical plan record — the same one keyed by the `Symbol` you declared in `config.plan :pro` in the plan DSL. Use `plan&.key` (the string key, e.g. `"pro"`) for comparisons and display labels. ### `user.entitled?(:key)` [#userentitledkey] The headline method. Returns `true` when the user's active subscription's plan grants the named entitlement and the entitlement's quota (if numeric) is positive; `false` otherwise. ```ruby current_user.entitled?(:sso) # => true (boolean entitlement granted) current_user.entitled?(:ai_requests) # => true (numeric entitlement, value > 0) current_user.entitled?(:not_in_plan) # => false (entitlement key not granted) ``` Resolution rules, in order: 1. If the user has no active subscription, return `false`. 2. If the active plan has no `plan_entitlement` link for the key, return `false`. 3. If the link's `value_boolean` is set (true or false), return that. 4. If the link's `value_numeric` is set, return `value_numeric > 0`. 5. Otherwise, return `false`. The argument can be a `Symbol` or `String`; both are coerced to a string and matched against `entitlements.key`. ### `user.limit(:key)` [#userlimitkey] Returns the numeric cap for a metered entitlement, or `nil` when the entitlement is boolean, the plan doesn't grant it, or the user has no active subscription. ```ruby current_user.limit(:ai_requests) # => 10_000 current_user.limit(:projects) # => 100 current_user.limit(:sso) # => nil (boolean entitlement) current_user.limit(:not_in_plan) # => nil ``` `limit` reads `plan_entitlements.value_numeric` for the active plan and the requested key — it does **not** consider how much of the limit the user has already consumed. For "how much is left," see [`usage` and `remaining`](#metered-usage-usage-remaining-and-record_usage) below. All four methods (plus `usage` and `remaining`) cache their results per request in `Supabase::Billing::Current.entitlement_cache`. Rails clears this cache automatically at the request boundary, so a single controller action that calls `entitled?(:foo)` three times only hits the database once. Disable globally via `Supabase::Billing.config.cache_entitlements_per_request = false` for apps that need always-fresh reads. ## The data model: plans, entitlements, plan\_entitlements, usage\_limits [#the-data-model-plans-entitlements-plan_entitlements-usage_limits] The four `entitled?` methods read from four canonical tables. Knowing how they fit together makes it obvious where to add a new entitlement, why a key doesn't resolve, or what to query directly for an admin dashboard. * **`plans`** — one row per pricing tier (`free`, `pro`, `enterprise`). Keyed by the `Symbol` you declared in `config.plan :pro`. Plans removed from the DSL get `archived_at` stamped rather than deleted, so historic subscriptions stay resolvable. * **`entitlements`** — one row per feature key (`:ai_requests`, `:projects`, `:sso`). Each entitlement has a `kind` of `"numeric"` or `"boolean"` that's inferred from the value type the first time it appears in a `config.plan` block. * **`plan_entitlements`** — the join table that says "plan X grants entitlement Y at value Z." Each row has either `value_numeric` (for metered limits, e.g. `10_000`) or `value_boolean` (for on/off features, e.g. `true`). `entitled?(:key)` consults this table for the user's active plan; `limit(:key)` returns the `value_numeric`. * **`usage_limits`** — per-subscription per-period quotas that can override the plan-level cap. Optional. Each row points at a `subscription` and an `entitlement`, with a `value_numeric` and a `period_start` / `period_end`. Use this when one customer needs a non-standard cap without forking the plan. The `Billing::ApplicationRecord` ActiveRecord classes mirror these tables one-for-one and ship with the standard associations: ```ruby Billing::Plan has_many :plan_entitlements has_many :entitlements, through: :plan_entitlements has_many :subscriptions has_many :provider_products Billing::Entitlement has_many :plan_entitlements has_many :plans, through: :plan_entitlements has_many :usage_limits has_many :usage_events Billing::PlanEntitlement belongs_to :plan belongs_to :entitlement Billing::UsageLimit belongs_to :subscription belongs_to :entitlement ``` In normal app code you should never need to touch these directly — `entitled?`, `limit`, and `record_usage` cover the full surface. Reach for them when you're building admin pages or migrating data. See [Schema Reference](/reference/billing/schema) for the full column-level breakdown of every table. ### Editing the plan DSL [#editing-the-plan-dsl] The source of truth for which plan grants which entitlement is the plan DSL in `config/initializers/supabase_billing.rb`: ```ruby SupabaseBilling.configure do |config| config.plan :free, name: "Free" do entitlement :ai_requests, value: 100 entitlement :projects, value: 1 entitlement :sso, value: false end config.plan :pro, name: "Pro" do entitlement :ai_requests, value: 10_000 entitlement :projects, value: 100 entitlement :sso, value: false end config.plan :enterprise, name: "Enterprise" do entitlement :ai_requests, value: 1_000_000 entitlement :projects, value: 10_000 entitlement :sso, value: true end end ``` After any change to the DSL, reconcile it into the database: ```bash bin/rails supabase_billing:sync ``` `sync` upserts the `plans`, `entitlements`, and `plan_entitlements` rows so they match the DSL. Numeric values populate `plan_entitlements.value_numeric`; `true` / `false` values populate `plan_entitlements.value_boolean`. The same key reused across plans (e.g. `:ai_requests`) is a single `entitlements` row with one `plan_entitlement` per plan. ## Gating a controller action [#gating-a-controller-action] The most common use case: refuse to execute an action unless the user has the entitlement. ```ruby class Api::AiRequestsController < ApplicationController before_action :require_authentication before_action :require_ai_quota, only: :create def create response = AiClient.complete(params[:prompt]) Current.user.record_usage(:ai_requests, amount: 1) render json: response end private def require_ai_quota return if Current.user.entitled?(:ai_requests) render json: { error: "quota exceeded", plan: Current.user.plan&.key, limit: Current.user.limit(:ai_requests) }, status: :payment_required end end ``` For "any paying user can use this," gate on `subscribed?` instead: ```ruby class ExportsController < ApplicationController before_action :require_authentication before_action :require_subscription def create Export.create!(user: Current.user, format: params[:format]) end private def require_subscription return if Current.user.subscribed? redirect_to billing_url, alert: "An active subscription is required." end end ``` `Current.user` and `current_user` are interchangeable here — both come from `supabase-rails` and both have `Acts::Billable` mixed in. ## Gating UI in views [#gating-ui-in-views] The same methods work in views and helpers. They share the per-request cache, so calling `entitled?` once in a controller and again in the view rendered for that request is a single database read. ```erb <%# app/views/dashboard/index.html.erb %> <% if Current.user.entitled?(:sso) %> <%= link_to "SSO settings", sso_settings_path, class: "btn" %> <% else %>
SSO is a Pro feature. <%= link_to "Upgrade", billing_path %>.
<% end %> <% if Current.user.entitled?(:ai_requests) %>

You have used <%= Current.user.usage(:ai_requests) %> of <%= Current.user.limit(:ai_requests) %> AI requests this period. <%= Current.user.remaining(:ai_requests) %> remaining.

<% else %>

Your AI quota for this period is exhausted.

<% end %> ``` A small helper keeps view code tidy when the same entitlement is gated in several places: ```ruby # app/helpers/billing_helper.rb module BillingHelper def gated(entitlement_key, &block) return capture(&block) if Current.user&.entitled?(entitlement_key) render(partial: "shared/upgrade_prompt", locals: { entitlement: entitlement_key }) end end ``` ```erb <%= gated(:sso) do %> <%= link_to "SSO settings", sso_settings_path %> <% end %> ``` ## Metered usage: `usage`, `remaining`, and `record_usage` [#metered-usage-usage-remaining-and-record_usage] For numeric entitlements you want to consume against (API calls, exports, AI tokens), `Acts::Billable` adds three more methods on top of the core four: ```ruby current_user.usage(:ai_requests) # => 248 (sum of recorded events this period) current_user.limit(:ai_requests) # => 10_000 current_user.remaining(:ai_requests) # => 9_752 current_user.record_usage(:ai_requests, amount: 1) ``` * `usage(:key)` — sums `usage_events.amount` for the user's `billing_customer` and the named entitlement, scoped to the active subscription's `current_period_start` ... `current_period_end`. Returns `0` when the user has no customer record, no active subscription, or the entitlement key isn't registered. * `remaining(:key)` — convenience for `limit(:key) - usage(:key)`. Returns `nil` when the entitlement has no numeric cap (e.g. it's boolean, unlimited, or the user has no subscription). * `record_usage(:key, amount: 1, recorded_at: nil)` — writes a `Billing::UsageEvent` row tied to the user's `billing_customer` and the entitlement, and invalidates the per-request cache for that key so a subsequent `usage` / `remaining` / `entitled?` re-reads from the database. `recorded_at` defaults to `Time.now.utc`. The `usage_events` table is also a direct ActiveRecord model — `Billing::UsageEvent` — if you need to query history outside the current period or aggregate by day: ```ruby Billing::UsageEvent .where(billing_customer: Current.user.billing_customer) .where(entitlement: Billing::Entitlement.find_by!(key: "ai_requests")) .where(recorded_at: 30.days.ago..) .sum(:amount) ``` `record_usage` raises `Supabase::Billing::Error` when the user has no `billing_customer` record or when the entitlement key isn't registered in the DSL — both are programmer-error conditions that should fail loudly in development, not be silently ignored. `entitled?(:ai_requests)` returns `true` while `plan_entitlements.value_numeric` is positive — it does **not** auto-decrement as you call `record_usage`. The "have they exhausted their quota?" check belongs in your app code (or in a `usage_limits` override row), typically as: ```ruby Current.user.entitled?(:ai_requests) && Current.user.remaining(:ai_requests).to_i.positive? ``` If you need hard period-scoped caps enforced at the DB layer, populate `usage_limits` rows during webhook ingestion and consult them in your gating method. ## Cache invalidation [#cache-invalidation] The per-request cache is invalidated automatically when: * Rails finishes the request (the `Supabase::Billing::Current` `CurrentAttributes` subclass is reset by the executor). * `record_usage(:key)` writes a `UsageEvent` — it calls `acts_billable_invalidate_cache(:key)` so subsequent reads in the same request see the new total. If you write to billing tables out-of-band (e.g. an admin tool inside the same request that creates a subscription manually), call the public invalidator yourself: ```ruby Current.user.acts_billable_invalidate_cache # drops all cached entitlements for this user Current.user.acts_billable_invalidate_cache(:projects) # drops just :projects (entitled?, limit, usage, remaining) ``` For development hosts that want to disable caching entirely, set `Supabase::Billing.config.cache_entitlements_per_request = false` in `config/initializers/supabase_billing.rb`. The canonical `billing_customers`, `subscriptions`, and `subscription_items` tables ship with `*_select_own` Row-Level Security policies scoped to `auth.uid()` (installed by the `supabase_billing:install` migration). Because `entitled?`, `subscribed?`, `plan`, and `limit` resolve through ActiveRecord against the request's Supabase JWT context — set up by `supabase-rails` — a misconfigured controller cannot leak another user's subscription state through these methods. The same RLS scoping also applies when the Supabase JS or iOS SDKs read these tables directly with the anon key. Pass `--skip-rls` to the install generator if you intend to author the policies by hand, but never run multi-tenant production traffic without RLS in place. ## Next steps [#next-steps] # Getting started with supabase-billing (/reference/billing/getting-started) This is the quickstart for adding `supabase-billing` to a Rails app that already uses [`supabase-rails`](/reference/rails) for Supabase Auth. Follow it top-to-bottom and you'll end with the canonical billing schema migrated, Stripe and Adapty webhook endpoints mounted, and a controller action gated by `current_user.entitled?(:feature_key)`. `supabase-billing` is **not standalone** — it expects `Current.user`, the shadow `users` table, and the Supabase request context that `supabase-rails` installs. The first step on this page is making sure those are in place. ## 1. Install `supabase-rails` first [#1-install-supabase-rails-first] `supabase-billing` builds on top of `supabase-rails` and **assumes the supabase-rails install and user-model generators have already been run** in the host app. Skipping this step leaves you without `Current.user`, without the `users` shadow table that `billing_customers.user_id` references, and with no Supabase request context for the webhook controllers to reuse. The billing install generator will fail or produce a broken schema if these aren't done first. If your app doesn't already have `supabase-rails` installed, do that now: ```bash bundle add supabase-rails bin/rails generate supabase:install bin/rails generate supabase:user_model ``` * `supabase:install` writes the `Authentication` concern, the Devise-shape controllers, `Current`, the `:web`-mode initializer, and the `supabase_authentication_routes` line in `config/routes.rb`. See [Getting started with Rails](/reference/rails/getting-started) for the full walk-through. * `supabase:user_model` adds an `app/models/user.rb` backed by a host-app `users` table whose `id` matches `auth.users.id`. `supabase-billing` foreign-keys `billing_customers.user_id` against this table, so it must exist before the billing migration runs. Confirm that you have: * A `User` model at `app/models/user.rb`. * A `users` table in the host database (run `bin/rails db:migrate` if the supabase-rails user-model migration is still pending). * A booting app where `Current.user` is populated for a signed-in request. ## 2. Add the gem [#2-add-the-gem] ```bash bundle add supabase-billing ``` This pulls `supabase-billing` into your `Gemfile` and runs `bundle install`. The gem is a mountable Rails engine — no extra `require` or initializer changes are needed at this step. ## 3. Run the install generator [#3-run-the-install-generator] ```bash bin/rails generate supabase_billing:install --provider=stripe,adapty ``` The `--provider` flag is required and selects which ingestion adapters to wire up. Pass one or both of `stripe` and `adapty`: * `--provider=stripe` — webhook-only, no mobile. * `--provider=adapty` — mobile-only, no Stripe. * `--provider=stripe,adapty` — both (most common). Two optional flags: * `--stripe-ingestion=webhook|sync_engine` — default `webhook`. Pick `sync_engine` if you're already running [`stripe-sync-engine`](/reference/billing/providers) and want the gem to read from its `stripe.*` schema instead of consuming webhooks directly. * `--skip-rls` — omit the Supabase RLS policies from the generated migration. Use this only if you plan to author the policies by hand. ### Files the generator creates [#files-the-generator-creates] ``` create db/migrate/_create_supabase_billing_schema.rb create app/models/billing/application_record.rb create app/models/billing/billing_customer.rb create app/models/billing/plan.rb create app/models/billing/entitlement.rb create app/models/billing/plan_entitlement.rb create app/models/billing/subscription.rb create app/models/billing/subscription_item.rb create app/models/billing/usage_limit.rb create app/models/billing/usage_event.rb create app/models/billing/provider_customer.rb create app/models/billing/provider_subscription.rb create app/models/billing/provider_product.rb create app/models/billing/provider_event.rb create config/initializers/supabase_billing.rb create config/supabase_billing.yml insert config/routes.rb insert app/models/user.rb ``` What each group does: * **Migration.** A single `create_supabase_billing_schema` migration that creates eight canonical tables (`billing_customers`, `plans`, `entitlements`, `plan_entitlements`, `subscriptions`, `subscription_items`, `usage_limits`, `usage_events`) plus four provider-mapping tables (`provider_customers`, `provider_subscriptions`, `provider_products`, `provider_events`). All UUID-keyed and (unless `--skip-rls`) shipped with RLS policies scoped to `auth.uid()`. See [Schema Reference](/reference/billing/schema) for the full ER diagram. * **Models** under `app/models/billing/`. One ActiveRecord class per table, all inheriting from `Billing::ApplicationRecord`. These are the canonical objects you reach for in your app code — never the provider-mapping tables directly. * **Initializer** at `config/initializers/supabase_billing.rb`. Fully-commented DSL skeleton — uncomment the `plan` blocks and adapter secret lines you need. See [Providers](/reference/billing/providers) for the per-provider configuration. * **YAML** at `config/supabase_billing.yml`. Env-keyed lookups for the dev / test / prod webhook secrets, pre-filled with `ENV.fetch(...)` calls. * **Routes.** `mount Supabase::Billing::Engine => "/supabase_billing"` is appended to `config/routes.rb`. The engine exposes `/supabase_billing/webhooks/stripe` and `/supabase_billing/webhooks/adapty` — see [Webhooks](/reference/billing/webhooks) for the endpoint contract. * **`User` model.** `include Acts::Billable` is inserted idempotently into `app/models/user.rb`. This is what gives `current_user.entitled?`, `subscribed?`, `plan`, and `limit` their behaviour. The generator is idempotent — re-running it skips files that already exist and only re-applies the `include Acts::Billable` and route mount if they're missing. ## 4. Migrate the database [#4-migrate-the-database] ```bash bin/rails db:migrate ``` This runs the `create_supabase_billing_schema` migration against your Supabase Postgres database. You should see twelve new tables and (unless you passed `--skip-rls`) `*_select_own` policies attached to `billing_customers`, `subscriptions`, and `subscription_items`. Run migrations against Supabase's direct connection (port 5432), not the pooler. The pooler doesn't support the `CREATE POLICY` statements the RLS-enabled migration emits. The `supabase-rails` README has the connection string details. ## 5. Configure at least one plan [#5-configure-at-least-one-plan] Open `config/initializers/supabase_billing.rb`, uncomment the example `config.plan` blocks, and define the plans and entitlements your app needs. A minimal example: ```ruby # config/initializers/supabase_billing.rb SupabaseBilling.configure do |config| config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET") config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET") config.plan :free, name: "Free" do entitlement :ai_requests, value: 100 entitlement :projects, value: 1 end config.plan :pro, name: "Pro" do entitlement :ai_requests, value: 10_000 entitlement :projects, value: 100 stripe_prices %w[price_1Nabc...] adapty_products %w[com.example.pro.monthly] end end ``` Then reconcile the DSL into the database: ```bash bin/rails supabase_billing:sync ``` `sync` upserts plans, entitlements, and `provider_products` rows. Plans removed from the DSL get `archived_at` stamped rather than destroyed, so historic subscriptions stay resolvable. Re-run it after every change to the plan DSL — see [Entitlements](/reference/billing/entitlements) for the full DSL surface. ## 6. Make your first `entitled?` call [#6-make-your-first-entitled-call] With at least one plan synced, you can gate a controller action on it. The `Current.user` you already get from `supabase-rails` now has the four billing methods on it: ```ruby class ExportsController < ApplicationController before_action :require_authentication def create unless Current.user.entitled?(:ai_requests) return render json: { error: "quota exceeded" }, status: :payment_required end response = AiClient.complete(params[:prompt]) Current.user.record_usage(:ai_requests, amount: 1) render json: response end end ``` In a brand-new app with no provider webhook delivered yet, `current_user.subscribed?` returns `false` and `current_user.entitled?(:any_key)` returns `false` — exactly the behaviour you want for a user who hasn't checked out yet. The first `subscription_started` event from Stripe or Adapty flips them to `true`. To exercise the path end-to-end before wiring real provider webhooks, you can hand-create a `Billing::Subscription` row in a Rails console: ```ruby user = User.find_by(email: "you@example.com") customer = Billing::BillingCustomer.create!(user: user) Billing::Subscription.create!( billing_customer: customer, plan: Billing::Plan.find_by!(key: "pro"), status: "active" ) user.reload.entitled?(:ai_requests) # => true user.subscribed? # => true user.plan.key # => "pro" user.limit(:ai_requests) # => 10_000 ``` ## What you just got [#what-you-just-got] After this guide: * Twelve canonical and provider-mapping tables exist in your Supabase Postgres database, all UUID-keyed and (by default) RLS-scoped to `auth.uid()`. * The `Supabase::Billing::Engine` is mounted at `/supabase_billing`, exposing `/supabase_billing/webhooks/stripe` and `/supabase_billing/webhooks/adapty`. * Your `User` model has the four `entitled?` / `subscribed?` / `plan` / `limit` methods through `Acts::Billable`. * A plan DSL in `config/initializers/supabase_billing.rb` and a `bin/rails supabase_billing:sync` rake task to reconcile it into the database. The webhook endpoints are mounted but not yet receiving traffic. To take real money, point Stripe and/or Adapty at those URLs and copy their signing secrets into your environment — see [Providers](/reference/billing/providers) for the per-provider setup. ## Next steps [#next-steps] # Supabase Billing (supabase-billing) (/reference/billing) `supabase-billing` is the Ruby on Rails subscription and entitlement layer for [Supabase](https://supabase.com). It ships a canonical billing schema (customers, subscriptions, plans, entitlements, usage limits), ingestion adapters for Stripe and Adapty, and a single `entitled?` API for gating features — so your app code never reaches for a provider SDK to ask "can this user use this feature?". The gem builds on [`supabase-rails`](/reference/rails) and assumes Supabase Auth is your identity layer: `Current.user` is set per request, RLS policies key off `auth.uid()`, and provider customer records are linked to `auth.users.id` automatically. `supabase-billing` is most commonly paired with the [Hotwire monolith starter kit](/reference/starterkits/hotwire) — it ships `supabase-rails` in `:web` mode with a full server-rendered app shell where `entitled?` gating drops naturally into controllers and views. The [Inertia + React kit](/reference/starterkits/inertia-react) is also `:web` mode and works the same way on the Rails side; expose `entitled?` results through `inertia_share` to gate React routes. ## Why supabase-billing [#why-supabase-billing] * **Provider-agnostic by design.** Your app calls `user.entitled?(:pro_export)` whether the subscription was opened on the web via Stripe or in your iOS / Android app via Adapty. * **One canonical schema, two adapters.** Webhooks from each provider land in the same `customers` / `subscriptions` / `subscription_items` tables. Reporting, RLS, and feature gates see one shape. * **RLS-aware out of the box.** Tables ship with policies scoped to `auth.uid()`, so a misconfigured controller can't leak another user's subscription. * **No reimplementation of Supabase plumbing.** Session storage, JWT verification, and per-request auth come from `supabase-rails`. This gem only adds billing. ## Providers [#providers] ## The `entitled?` API [#the-entitled-api] Once a webhook has landed, gating a feature is a single call: ```ruby class ExportsController < ApplicationController before_action :authenticate def create return head :payment_required unless Current.user.entitled?(:pro_export) Export.create!(user: Current.user, format: params[:format]) end end ``` `entitled?` resolves the current user's active subscription, looks up the plan's entitlements, and answers true/false. Companion methods cover the common shapes — `Current.user.subscribed?`, `Current.user.plan`, and `Current.user.limit(:monthly_exports)` for metered features. See [Entitlements](/reference/billing/entitlements) for the full surface. ## Dependency on `supabase-rails` [#dependency-on-supabase-rails] `supabase-billing` is a Rails engine that depends on [`supabase-rails`](/reference/rails). Before installing this gem, your app must already be wired up via `rails g supabase:install` and have a User model generated by `rails g supabase:user_model` (so `users.id` matches `auth.users.id`). The Stripe and Adapty webhook controllers reuse the Supabase request context that `supabase-rails` sets, and entitlement queries trust that `auth.uid()` is populated. The [Getting Started](/reference/billing/getting-started) guide walks through the required `supabase-rails` setup first. ## Explore the reference [#explore-the-reference] ## Project [#project] * Source: [`supabase-community/supabase-billing`](https://github.com/supabase-community/supabase-billing) * Rails integration reference: [`supabase-rails` docs](/reference/rails) * Issues: the gem's issue tracker # Providers (Stripe + Adapty) (/reference/billing/providers) `supabase-billing` ships two ingestion adapters: **Stripe** for the web and **Adapty** for mobile in-app subscriptions. Both write into the same canonical `subscriptions` / `provider_subscriptions` tables, so `current_user.entitled?(:feature)` returns the same answer regardless of where the user checked out. The tabs below cover each provider end-to-end: the webhook endpoint the engine mounts, the env var the signing secret comes from, the supported event types, and the matching `config/initializers/supabase_billing.rb` block. You only need to configure the providers your app actually uses. Both Stripe and Adapty refuse to deliver webhooks to non-TLS endpoints in their production / live environments. Expose `/supabase_billing/webhooks/stripe` and `/supabase_billing/webhooks/adapty` over HTTPS — terminate TLS at your load balancer, never at the Rails process — and use plain HTTP only for local development behind a tunneling tool (e.g. `ngrok`, `cloudflared`) that re-presents the endpoint as HTTPS to the provider. Stripe is the web ingestion adapter. It runs in one of two mutually exclusive modes — pick `webhook` (the default) for the typical setup, or `sync_engine` if you already operate a `stripe/stripe-sync-engine` instance that mirrors Stripe into a `stripe.*` Postgres schema. ### Webhook endpoint [#webhook-endpoint] `POST /supabase_billing/webhooks/stripe` Mounted by the engine when `config.stripe_ingestion = :webhook` (the default). The route is **not** registered in `:sync_engine` mode — by design, so the two modes can never both be live at once. Point the Stripe dashboard webhook at this path under your app's public origin, e.g.: ``` https://app.example.com/supabase_billing/webhooks/stripe ``` ### Signing secret [#signing-secret] The endpoint verifies every request against Stripe's HMAC signature using the value of `config.stripe_webhook_secret`, which the generated initializer pulls from `ENV["STRIPE_WEBHOOK_SECRET"]`. Set this to the `whsec_...` value Stripe shows you when you create the webhook. ``` STRIPE_WEBHOOK_SECRET=whsec_******************************** ``` Requests without a valid `Stripe-Signature` header are rejected with `400 Bad Request` and never reach the event processor. ### Ingestion mode: `:webhook` vs `:sync_engine` [#ingestion-mode-webhook-vs-sync_engine] `supabase-billing` supports two ways of getting subscription state from Stripe into the canonical tables. They are mutually exclusive — `config.stripe_ingestion` picks one: * **`:webhook` (default).** The engine mounts `/supabase_billing/webhooks/stripe`, verifies the signature, parses the event, and runs it through `Supabase::Billing::Adapters::Stripe::EventProcessor`. This is the right choice for a typical Rails app — no extra infrastructure beyond Stripe itself. * **`:sync_engine`.** No webhook route is mounted. Instead, `stripe/stripe-sync-engine` is responsible for keeping a `stripe.*` schema in your Postgres up to date, and the gem's `Supabase::Billing::Adapters::Stripe::SyncEngineReflector` reads from those tables to populate the canonical rows. Use this if you already operate stripe-sync-engine for other reasons (analytics, finance reconciliation) and don't want a second consumer of Stripe events. Switching between modes is a config-only change; the canonical `subscriptions` / `entitlements` data the rest of your app reads from is identical either way. ### Supported event types [#supported-event-types] In `:webhook` mode, the Stripe event processor handles exactly four event types — anything else is logged and dropped (it will not 500 the endpoint, but Stripe quota is wasted sending it). Untick the rest in the Stripe dashboard: * `customer.subscription.created` * `customer.subscription.updated` * `customer.subscription.deleted` * `invoice.payment_succeeded` The first three drive the canonical `subscriptions.status` transitions (`active` / `trialing` / `past_due` / `cancelled` / `expired`). `invoice.payment_succeeded` extends `current_period_end` and bumps a `past_due` subscription back to `active`. ### Initializer snippet [#initializer-snippet] ```ruby # config/initializers/supabase_billing.rb Supabase::Billing.configure do |config| # :webhook (default) or :sync_engine — mutually exclusive. config.stripe_ingestion = :webhook config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET") config.plan :pro, name: "Pro" do entitlement :ai_requests, value: 10_000 entitlement :projects, value: 100 # Each Stripe price ID that should resolve to this plan. stripe_prices %w[price_1Nabc... price_1Ndef...] end end ``` In `:sync_engine` mode the `stripe_webhook_secret` line can be omitted — there is no webhook to verify — but the `stripe_prices` mapping on each plan is still required so the reflector can resolve a Stripe price to a canonical plan. Adapty is the mobile ingestion adapter. It covers iOS (StoreKit / Apple In-App Purchases) and Android (Google Play Billing) through a single server-to-server webhook — your app never parses StoreKit receipts or Play Billing tokens directly. ### Webhook endpoint [#webhook-endpoint-1] `POST /supabase_billing/webhooks/adapty` Always mounted (Adapty is webhook-only — there is no sync-engine equivalent). Register this path in the Adapty dashboard under your app's public origin: ``` https://app.example.com/supabase_billing/webhooks/adapty ``` ### Signing secret [#signing-secret-1] The endpoint verifies every request against Adapty's HMAC signature using `config.adapty_webhook_secret`, which the generated initializer pulls from `ENV["ADAPTY_WEBHOOK_SECRET"]`. Copy the signing secret Adapty shows you when you register the webhook in the dashboard. ``` ADAPTY_WEBHOOK_SECRET=******************************** ``` Requests without a valid signature are rejected with `400 Bad Request` and never reach the event processor. ### Mobile only — no StoreKit / Play Billing direct [#mobile-only--no-storekit--play-billing-direct] The Adapty adapter exists for in-app subscriptions purchased on iOS or Android. There is **no direct StoreKit integration** — your iOS app should not be parsing receipts, calling `Transaction.currentEntitlements`, or hitting Apple's `/verifyReceipt` endpoint, and your Android app should not be calling Google Play Billing's `BillingClient.queryPurchasesAsync` for entitlement decisions. Adapty does that work and forwards the normalized result to this webhook. If you need web checkout, use the Stripe adapter — Adapty is not a web billing layer. The only client-side wiring required is calling `Adapty.identify(supabaseUserId)` after Supabase sign-in so Adapty's `customer_user_id` matches `auth.users.id`. Once that's in place, the webhook adapter resolves every event to the right `billing_customers.user_id` without a server-side mapping table. ```swift // iOS — immediately after Supabase auth completes let session = try await supabase.auth.session Adapty.identify(session.user.id.uuidString) { error in if let error = error { /* report + retry */ } } ``` See [Webhooks](/reference/billing/webhooks) for the full Adapty dashboard registration walk-through. ### Supported event types [#supported-event-types-1] The Adapty event processor handles exactly these seven event types — anything else is logged and dropped. Untick the rest in the Adapty dashboard to avoid burning quota on events the gem ignores: * `subscription_started` * `subscription_renewed` * `subscription_cancelled` * `subscription_expired` * `subscription_refunded` * `non_subscription_purchase` * `access_level_updated` These drive the canonical `subscriptions.status` transitions (`active` / `trialing` / `cancelled` / `expired` / `refunded`) the same way Stripe's four events do — your app code never has to know which provider opened the subscription. ### Initializer snippet [#initializer-snippet-1] ```ruby # config/initializers/supabase_billing.rb Supabase::Billing.configure do |config| config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET") config.plan :pro, name: "Pro" do entitlement :ai_requests, value: 10_000 entitlement :projects, value: 100 # Adapty product IDs (App Store / Play Store SKUs) that map to this plan. adapty_products %w[com.example.pro.monthly com.example.pro.yearly] end end ``` A plan can declare both `stripe_prices` and `adapty_products` — that's the supported way to sell the same canonical plan on web and mobile. ## Both providers together [#both-providers-together] The most common configuration is "Stripe on web, Adapty on mobile, same plans on both." The initializer for that looks like: ```ruby # config/initializers/supabase_billing.rb Supabase::Billing.configure do |config| config.stripe_ingestion = :webhook config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET") config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET") config.plan :pro, name: "Pro" do entitlement :ai_requests, value: 10_000 entitlement :projects, value: 100 stripe_prices %w[price_1Nabc...] adapty_products %w[com.example.pro.monthly com.example.pro.yearly] end end ``` After editing the plan DSL, reconcile it into the database: ```bash bin/rails supabase_billing:sync ``` See [Getting Started](/reference/billing/getting-started) for the install flow that scaffolds this initializer, and [Webhooks](/reference/billing/webhooks) for the per-provider signature verification and idempotency contract. # Schema Reference (/reference/billing/schema) `supabase_billing:install` generates one migration that creates **twelve tables**: eight canonical tables that power the entitlement engine and four provider-mapping tables that translate Stripe and Adapty identifiers into canonical rows. Everything is UUID-keyed so primary keys line up with `auth.users.id` (a `billing_customers.user_id` is literally an `auth.users.id`), which is what makes the RLS policies one-liners. Every table uses `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`. The three user-owned tables — `billing_customers`, `subscriptions`, and `subscription_items` — ship with RLS policies scoped to `auth.uid()`, so the Supabase JS / iOS / Android SDKs (which connect with the anon key) can only see the signed-in user's billing rows. Plans, entitlements, and provider-mapping tables are not user-scoped — they're admin / sync data and stay un-RLS'd by the install generator. ## ER diagram [#er-diagram] `users` is the application's User model (created by `supabase-rails`); the rest are created by this gem. `provider_events` is a raw webhook log — it doesn't hold a foreign key to `provider_subscriptions`, but processing one usually creates or updates the other, which is what the dotted relationship in the diagram represents. ## Canonical tables [#canonical-tables] The eight tables below are the source of truth for who is subscribed to what, which entitlements that grants them, and how much of any metered limit they have used. ### `billing_customers` [#billing_customers] One row per User who has ever had a billing relationship with any provider. Unique on `user_id` so each `auth.users.id` maps to exactly one billing customer. Key columns: `id uuid`, `user_id uuid` (FK → `users.id`, unique), `created_at`, `updated_at`. ### `plans` [#plans] The product catalog as your app sees it. Each row is one offering (`free`, `pro`, `team_yearly`), independent of any provider's price/product IDs. Key columns: `id uuid`, `key string` (unique — what your code passes to the plan DSL), `name string`, `description text`, `archived_at datetime` (soft-archive when you stop offering a plan). ### `entitlements` [#entitlements] The capability catalog — every feature, quota, or flag your app gates on. `kind` is `"boolean"` (capability flag) or `"numeric"` (quota / metered limit). Key columns: `id uuid`, `key string` (unique — what `entitled?(:key)` and `limit(:key)` look up), `kind string`, `description text`. ### `plan_entitlements` [#plan_entitlements] The join table connecting plans to entitlements with a value. One row per (plan, entitlement) pair, unique on `(plan_id, entitlement_id)`. Key columns: `id uuid`, `plan_id uuid` (FK → `plans`), `entitlement_id uuid` (FK → `entitlements`), `value_numeric bigint` (set when `kind = "numeric"`), `value_boolean boolean` (set when `kind = "boolean"`). ### `subscriptions` [#subscriptions] One row per active or historical subscription on a `billing_customer`. The current entitlement set is whichever subscription is currently `status = "active"` or `"trialing"`. Key columns: `id uuid`, `billing_customer_id uuid` (FK), `plan_id uuid` (FK), `status string` (`active` | `trialing` | `past_due` | `cancelled` | `expired` | `refunded`), `source string` (`stripe` | `adapty`), `current_period_start`, `current_period_end`, `trial_end`, `cancelled_at`. ### `subscription_items` [#subscription_items] Line items inside a subscription — necessary because Stripe subscriptions can have multiple prices (e.g. a base plan + a metered add-on). Key columns: `id uuid`, `subscription_id uuid` (FK), `provider_price_id string` (mirrors Stripe's price ID for reconciliation), `quantity integer` (default `1`). ### `usage_limits` [#usage_limits] The "remaining budget" snapshot for metered entitlements, scoped to one subscription and one billing period. Lets `Current.user.remaining(:ai_requests)` answer in O(1) instead of summing every `usage_event` since the start of the period. Key columns: `id uuid`, `subscription_id uuid` (FK), `entitlement_id uuid` (FK), `value_numeric bigint` (cap for the period), `period_start datetime`, `period_end datetime`. Unique on `(subscription_id, entitlement_id, period_start)`. ### `usage_events` [#usage_events] The append-only log of metered consumption. `record_usage(:key, amount:, recorded_at:)` writes here; `usage(:key)` and `remaining(:key)` read here. Key columns: `id uuid`, `billing_customer_id uuid` (FK), `entitlement_id uuid` (FK), `amount bigint` (positive consumed, negative for credits / refunds), `recorded_at datetime`. ## Provider-mapping tables [#provider-mapping-tables] The four tables below are the only place provider-specific IDs live. Every webhook handler writes through them so the rest of the schema stays vendor-agnostic. ### `provider_customers` [#provider_customers] Maps `billing_customers` to the provider's customer identifier (Stripe `cus_…`, Adapty `profile_id`). Unique on `(provider, provider_customer_id)`. Key columns: `id uuid`, `billing_customer_id uuid` (FK), `provider string` (`stripe` | `adapty`), `provider_customer_id string`. ### `provider_subscriptions` [#provider_subscriptions] Maps `subscriptions` to the provider's subscription identifier (Stripe `sub_…`, Adapty access-level grant). `subscription_id` is nullable so we can record a provider subscription that hasn't been linked to a canonical row yet. `raw_data jsonb` keeps the full last-seen payload for debugging. Key columns: `id uuid`, `subscription_id uuid` (FK, nullable), `provider string`, `provider_subscription_id string`, `raw_status string`, `raw_data jsonb`. Unique on `(provider, provider_subscription_id)`. ### `provider_products` [#provider_products] Maps `plans` to provider price/product IDs (Stripe `price_…` / `prod_…`, Adapty product IDs). One plan can have many provider products — that's how the same `pro` plan can be sold via three different Stripe prices and one Adapty product. Key columns: `id uuid`, `plan_id uuid` (FK), `provider string`, `provider_product_id string`. Unique on `(provider, provider_product_id)`. ### `provider_events` [#provider_events] Raw webhook log — every event a provider sent us, even ones we ignored. Idempotency comes from the unique index on `(provider, provider_event_id)`: a retried Stripe webhook is recognised and short-circuited before any side-effects run. Key columns: `id uuid`, `provider string`, `provider_event_id string`, `event_type string`, `payload jsonb`, `received_at datetime`, `processed_at datetime` (NULL until processed; non-NULL guarantees we won't re-process). ## RLS policies [#rls-policies] The install migration enables RLS on the three user-owned tables and creates a single `SELECT` policy on each: | Table | Policy | Predicate | | -------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `billing_customers` | `billing_customers_select_own` | `user_id = auth.uid()` | | `subscriptions` | `subscriptions_select_own` | `billing_customer_id IN (SELECT id FROM billing_customers WHERE user_id = auth.uid())` | | `subscription_items` | `subscription_items_select_own` | `subscription_id IN (SELECT s.id FROM subscriptions s JOIN billing_customers bc ON bc.id = s.billing_customer_id WHERE bc.user_id = auth.uid())` | Writes are not RLS-gated because they happen from the Rails side (webhook controllers, the install generator, and `bin/rails supabase_billing:sync`) using the service role connection. The anon key cannot insert or update billing rows. If you want to opt out — for example because you're not using the Supabase JS SDK against this database — pass `--skip-rls` to the install generator and the `ENABLE ROW LEVEL SECURITY` block is omitted from the migration. ## Indexes [#indexes] The migration creates the indexes that the entitlement engine and webhook adapters actually use: * `billing_customers (user_id)` — unique. One billing customer per user. * `plans (key)` — unique. Plan DSL lookups. * `entitlements (key)` — unique. `entitled?` / `limit` lookups. * `plan_entitlements (plan_id, entitlement_id)` — unique. One value per pair. * `subscriptions (billing_customer_id, status)` — finding the active subscription for a user. * `usage_limits (subscription_id, entitlement_id, period_start)` — unique. One budget row per period. * `usage_events (billing_customer_id, entitlement_id, recorded_at)` — usage rollups. * `provider_customers (provider, provider_customer_id)` — unique. Webhook dispatch. * `provider_subscriptions (provider, provider_subscription_id)` — unique. Webhook dispatch. * `provider_products (provider, provider_product_id)` — unique. Webhook dispatch. * `provider_events (provider, provider_event_id)` — unique. Idempotency. * `provider_events (provider, processed_at)` — finding un-processed events for retry. ## Where this comes from [#where-this-comes-from] The full migration lives at `db/migrate/_create_supabase_billing_schema.rb` after you run the install generator, mirroring `lib/generators/supabase_billing/install/templates/db/migrate/create_supabase_billing_schema.rb.tt` in the gem. The companion `Billing::*` model files under `app/models/billing/` declare the ActiveRecord associations the diagram above is built from. # Webhooks (/reference/billing/webhooks) `supabase-billing` ships exactly two HTTP endpoints, both `POST`, both mounted under the engine's `/supabase_billing` namespace: * `POST /supabase_billing/webhooks/stripe` — mounted in Stripe `:webhook` mode (default). * `POST /supabase_billing/webhooks/adapty` — always mounted; Adapty is webhook-only. Each endpoint verifies the provider's signature, stores the raw payload in `provider_events` for replay and debugging, hands the parsed event to a per-provider `EventProcessor`, and responds `200` with `{ "received": true }`. Everything you need to know to operate, observe, and debug those two endpoints in production is below. Before pointing a real Stripe or Adapty webhook at a deployed app, confirm all four: * **HTTPS only.** Stripe and Adapty refuse to deliver webhooks to non-TLS endpoints in their production environments. Expose `/supabase_billing/webhooks/stripe` and `/supabase_billing/webhooks/adapty` over HTTPS — terminate TLS at your load balancer, never at the Rails process — and use plain HTTP only for local development behind a tunneling tool that re-presents the endpoint over HTTPS. * **Signing secrets in env, not source.** `STRIPE_WEBHOOK_SECRET` and `ADAPTY_WEBHOOK_SECRET` belong in your deployment's secret store, not in `config/supabase_billing.yml` or any committed file. The generated initializer reads them via `ENV.fetch`, which fails loudly if a secret is missing rather than silently disabling verification. * **Never log raw webhook payloads.** Webhook bodies contain provider IDs, prices, and (on Adapty) the Supabase `auth.users.id` UUID. Don't put `request.body` or `params` into your Rails logger; the gem writes the parsed payload to the `provider_events.payload` jsonb column, which is the supported audit trail. If you need to inspect a specific event during an incident, query the column directly — don't grep production logs. * **One secret per environment.** Use a different signing secret for staging vs. production, and rotate immediately if a secret is ever printed to a log line, posted in chat, or shipped in a deploy artifact. Both providers let you rotate without downtime — register the new secret, deploy, then revoke the old one. ## Endpoints [#endpoints] ### `POST /supabase_billing/webhooks/stripe` [#post-supabase_billingwebhooksstripe] Mounted by `Supabase::Billing::Engine` when `config.stripe_ingestion = :webhook` (the default). The route is **not** registered when `config.stripe_ingestion = :sync_engine` — `config/routes.rb` wraps the route declaration in a conditional, so the two modes are mutually exclusive by construction. Pointing a Stripe webhook at an app running in `:sync_engine` mode returns `404`; switch the app back to `:webhook` mode or repoint the webhook. Status codes: * `200 OK` + `{ "received": true }` — signature verified, payload parsed, event handed to the processor (including for event types the processor doesn't handle, which are logged and dropped). * `400 Bad Request` — signature verified but the body isn't valid JSON. * `401 Unauthorized` — signature missing, malformed, or doesn't match the configured secret. The path is rooted at whatever mount point you chose in `config/routes.rb` (the generator picks `/supabase_billing`). With the default mount the full URL Stripe should call is: ``` https://app.example.com/supabase_billing/webhooks/stripe ``` ### `POST /supabase_billing/webhooks/adapty` [#post-supabase_billingwebhooksadapty] Always mounted — there is no sync-engine equivalent for Adapty. Adapty's server-to-server webhook is the only supported ingestion path for mobile in-app subscriptions. With the default mount the URL Adapty should call is: ``` https://app.example.com/supabase_billing/webhooks/adapty ``` Status codes are the same as Stripe: `200` on success / ignored event types, `400` on invalid JSON, `401` when the `Authorization` header is missing or doesn't match the configured secret. ## Signature verification [#signature-verification] Both adapters verify in-gem rather than depending on the official `stripe` / `adapty` Ruby gems — that keeps `supabase-billing` dependency-free at runtime. The verification code lives in `lib/supabase/billing/adapters//signature_verifier.rb` and is invoked from the controller before any JSON parsing. Stripe sends a `Stripe-Signature` header on every webhook request, formatted as: ``` Stripe-Signature: t=1700000000,v1= ``` The adapter (see `Supabase::Billing::Adapters::Stripe::SignatureVerifier`) does the following per request: 1. Parse `t=` and one or more `v1=` entries from the header. 2. Reject (`401`) if `t` is older than **5 minutes** (`DEFAULT_TOLERANCE = 300`), matching Stripe's documented replay window. 3. Compute `HMAC-SHA256(secret, "#{t}.#{raw_body}")` and compare against each `v1` value in **constant time** via `OpenSSL.fixed_length_secure_compare`. 4. Raise `SignatureVerificationError` if no `v1` matches. The signing secret comes from `config.stripe_webhook_secret`, which the generated initializer reads from `ENV["STRIPE_WEBHOOK_SECRET"]` (the `whsec_...` value Stripe shows you when you create the webhook). A blank or missing secret is itself a `SignatureVerificationError` — the endpoint never silently degrades to "accept everything." Common reasons for a `401`: * `STRIPE_WEBHOOK_SECRET` set to the wrong env's value (staging secret deployed to prod). * A proxy or middleware that mutates the request body before it reaches Rails (HMAC is computed over the *exact* bytes Stripe signed — re-serializing JSON anywhere upstream will invalidate the signature). * Clock skew on the Rails host. If `t` differs from `Time.now` by more than 5 minutes, verification fails even for an otherwise-correct signature. NTP must be healthy on production hosts. Adapty's server-to-server webhook scheme is a shared secret passed verbatim in the `Authorization` header — there is no HMAC over the body. The adapter (see `Supabase::Billing::Adapters::Adapty::SignatureVerifier`) does a single fixed-length constant-time comparison via `OpenSSL.fixed_length_secure_compare`: ``` Authorization: ``` A blank or missing `config.adapty_webhook_secret` is a `SignatureVerificationError` — the endpoint never accepts unsigned requests. The signing secret comes from `ENV["ADAPTY_WEBHOOK_SECRET"]` via the generated initializer. Because Adapty does not HMAC the payload, **the secret is the only thing standing between an attacker and a forged event**. Treat it with the same care you would a database password: rotate immediately if it leaks, never commit it, and don't print it anywhere your logging infrastructure retains. Common reasons for a `401`: * `ADAPTY_WEBHOOK_SECRET` set to the wrong env's value, or to the *display* value rather than the secret value from the Adapty dashboard. * An upstream proxy stripping or rewriting the `Authorization` header. Some CDNs strip `Authorization` by default on cached paths — verify your routing layer passes it through unmodified. ## The `provider_events` table and idempotency model [#the-provider_events-table-and-idempotency-model] Every signature-verified webhook — handled or ignored — is recorded in `provider_events` **before** the event processor inspects the event type. That gives you a complete audit trail of what each provider sent, decoupled from whether the gem currently knows how to act on it. ### Schema [#schema] The migration generated by `supabase_billing:install` creates the table as: ```ruby create_table :provider_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t| t.string :provider, null: false # "stripe" or "adapty" t.string :provider_event_id, null: false # the provider's own event id t.string :event_type, null: false # e.g. "customer.subscription.created" t.jsonb :payload, null: false, default: {} t.datetime :received_at, null: false t.datetime :processed_at # set when the processor finishes t.timestamps end add_index :provider_events, %i[provider provider_event_id], unique: true add_index :provider_events, %i[provider processed_at] ``` Key things to know: * `(provider, provider_event_id)` is **unique**. That index is the gem's idempotency primitive — Stripe and Adapty both retry on non-2xx responses, and the unique constraint guarantees that no matter how many duplicate deliveries hit the endpoint, you end up with exactly one row. * `payload` is `jsonb` (defaults to `{}`) and stores the parsed event Hash as the provider sent it. This is the supported audit trail — query this column, don't grep production logs. * `received_at` is set the first time a delivery for a given `provider_event_id` is seen. It is **not** reset on subsequent duplicate deliveries. * `processed_at` is set once the event handler runs to completion (including for ignored event types). A row with `processed_at IS NULL` is one that was stored but never finished processing — useful for diagnosing crashes mid-handler. * The `(provider, processed_at)` index supports the "what's stuck?" query: `Billing::ProviderEvent.where(provider: "stripe", processed_at: nil)`. ### Adapty: when no `event_id` is supplied [#adapty-when-no-event_id-is-supplied] Stripe always sends an `id` field on its top-level event payload. Adapty supplies an `event_id` on its server-to-server webhooks in normal operation — but the adapter defensively handles the case where it's absent by synthesizing a deterministic id: ``` adapty_synth_ ``` Same event in → same synthetic id out, so the unique index still gives you idempotency even on payloads where Adapty omits `event_id`. ### Idempotency in practice [#idempotency-in-practice] Because storage happens via `find_or_initialize_by(provider:, provider_event_id:)`, replaying the same delivery (or having Stripe / Adapty retry after a transient outage) is safe end-to-end: * Duplicate delivery → `provider_events` already has the row → no INSERT happens, only `event_type` / `payload` / `processed_at` are re-written. * Canonical-table writes (`subscriptions`, `provider_subscriptions`, `billing_customers`) are also `find_or_initialize_by` upserts keyed off provider IDs, so the second delivery is a no-op on the canonical side too. You do **not** need an idempotency key, a Redis lock, or a dedupe job in your host app. The unique index plus the upsert pattern is the entire mechanism. ## Replay and retry behavior [#replay-and-retry-behavior] ### What the providers do [#what-the-providers-do] * **Stripe** retries any non-2xx response on an exponential backoff over up to 3 days (per Stripe's documented retry policy). The retries carry the same `id` field, so they hit the same `(provider, provider_event_id)` unique constraint and dedupe cleanly. * **Adapty** retries non-2xx responses with its own backoff; the retried delivery carries the same `event_id` (or, when absent, the same payload, which the synthetic-id scheme above hashes to the same value). The contract on your side is simple: **return `2xx` only if you're willing to mark the event as handled**. The controllers return `401` for bad signatures and `400` for un-parseable JSON specifically so the provider keeps retrying until you fix the misconfiguration — silently 200-ing those would lose events. ### Replaying an event by hand [#replaying-an-event-by-hand] Operationally, every signature-verified delivery is in `provider_events.payload` as parsed JSON. To re-run a single event through the processor — e.g. after fixing a bug in a custom subclass, or after recovering from a host-app exception that aborted the original handler — hand the payload back to the processor: ```ruby event = Billing::ProviderEvent.find_by!( provider: "stripe", provider_event_id: "evt_1Nabc..." ) Supabase::Billing::Adapters::Stripe::EventProcessor.new.call(event.payload) ``` The processor is idempotent across the board — upserting provider\_subscriptions and the canonical subscription is a no-op when state already matches, so replaying a successful event won't double-charge, double-cancel, or otherwise corrupt state. The same shape works for Adapty: ```ruby event = Billing::ProviderEvent.find_by!( provider: "adapty", provider_event_id: "" ) Supabase::Billing::Adapters::Adapty::EventProcessor.new.call(event.payload) ``` ### Manual retry on the provider side [#manual-retry-on-the-provider-side] Both Stripe and Adapty's dashboards expose a "resend webhook" button for individual events — that's the right tool when you want the provider to re-send a specific event (e.g. after rotating a webhook secret and missing a delivery during the rotation window). The retry hits the live endpoint, goes through signature verification, and dedupes against `provider_events` the same as any other delivery. ### Replaying a range during recovery [#replaying-a-range-during-recovery] If your endpoint was down or returning 5xx for an extended window, query `provider_events` to identify which deliveries you actually received: ```ruby Billing::ProviderEvent .where(provider: "stripe", processed_at: nil) .where(received_at: 1.hour.ago..) .find_each do |evt| Supabase::Billing::Adapters::Stripe::EventProcessor.new.call(evt.payload) end ``` For events Stripe / Adapty *attempted* to deliver but you never received (full outage, DNS failure), the provider's dashboard is the source of truth — use its bulk-resend feature or its event-list API, and rely on `provider_events` to dedupe whatever comes back through the live endpoint. ## Debugging checklist [#debugging-checklist] When a webhook isn't doing what you expect, walk down the list: 1. **Did the delivery reach Rails?** Check your load balancer / Rails access log for a `POST /supabase_billing/webhooks/`. If it's not there, the failure is upstream — DNS, firewall, or the provider didn't fire the event. 2. **Did signature verification pass?** A `401` in the response log means the controller rejected the delivery before any DB write. Re-check the env var, the dashboard secret, and (for Stripe) host clock skew. 3. **Did it land in `provider_events`?** `Billing::ProviderEvent.where(provider: "stripe").order(received_at: :desc).first` should be the most recent delivery. If `processed_at IS NULL`, the handler ran into a host-app exception — your Rails error reporter (Sentry / Honeybadger / etc.) should have the backtrace. 4. **Was the event type one the processor handles?** See [Providers](/reference/billing/providers) for the supported event lists (four for Stripe, seven for Adapty). Unsupported types are stored in `provider_events` and silently dropped — that's not a bug, but it's not the same as "handled." 5. **Did the canonical row update?** If the event type is supported and the processor ran, but `subscriptions` / `billing_customers` didn't move, the usual cause is a missing mapping — an unmapped Stripe price ID, an Adapty `customer_user_id` that doesn't match any `auth.users.id`. Both are logged at `info` with the specific ID that didn't resolve. See [Providers](/reference/billing/providers) for the per-adapter event-type lists and dashboard registration steps, [Schema Reference](/reference/billing/schema) for the rest of the tables that webhooks write to, and [Entitlements](/reference/billing/entitlements) for how the canonical subscription state turns into the `entitled?` answer your controllers actually call. # Supabase Rails (supabase-rails) (/reference/rails) `supabase-rails` is the Ruby on Rails integration for [Supabase](https://supabase.com) — `supabase-rb` on Rails, packaged as a Railtie with built-in Rails Supabase auth. It wires the [`supabase-rb`](/reference/ruby) client into Rails, ships a drop-in [`Authentication` concern](/reference/rails/authentication) with [controllers](/reference/rails/controllers), [views](/reference/rails/views), and [generators](/reference/rails/generators) for sign-in, sign-up, OAuth, password reset, and OTP, and provides an [encrypted-cookie session store](/reference/rails/web-mode) so browser sessions Just Work without a separate user table. Use this Supabase Rails gem when you want first-party Rails Supabase auth without bolting `supabase-js` onto the front end, or when your Rails-8 app already expects the `bin/rails generate authentication` shape and you want Supabase as the identity provider behind it. `supabase-rails` handles identity. For plans, subscriptions, and `Current.user.entitled?(:feature)` gating on top of it — Stripe and Adapty included — see [`supabase-billing`](/reference/billing). The [Starter Kits](/reference/starterkits) are production-ready Rails 8 apps pre-wired with `supabase-rails` — pick the API, Hotwire, or Inertia + React shape, clone it, and skip the manual install steps below. ## Start here [#start-here] ## Explore the reference [#explore-the-reference] ## Project [#project] * Source: [`supabase-community/supabase-rails`](https://github.com/supabase-community/supabase-rails) * Ruby client reference: [`supabase-rb` docs](/reference/ruby) * Issues: the gem's issue tracker # Supabase Ruby (supabase-rb) (/reference/ruby) `supabase-rb` is the Ruby client for [Supabase](https://supabase.com) and works in any Ruby or Ruby on Rails app. It bundles every Supabase product — Auth (GoTrue), Database (PostgREST), Realtime, Storage, and Edge Functions — behind a single `Supabase::Client`. ## Quickstart [#quickstart] Add the gem to your `Gemfile`: ```ruby # Gemfile gem "supabase-rb" ``` Then install: ```bash bundle install ``` Construct a client and run a first query: ```ruby require "supabase" supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) response = supabase.from("countries").select("*").limit(1).execute puts response.data # => [{"id" => 1, "name" => "United Kingdom"}] ``` `SUPABASE_URL` is your project URL (e.g. `https://abcd1234.supabase.co`); `SUPABASE_ANON_KEY` is the anon key from your project's API settings. Requires Ruby ≥ 3.1. For the full constructor surface — `ClientOptions`, schema selection, async mode, the legacy nested-hash form — see [Initializing](/reference/ruby/initializing). ## Explore the reference [#explore-the-reference] ## Project [#project] * Source: [`supabase-community/supabase-rb`](https://github.com/supabase-community/supabase-rb) * Issues: the gem's issue tracker # Starter Kits (/reference/starterkits) The starter kits are opinionated Rails 8 applications pre-wired with [`supabase-rails`](/reference/rails) so you can clone, set two environment variables, and start building features instead of plumbing. Each kit is a real Rails app — not a generator output — so you can read the controllers, layouts, and config to see exactly how `supabase-rails` is wired into a working app. The three kits differ only in the *shape* of the frontend: a JSON API for mobile or SPA clients, a server-rendered Hotwire monolith, or an Inertia-driven React frontend served from the same Rails process. Pick the one that matches the app you want to ship — the Supabase integration is the same underneath. ## Compare the kits [#compare-the-kits] | | [Rails API](/reference/starterkits/api) | [Hotwire monolith](/reference/starterkits/hotwire) | [Inertia + React](/reference/starterkits/inertia-react) | | ------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | **Rendering model** | JSON API — no views, JWT on every request | Server-rendered HTML with Hotwire (Turbo + Stimulus) | Server-driven SPA — Rails controllers return Inertia responses, React renders | | **Frontend stack** | None — bring your own mobile or SPA client | ERB + ViewComponent + Tailwind v4 + Railsblocks | React 19 + TypeScript + Vite + shadcn/ui | | **`supabase-rails` mode** | `:api` — JWT verified per request from `Authorization: Bearer` | `:web` — encrypted cookie session, no tokens in the client | `:web` — encrypted cookie session shared with the React layer | | **When to choose it** | You're shipping a mobile app or third-party SPA and want Rails to be the JSON backend | You want a productive server-rendered Rails app with real-time UI and no JS build pipeline | You want a typed React frontend without standing up a separate API tier | | **Key dependencies** | Rails 8, `supabase-rails`, `rswag`, `rack-attack`, `rack-cors`, Kamal | Rails 8.1, `supabase-rails`, ViewComponent, `tailwindcss-rails`, `lucide-rails`, Importmap | Rails 8, `supabase-rails`, `inertia_rails`, `vite_rails`, React, shadcn/ui, Kamal | ## Pick a kit [#pick-a-kit] # Authentication (/reference/rails/authentication) `Supabase::Rails::Authentication` is the Rails-8-shape concern hosts mix into `ApplicationController` to get a Supabase-backed `current_user`, `authenticated?`, and `require_authentication` surface. It is the concern the `bin/rails generate supabase:install` generator wires in via [`app/controllers/concerns/authentication.rb`](/reference/rails/generators/install) and the only public Auth API your controllers and views should reach for. This page is the one-stop reference for the concern's public surface: every method it installs on a controller, the `Current.user` / `Current.session` attributes it writes, the `Supabase::Rails::User` value object that backs `current_user` by default, and the `supabase_context` request object the surrounding middleware places on `request.env`. ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base include Authentication end # app/controllers/posts_controller.rb class PostsController < ApplicationController def index @posts = Current.user.posts end end ``` ## The generated `Authentication` concern [#the-generated-authentication-concern] `bin/rails generate supabase:install` writes a 7-line concern at `app/controllers/concerns/authentication.rb`: ```ruby # frozen_string_literal: true module Authentication extend ActiveSupport::Concern included do include Supabase::Rails::Authentication end end ``` The host's `ApplicationController` then `include`s `Authentication`, which transitively includes `Supabase::Rails::Authentication`. The thin wrapper exists so host apps can add app-specific `before_action`s, helper methods, or override hooks alongside the gem-provided ones without forking the gem. Including the concern runs four installation steps on the host class: ```ruby included do before_action :require_authentication before_action :populate_current_attributes helper_method :authenticated? helper_method :current_user if Supabase::Rails::Authentication.expose_current_user? end ``` 1. **`before_action :require_authentication`** — every action requires a signed-in user unless explicitly opted out via [`allow_unauthenticated_access`](#allow_unauthenticated_access). 2. **`before_action :populate_current_attributes`** — runs after `require_authentication` and writes `Current.user` / `Current.session` from `supabase_context`, including on actions that opted out (so anonymous pages can still read `Current.user` if a session happens to be present). 3. **`helper_method :authenticated?`** — `authenticated?` is callable from views. 4. **`helper_method :current_user`** — `current_user` is callable from views when [`config.supabase.expose_current_user`](/reference/rails/configuration#expose_current_user) resolves to `true` (the default in `:web` mode). `Supabase::Rails::Authentication` is included on the host's `ApplicationController` (via the generated `Authentication` module). Every subclass — including the gem's built-in `Supabase::Rails::SessionsController`, `RegistrationsController`, and friends — inherits the surface. Do **not** include `Supabase::Rails::Authentication` directly in individual controllers; doing so re-runs `before_action :require_authentication` and re-registers the helpers. ## Helper reference [#helper-reference] Every method below is an instance method on any controller that includes the `Authentication` concern. Two are also registered as `helper_method`s so views can call them directly. ### `authenticated?` [#authenticated] | Returns | Helper method | | --------- | ------------- | | `Boolean` | yes (always) | True when `Current.user` is present — the Rails-8-shape replacement for Devise's `signed_in?` / `user_signed_in?`. Reads `Current.user.present?`, so it is `false` both for genuinely anonymous requests and for requests where `populate_current_attributes` has not yet run (e.g. before any `before_action`). ```erb <%# app/views/layouts/application.html.erb %> <% if authenticated? %> <%= link_to "Sign out", session_path, data: { turbo_method: :delete } %> <% else %> <%= link_to "Sign in", new_session_path %> <% end %> ``` ```ruby class PostsController < ApplicationController def index if authenticated? @posts = Current.user.posts else @posts = Post.public_only end end end ``` ### `current_user` [#current_user] | Returns | Helper method | | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | [`Supabase::Rails::User`](#supabaserailsuser-value-object) value object, or the configured `user_model` AR record, or `nil` | when `config.supabase.expose_current_user` resolves to `true` | Delegates to `Current.user`. The shape depends on whether [`config.supabase.user_model`](/reference/rails/configuration#user_model) is set: * **`user_model` unset (default)** — `current_user` is a frozen `Supabase::Rails::User` value object built from the verified JWT claims. * **`user_model` set** — `current_user` is the `ActiveRecord` row returned by `.from_supabase(claims)` (see [`supabase:user_model`](/reference/rails/generators/user-model)). Whether views can call `current_user` is controlled by [`config.supabase.expose_current_user`](/reference/rails/configuration#expose_current_user) — `true` in `:web` mode by default, `false` in `:api` mode so it does not clash with API hosts that define their own `current_user`. ```erb <%# app/views/dashboard/show.html.erb — web mode, expose_current_user resolves true %>

Signed in as <%= current_user.email %>.

``` ```ruby class CommentsController < ApplicationController def create comment = Current.user.comments.create!(comment_params) redirect_to comment.post end end ``` In controllers, prefer `Current.user` — it works regardless of `expose_current_user` and is identical to what views see. The `current_user` helper exists for Devise muscle memory and for templates. When a host app defines its own `current_user` on a parent controller, Ruby's method lookup picks the host's first, so the gem's helper does not shadow customisations. ### `require_authentication` [#require_authentication] | Returns | Helper method | | ------------------------------------------------------------ | ------------- | | Truthy on success; calls `request_authentication` on failure | no | The `before_action` installed automatically on every controller that includes the concern. Tries to resume the session from `supabase_context` (which the middleware populated on the way in) and writes `Current.user` if a verified session is present. When no session is present, calls `request_authentication` to redirect (`:web`) or 401 (`:api`). Hosts almost never call this directly. Use [`allow_unauthenticated_access`](#allow_unauthenticated_access) to opt specific actions out. ### `allow_unauthenticated_access(only:, except:)` [#allow_unauthenticated_accessonly-except] Class macro. Skips the `:require_authentication` before-action on the named actions — the Rails-8-shape replacement for Devise's `skip_before_action :authenticate_user!`. Accepts the same `only:` / `except:` options Rails' `skip_before_action` does. ```ruby class SessionsController < ApplicationController allow_unauthenticated_access only: %i[new create] def new; end def create # sign-in flow end def destroy # require_authentication still applies here end end ``` ```ruby class HomeController < ApplicationController allow_unauthenticated_access only: :index def index; end end ``` Internally: ```ruby def self.allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options end ``` `populate_current_attributes` still runs on skipped actions, so `Current.user` / `Current.session` are populated when a session cookie happens to be present — useful for landing pages that show a "Welcome back" banner to signed-in visitors. ### `before_action :require_authentication` [#before_action-require_authentication] The "everything requires auth by default" stance is the Rails-8 default — opposite of Devise, where `authenticate_user!` is opt-in per controller. If you prefer Devise-style "opt in to auth", you have two options: ```ruby # Option A — skip auth at the parent level and re-require it per controller. class ApplicationController < ActionController::Base include Authentication skip_before_action :require_authentication end class DashboardController < ApplicationController before_action :require_authentication end ``` ```ruby # Option B — name the actions that DON'T need auth. class HomeController < ApplicationController allow_unauthenticated_access only: %i[index about pricing] end ``` Option B is the recommended default — the gem's controllers (`SessionsController`, `RegistrationsController`, etc.) all use `allow_unauthenticated_access`, and forgetting to gate a private action is the more dangerous failure mode. ### `start_new_session_for(supabase_session)` [#start_new_session_forsupabase_session] | Returns | Helper method | | ------------------------------- | ------------- | | The supplied `supabase_session` | no | Persists a Supabase session in the encrypted `sb-session` cookie and populates `Current.user` / `Current.session`. Called by `Supabase::Rails::SessionsController#create` after `authenticate_with_supabase` returns a session. Hosts only call it directly when building custom sign-in flows (e.g. exchanging a third-party identity for a Supabase session). ```ruby class CustomSignInController < ApplicationController allow_unauthenticated_access only: :create def create if (session = authenticate_with_supabase(email: params[:email], password: params[:password])) start_new_session_for(session) redirect_to dashboard_path else render :new, status: :unauthorized end end end ``` In `:api` mode, raises `Supabase::Rails::ConfigError(API_MODE_COOKIE_UNSUPPORTED)` — cookies do not apply, and API clients are expected to send the JWT via `Authorization: Bearer`. ### `terminate_session(scope: :local)` [#terminate_sessionscope-local] | Returns | Helper method | | ------- | ------------- | | `nil` | no | Signs the user out. Calls `supabase-rb`'s [`auth.sign_out(scope:)`](/reference/ruby/auth/signout) upstream (best-effort — failures are rescued because the local cookie clear is the source of truth), then clears the `sb-session` cookie and resets `Current.user` / `Current.session` to `nil`. `scope:` is forwarded verbatim to Supabase Auth (`:local`, `:global`, or `:others`). ```ruby class SessionsController < ApplicationController def destroy terminate_session redirect_to root_path end end ``` In `:api` mode, this is a local no-op — there is no cookie to clear and the client drops the JWT on its side. ### `authenticate_with_supabase(email:, password:)` [#authenticate_with_supabaseemail-password] | Returns | Helper method | | ------------------------------------------------------------------- | ------------- | | `Supabase::Auth::Types::Session` on success, `nil` on a 4xx failure | no | The Rails-8 parity entry point that mirrors `User.authenticate_by(email:, password:)`. Wraps `supabase-rb`'s [`auth.sign_in_with_password`](/reference/ruby/auth/signinwithpassword) and translates the result: returns the upstream session struct on success so the caller can pass it to `start_new_session_for`, `nil` on a 4xx authentication failure (so the controller can render "Invalid credentials"), and raises only on a 5xx upstream error. Used by the gem's stock `Supabase::Rails::SessionsController#create`. ```ruby def create if (session = authenticate_with_supabase(email: params[:email], password: params[:password])) start_new_session_for(session) redirect_to after_authentication_url else flash.now[:alert] = "Invalid credentials" render :new, status: :unauthorized end end ``` The bcrypt-imposed 72-byte password ceiling applies — keep `maxlength: 72` on the `password_field` in any custom sign-in form. ### Override hooks [#override-hooks] Four instance methods are deliberately overridable so hosts can change the redirect destination, store location behaviour, or the way unauthenticated requests are surfaced. Redefine any of them on a parent controller (or the host's `Authentication` concern wrapper). | Hook | Default | What it does | | ------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `request_authentication` | `redirect_to new_session_path` (`:web`) / `head :unauthorized` (`:api`) | Called by `require_authentication` when no session is present. Override to redirect somewhere other than the sign-in page (e.g. a paywall, a "you've been signed out" interstitial). | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | URL `SessionsController#create` redirects to after a successful sign-in. Override to send users to a per-role dashboard. | | `store_location_for_redirect` | `session[:return_to_after_authenticating] = request.url` (only for `GET` requests) | Stashes the requested URL before redirecting to sign-in, so the user lands on what they originally asked for. Override to scope the stash by host or path. | | `stored_location_for_redirect` | `session.delete(:return_to_after_authenticating)` | Reads (and consumes) the stash. | ```ruby class ApplicationController < ActionController::Base include Authentication private def after_authentication_url Current.user.admin? ? admin_root_url : dashboard_url end end ``` ## The `Current` model [#the-current-model] `bin/rails generate supabase:install` writes `app/models/current.rb`: ```ruby # frozen_string_literal: true class Current < ActiveSupport::CurrentAttributes attribute :user, :session end ``` `ActiveSupport::CurrentAttributes` gives you a thread-local, per-request store that Rails resets between requests automatically — the canonical Rails-8 home for "who is the current user". The `Authentication` concern populates two attributes: | Attribute | Type | When written | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Current.user` | `Supabase::Rails::User` value object (default), or the `user_model` AR record when configured, or `nil` | Set by `populate_current_attributes` (after `require_authentication`) and by `start_new_session_for` (on sign-in). Cleared by `terminate_session`. | | `Current.session` | The verified JWT claims `Hash` on the resume path, or the upstream `Supabase::Auth::Types::Session` struct after `start_new_session_for` | Set by `populate_current_attributes` from `supabase_context.jwt_claims`, or by `start_new_session_for` from the upstream session struct. Cleared by `terminate_session`. | ```ruby class PostsController < ApplicationController def create post = Current.user.posts.create!(post_params) redirect_to post end end ``` ```erb <%# Available in views without an explicit helper — Current is autoloaded. %>

Welcome back, <%= Current.user.email %>!

``` Hosts often add app-specific attributes alongside `:user` and `:session` — e.g. `:account`, `:request_id`, `:locale`. Extend the generated `Current` model directly: ```ruby class Current < ActiveSupport::CurrentAttributes attribute :user, :session, :account, :request_id end ``` The gem only reads and writes `:user` and `:session`. Everything else is yours to populate from your own before-actions. ## `Supabase::Rails::User` value object [#supabaserailsuser-value-object] When [`config.supabase.user_model`](/reference/rails/configuration#user_model) is unset (the default), `Current.user` is an immutable `Supabase::Rails::User` value object built from the verified JWT claims. Defined in `lib/supabase/rails/user.rb`: ```ruby class User < Data.define(:id, :email, :role, :app_metadata, :user_metadata, :raw) def self.from_claims(claims) claims = {} unless claims.is_a?(Hash) new( id: claims["sub"], email: claims["email"], role: claims["role"], app_metadata: claims["app_metadata"], user_metadata: claims["user_metadata"], raw: claims ) end end ``` | Attribute | Source claim | Description | | --------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | | `id` | `sub` | The Supabase user UUID. Stable across sessions; safe to use as a foreign key. | | `email` | `email` | The user's email address. | | `role` | `role` | The Postgres role the JWT was issued for (`authenticated`, `anon`, or a custom role). | | `app_metadata` | `app_metadata` | Provider-managed metadata (`provider`, `providers`). | | `user_metadata` | `user_metadata` | User-supplied metadata — what `data:` sets on sign-up and what `supabase_update_user(data: ...)` writes. | | `raw` | the full claims `Hash` | Every other claim verbatim, including custom claims you added via `before_user_created` hooks or the JWT template. | `Supabase::Rails::User` is a `Data.define` value, so: * Instances are frozen — attempting to mutate raises `FrozenError`. * Two `User`s with the same attributes are `==`. * Pattern-matching works directly: `case Current.user in {role: "service_role"}`. ```ruby Current.user.id # => "9e7d2..." Current.user.email # => "alice@example.com" Current.user.user_metadata # => { "full_name" => "Alice" } Current.user.raw["aud"] # => "authenticated" ``` The value object is the right default — zero DB writes, no migrations, and authentication works out of the box. Reach for [`supabase:user_model`](/reference/rails/generators/user-model) when you need per-user domain data (`belongs_to :user`, scopes, validations) or want a queryable `users` table for analytics. After running that generator, `Current.user` is the AR record and `supabase_context.current_user` stays `nil` (the gem skips the value-object build so the AR row is the single source of truth). When you need the full `Supabase::Auth::Types::User` (every field Supabase Auth knows about — including `phone`, `confirmed_at`, identities, MFA factors), call `supabase_context.supabase.auth.get_user` from a controller. That is an extra `auth/v1/user` round-trip, so use it only when the JWT claims do not carry the field you need. ## The `supabase_context` request object [#the-supabase_context-request-object] The Rack middleware (`Supabase::Rails::Middleware`) verifies the request's credentials once per request and hangs a `SupabaseContext` value object off `request.env["supabase.context"]`. The `Authentication` concern reads from it on every action; controllers can read from it directly when they need the underlying Supabase clients or the raw JWT claims. ```ruby SupabaseContext = Data.define( :supabase, :supabase_admin, :user_claims, :jwt_claims, :auth_mode, :auth_key_name, :current_user ) ``` | Attribute | Type | Description | | ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supabase` | `Supabase::Client` | A per-request client bound to the verified user's JWT — every PostgREST / Storage / Functions call goes through this. Honours RLS. | | `supabase_admin` | `Supabase::Client` | A per-request client bound to the project's secret key. **Bypasses RLS.** Reach for this only in trusted admin paths. | | `user_claims` | `Hash` | The user-claims subset of the JWT payload (`sub`, `email`, `role`, `aud`, `exp`). | | `jwt_claims` | `Hash` | The full verified JWT payload, including custom claims. | | `auth_mode` | `Symbol` | The mode the credentials were verified under: `:user`, `:publishable`, `:secret`, or `:none`. | | `auth_key_name` | `String` or `nil` | The named entry in `SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS` that matched, when applicable. | | `current_user` | `Supabase::Rails::User` or `nil` | The value object built from `jwt_claims` when `config.supabase.user_model` is unset. `nil` when a `user_model` is configured (the AR record is used instead). | ```ruby class PostsController < ApplicationController def index @posts = supabase_context.supabase .from("posts") .select("*") .order("created_at", desc: true) .execute .data end end ``` ```ruby class Admin::UsersController < ApplicationController before_action :require_admin def index @users = supabase_context.supabase_admin .auth .admin .list_users .users end end ``` Access the env key directly if you want to test for context presence without instantiating the controller helper: ```ruby request.env["supabase.context"] # => SupabaseContext or nil request.env[Supabase::Rails::CONTEXT_KEY] # constant form ``` The middleware returns `nil` for routes where verification was opted out (or failed soft); the concern handles that case by redirecting (`:web`) or 401-ing (`:api`). ## Coming from Devise [#coming-from-devise] `supabase-rails` ships Rails-8 vocabulary, not Devise's. The mapping is one-for-one for the helpers most apps use: | Devise | `supabase-rails` | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `current_user` | `current_user` (helper) or `Current.user` (controller) | | `signed_in?` / `user_signed_in?` | `authenticated?` | | `authenticate_user!` (before\_action) | `require_authentication` (already installed by the concern; opt out with `allow_unauthenticated_access`) | | `skip_before_action :authenticate_user!` | `allow_unauthenticated_access only:` / `except:` | | `user_session` | `Current.session` (the verified JWT claims) | | `sign_in(user)` | `start_new_session_for(supabase_session)` | | `sign_out` | `terminate_session` | | `after_sign_in_path_for(user)` | override `after_authentication_url` | | `stored_location_for(:user)` | `stored_location_for_redirect` | The conceptual difference is that Devise's `authenticate_user!` is opt-in per controller (forget it and your action is public); `Supabase::Rails::Authentication` is opt-out per action (forget `allow_unauthenticated_access` and your action requires a session). The Rails 8 default is the safer one. ## See also [#see-also] * [JWT verification](/reference/rails/authentication/jwt) — `Supabase::Rails::JWT.verify` and the JWKS cache that backs every authenticated request. * [Session store](/reference/rails/authentication/session-store) — the encrypted-cookie wrapper and the Rack middleware that reads it on every `:web`-mode request. * [Configuration → `expose_current_user`](/reference/rails/configuration#expose_current_user) — toggle whether `current_user` is exposed to views. * [Configuration → `user_model`](/reference/rails/configuration#user_model) — switch `Current.user` to an `ActiveRecord` row. * [`supabase:install` generator](/reference/rails/generators/install) — writes the `Authentication` concern and the `Current` model. * [`supabase:user_model` generator](/reference/rails/generators/user-model) — shadow `users` table + `User.from_supabase(claims)`. * [Controllers](/reference/rails/controllers) — the gem's stock `SessionsController` / `RegistrationsController` / etc. all include this concern. * supabase-rb: [Auth](/reference/ruby/auth) — the underlying Ruby auth surface every `supabase_*` helper delegates to (`sign_in_with_password`, `sign_up`, `sign_out`, `reset_password_for_email`, `update_user`, `sign_in_with_otp`, `verify_otp`, `resend`, `sign_in_with_oauth`). # JWT verification (/reference/rails/authentication/jwt) `Supabase::Rails::JWT` is the gem's access-token verifier. Every request that arrives with a Bearer token (`:api` mode) or carries a Supabase session cookie (`:web` mode) flows through `Supabase::Rails::JWT.verify` before `Current.user` is populated — the call is what turns an opaque token string into the `user_claims` and `jwt_claims` the rest of the gem hands back to your controllers. You almost never call `JWT.verify` directly. The middleware runs it for you and surfaces the result via [`supabase_context`](/reference/rails/authentication#the-supabase_context-request-object); your controllers read `Current.user` instead. The module is documented here because: (a) the errors it raises propagate to your controllers and need an error-handling story, (b) the JWKS cache it maintains has visible TTL/cooldown behaviour during key rotation, and (c) host apps writing custom Rack middleware or out-of-band token verification (background jobs replaying webhooks, etc.) need the same primitive. ```ruby # Verify an arbitrary token outside the request cycle (e.g. background job). env = Supabase::Rails::Env.resolve claims = Supabase::Rails::JWT.verify(token, env: env) claims[:user_claims].id # => "f47ac10b-58cc-4372-a567-0e02b2c3d479" claims[:user_claims].email # => "alice@example.com" claims[:jwt_claims]["aud"] # => "authenticated" claims[:jwt_claims]["exp"] # => 1730000000 ``` ## `Supabase::Rails::JWT.verify(token, env:)` [#supabaserailsjwtverifytoken-env] | Returns | Raises | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `{ user_claims: UserClaims, jwt_claims: Hash }` on success | `Supabase::Rails::AuthError` on every failure (see [Errors raised](#errors-raised)) | Validates a Supabase access token against the project's JWKS and returns the verified claims. `env` is a `Supabase::Rails::SupabaseEnv` (the value `Supabase::Rails::Env.resolve` returns) — the verifier reads only `env.jwks` from it, ignoring `url` / `publishable_keys` / `secret_keys`. Passing `env:` explicitly lets you verify against a different project's JWKS without mutating process-wide ENV. ### Algorithms [#algorithms] | Algorithm | When Supabase uses it | | --------- | ----------------------------------------------------- | | `RS256` | Default for new projects (asymmetric, RSA). | | `ES256` | Asymmetric, elliptic-curve. Configurable per project. | | `HS256` | Legacy symmetric signing on older projects. | Other algorithms (`none`, `RS512`, etc.) are rejected by the underlying `ruby-jwt` decoder before any claim is read — the algorithm allowlist is `ALGORITHMS = %w[RS256 ES256 HS256].freeze`, and the decoder is invoked with `algorithms:` set to that frozen array. ### Time skew [#time-skew] `LEEWAY_SECONDS = 30` is passed to `JWT.decode`, so a token's `exp` claim is treated as valid for an extra 30 seconds beyond its stated expiry, and `iat` / `nbf` claims are accepted up to 30 seconds in the future. This absorbs clock skew between your Rails servers and Supabase Auth without rejecting otherwise-valid sessions. ### Required claims [#required-claims] After successful signature verification, the verifier additionally requires: * The payload is a `Hash` — opaque scalar tokens are rejected. * `payload["sub"]` is a `String` — every Supabase user JWT carries the user's UUID in `sub`. A missing or non-string `sub` raises `AuthError.invalid_credentials`. All other claims (`aud`, `email`, `role`, `app_metadata`, `user_metadata`, `iat`, `exp`, `iss`) are passed through unmodified into the returned `jwt_claims` hash. ## JWKS resolution [#jwks-resolution] `env.jwks` is the value `Env.resolve` derived from one of the two JWT-related environment variables (or from a `config.supabase.env` override). The verifier resolves it once per call: | Shape of `env.jwks` | What `verify` does | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `Hash` (e.g. `{"keys" => [...]}`) | Used directly. No HTTP fetch. | | `URI::HTTPS` instance | Fetched via `Net::HTTP.get_response`, cached (see [Cache behaviour](#cache-behaviour)). | | `URI::HTTP` instance with a loopback host | Same as HTTPS (loopback exemption: `localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`). | | `URI::HTTP` instance with a non-loopback host | Refused — raises `AuthError.invalid_credentials`. | | `nil` | Raises `AuthError(message: "JWKS not configured for user auth mode", code: AUTH_ERROR, status: 500)`. | The HTTP loopback exemption mirrors `Supabase::Rails::Env.loopback_host?` so local `supabase start` (which serves JWKS over plain HTTP) works without forcing TLS, while production must use HTTPS. Misconfiguring `SUPABASE_JWKS_URL` to a plain-HTTP non-loopback URL is treated like a missing key, not as a transparent fallback — the request fails closed. ### Configuring JWKS [#configuring-jwks] `Env.resolve` reads (in priority order): 1. `config.supabase.env[:jwks]` if `config.supabase.env` is set and has a `:jwks` key. The value can be a `Hash` (inline) or a parsed `URI`. 2. `ENV["SUPABASE_JWKS"]` — inline JSON, either `{"keys":[…]}` or a raw `[…]` array. The array form is normalised to the `{"keys":[…]}` shape before being handed to the verifier. 3. `ENV["SUPABASE_JWKS_URL"]` — must parse as a URL and pass the HTTPS-or-loopback check. When all three are absent (or the URL is rejected), `env.jwks` is `nil` and the verifier raises the "JWKS not configured" `AuthError` on the first authenticated request — the gem fails closed at request time, not at boot. See [`configuration` → Environment variables](/reference/rails/configuration#environment-variables) for the full table. ## Cache behaviour [#cache-behaviour] When `env.jwks` is a URL, the verifier maintains a process-wide in-memory cache keyed by the URL string. The cache is guarded by a single `Mutex` so concurrent requests cannot trigger duplicate HTTP fetches. | Constant | Value | Meaning | | ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CACHE_TTL_SECONDS` | `600` | A successfully-fetched JWKS is reused for 10 minutes from the moment of fetch. | | `MISS_COOLDOWN_SECONDS` | `30` | After a failed fetch (network error, non-2xx, malformed JSON), subsequent verify calls re-raise immediately for 30 seconds without retrying the HTTP request. | | `LEEWAY_SECONDS` | `30` | Per-token clock-skew tolerance (independent of the cache). | `Process.clock_gettime(Process::CLOCK_MONOTONIC)` is used for both age calculations so wall-clock adjustments (NTP step, container time-sync) cannot retroactively extend or shorten cached entries. ### Cache lifecycle [#cache-lifecycle] 1. **First call** — cache miss, HTTP `GET` against the URL, parses the body. On `200` with `{"keys": [...]}`, stores `{ value: jwks, fetched_at: monotonic_now }`. 2. **Subsequent calls within 600 s** — return the cached `value` without touching the network. 3. **Calls after 600 s** — treat the entry as stale, refetch. On success the slot is rewritten with a new `fetched_at`; on failure the stale `value` is discarded and the cooldown timer starts. 4. **Failure** — the slot stores `last_miss_at`. Within 30 s, every subsequent call raises `AuthError.invalid_credentials` without hitting the network. Once 30 s have elapsed, the next call retries. The cooldown matters during incidents: if your project's JWKS endpoint is returning 5xx, your app does not hammer it once per request. Authenticated requests still fail (correctly — the gem cannot verify tokens without a JWKS), but at most one fetch per 30 s reaches Supabase. ### Cache layout [#cache-layout] ```ruby @cache = { "https://abcd1234.supabase.co/auth/v1/.well-known/jwks.json" => { value: { "keys" => [...] }, fetched_at: 1234567890.123, # monotonic seconds last_miss_at: nil } } ``` A successful refetch clears `last_miss_at`; a failure leaves the previous `value` in place but sets `last_miss_at`. The mutex ensures that an incoming request that observes a fresh `value` returns it without checking `last_miss_at`. ### `Supabase::Rails::JWT._reset_cache!` [#supabaserailsjwt_reset_cache] | Returns | Visibility | | ---------------------- | ---------------------------------------------------------- | | The empty cache `Hash` | Public, but underscore-prefixed — test- and operator-only. | Clears the entire JWKS cache. Intended for two scenarios: ```ruby # 1. RSpec — between examples that stub different JWKS endpoints. RSpec.configure do |config| config.before(:each) { Supabase::Rails::JWT._reset_cache! } end # 2. Operator — force a refetch after rotating Supabase's signing key out of band. Supabase::Rails::JWT._reset_cache! ``` In production you can wait 10 minutes for natural TTL expiry instead — the underscore-prefix is a hint that the method is intentionally not part of the request-path API. The cache is a per-process `Hash`. On Puma with multiple workers, the first authenticated request to each worker fetches the JWKS independently. There is no Redis or shared store — the design assumes the JWKS endpoint can handle one fetch every 10 minutes per worker, which Supabase's CDN-fronted `.well-known/jwks.json` handles easily. ## Errors raised [#errors-raised] `Supabase::Rails::JWT.verify` raises `Supabase::Rails::AuthError` for every failure path. The code/status surface is small on purpose — verification either succeeds or fails as a credential rejection. | Condition | `code` | `status` | Message | | --------------------------------------------------------------- | --------------------- | -------- | ------------------------------------------ | | `token` is `nil` or empty | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | `env.jwks` is `nil` | `AUTH_ERROR` | `500` | `"JWKS not configured for user auth mode"` | | JWKS URL is plain-HTTP non-loopback | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | JWKS fetch failed and cooldown is active | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | JWKS HTTP response is not 2xx | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | JWKS response body is not a `Hash` with a `"keys"` array | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | Signature verification failed | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | Algorithm not in `ALGORITHMS` allowlist | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | Token expired beyond the 30 s leeway | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | Verified payload is not a `Hash` or `sub` is missing/non-string | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | | Any other `StandardError` from `JWT.decode` | `INVALID_CREDENTIALS` | `401` | `"Invalid credentials"` | The collapsing of every credential-path failure into a single `INVALID_CREDENTIALS` is intentional: it prevents user-enumeration via timing or message variation. The one exception is the JWKS-misconfiguration case (`status: 500`), which is an operator error and surfaces verbatim so it is debuggable from the logs. ### How errors surface to your controllers [#how-errors-surface-to-your-controllers] In `:api` mode, the middleware catches the `AuthError`, calls `Supabase::Rails::Logging.log(:warn, ...)`, and returns a JSON error body with the error's status: ```json { "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" } ``` In `:web` mode, the [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) treats a failed verify as an anonymous context — the cookie is cleared and the request continues without a `Current.user`. The `Authentication` concern's `require_authentication` before-action then redirects to `new_session_path`. The token never reaches your controller. The `:web` mode flow is why JWKS failures during a Supabase outage manifest as users being asked to sign in again rather than as a 5xx page — the gem fails closed on every authenticated request, and `:web` mode's failure mode is "anonymous", not "5xx". ## Returned claims [#returned-claims] ```ruby { user_claims: Supabase::Rails::UserClaims.new( id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", role: "authenticated", email: "alice@example.com", app_metadata: { "provider" => "email" }, user_metadata: { "name" => "Alice" } ), jwt_claims: { "aud" => "authenticated", "exp" => 1730000000, "iat" => 1729996400, "iss" => "https://abcd1234.supabase.co/auth/v1", "sub" => "f47ac10b-58cc-4372-a567-0e02b2c3d479", "email" => "alice@example.com", "phone" => "", "app_metadata" => { "provider" => "email", "providers" => ["email"] }, "user_metadata" => { "name" => "Alice" }, "role" => "authenticated", "aal" => "aal1", "amr" => [{ "method" => "password", "timestamp" => 1729996400 }], "session_id" => "8e6c..." } } ``` `user_claims` is a `Supabase::Rails::UserClaims` Struct (defined in `lib/supabase/rails/core.rb`) — a thin per-field projection of the JWT payload for callers that prefer a typed accessor over hash-key lookups. The five fields it carries are the ones the gem itself reads in downstream code. `jwt_claims` is the verified payload Hash, exactly as returned by `JWT.decode` after signature verification. Use it when you need a claim `UserClaims` does not project (e.g. `aud`, `session_id`, `aal`). Downstream code uses both: [`SupabaseContext`](/reference/rails/authentication#the-supabase_context-request-object) exposes the full `jwt_claims` as `ctx.jwt_claims`, and `Supabase::Rails::User.from_claims(jwt_claims)` (the [`Supabase::Rails::User`](/reference/rails/authentication#supabaserailsuser-value-object) value object) is what populates `Current.user` when `config.supabase.user_model` is unset. ## Calling `verify` outside a request [#calling-verify-outside-a-request] The verifier is module-method and stateless apart from the JWKS cache, so it is safe to call from a background job, a one-off script, or a custom Rack middleware. Resolve the env yourself: ```ruby # app/jobs/process_webhook_job.rb class ProcessWebhookJob < ApplicationJob def perform(token, payload) env = Supabase::Rails::Env.resolve claims = Supabase::Rails::JWT.verify(token, env: env) user_id = claims[:user_claims].id User.find(user_id).process_webhook(payload) rescue Supabase::Rails::AuthError => e Rails.logger.warn("Webhook auth rejected: [#{e.code}] #{e.message}") raise # let Sidekiq drop the job end end ``` `Env.resolve` is cheap (reads ENV + parses JWKS once) and the JWKS cache is shared with the request path — verifying out-of-band reuses any already-fetched keys. ## See also [#see-also] * [Authentication](/reference/rails/authentication) — the concern that consumes `verify`'s output via `supabase_context`. * [Session store](/reference/rails/authentication/session-store) — the encrypted cookie that carries the access token in `:web` mode. * [`web-mode/cookie-credential-strategy`](/reference/rails/web-mode/cookie-credential-strategy) — the per-request strategy that calls `verify` and refreshes near-expired tokens. * [Configuration → Environment variables](/reference/rails/configuration#environment-variables) — `SUPABASE_JWKS` / `SUPABASE_JWKS_URL` reference. # Session store (/reference/rails/authentication/session-store) `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`](/reference/rails/web-mode/cookie-credential-strategy) for refresh, and writes the resulting `supabase_context` to `request.env`. ```ruby # 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 [#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`](/reference/rails/authentication). ## `Supabase::Rails::SessionStore` [#supabaserailssessionstore] ```ruby 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 [#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 `__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)` [#newconfig--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`: ```ruby 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 [#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. | ```ruby 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. 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)` [#readrequest] | 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. ```ruby 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 [#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`](/reference/rails/web-mode/cookie-credential-strategy) 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)` [#writeresponse-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: ```ruby { httponly: true, same_site: store.same_site, secure: store.secure, path: store.path, domain: store.domain # only included if set } ``` ```ruby # 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): ```ruby 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 )")`. The intent is "don't silently swallow a coding error" — passing `nil` to `write` is almost always a bug. ## `clear(response)` [#clearresponse] 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): ```ruby 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_reader`s. Hand-rolling a `cookies.delete("sb-session")` call from a controller without those options leaves a stale cookie in the browser. ## Expiry semantics [#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`](/reference/rails/web-mode/cookie-credential-strategy): | 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. 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` [#supabaserailsmiddleware] 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`. ```ruby # 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 [#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`: * `:api` → `Supabase::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. * `:web` → `Web::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: ```json { "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]` [#valid_modes--iapi-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 ` 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` [#what-it-places-on-requestenv] ```ruby 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/rails/authentication#the-supabase_context-request-object) reference for what controllers do with it. ### `config.supabase.insert_middleware` [#configsupabaseinsert_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: ```ruby # 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_action`s fire. ## See also [#see-also] * [Authentication](/reference/rails/authentication) — the concern that calls `SessionStore#write` from `start_new_session_for` and `SessionStore#clear` from `terminate_session`. * [JWT verification](/reference/rails/authentication/jwt) — what `CookieCredentialStrategy` runs on the access token after reading the cookie. * [`web-mode/cookie-credential-strategy`](/reference/rails/web-mode/cookie-credential-strategy) — the inline-refresh + clear-or-503 dispatch logic that calls `SessionStore` on every `:web` request. * [Configuration → `session`](/reference/rails/configuration#session) — the Railtie key whose Hash is passed to `SessionStore.new`. * [Configuration → `insert_middleware`](/reference/rails/configuration#insert_middleware) — toggle to opt out of automatic Rack stack insertion. # Configuration (/reference/rails/configuration) `supabase-rails` exposes its full configuration surface on the Rails Railtie at `config.supabase.*`. Defaults are set when `Supabase::Rails::Railtie` boots; the install generator writes a starter `config/initializers/supabase.rb` that overrides the two values most apps need to change (`mode` and a commented-out `session` block). The same Railtie also reads a small set of environment variables via `Supabase::Rails::Env.resolve` and inserts the Rack middleware that hangs the per-request context off `request.env`. ## Where configuration lives [#where-configuration-lives] ```ruby # config/initializers/supabase.rb (written by `bin/rails generate supabase:install`) Rails.application.config.supabase.mode = :web # Rails.application.config.supabase.allowed_redirect_origins = ["https://example.com"] # Rails.application.config.supabase.expose_current_user = nil # Rails.application.config.supabase.session = { # cookie_name: "sb-session", # same_site: :lax, # secure: nil, # domain: nil, # path: "/" # } ``` Three pieces work together: * **`Supabase::Rails::Railtie`** declares `config.supabase = ActiveSupport::OrderedOptions.new`, sets defaults for every key listed below, and inserts `Supabase::Rails::Middleware` into the host's Rack stack (unless `insert_middleware` is `false`). * **`Supabase::Rails::Engine`** is internal — it is **not** mounted by the host (`mount Supabase::Rails::Engine` is **not** required). Its single job is to install the `supabase_authentication_routes` DSL helper onto `ActionDispatch::Routing::Mapper` before Rails draws `config/routes.rb`, and to add the gem's `app/controllers` / `app/views` / `config/locales` to the host autoload paths. The engine deliberately skips `isolate_namespace` so the gem's controllers inherit from the host's `::ApplicationController`. * **`Supabase::Rails::Env`** parses `SUPABASE_*` environment variables at request time. Keys provided via `config.supabase.env` (a Hash override) take precedence over the corresponding environment variables. ## Config keys [#config-keys] Every key has a default. You only need to set the ones that diverge from the defaults — the install generator sets `mode = :web` because the framework default is `:api`. ### `mode` [#mode] | Type | Default | | --------------------------- | ------- | | `Symbol` (`:api` or `:web`) | `:api` | Selects the authentication strategy. `:api` extracts credentials from the `Authorization: Bearer` and `apikey` request headers — appropriate for JSON APIs and SPAs that send a JWT on every request. `:web` reads and refreshes a Rails-encrypted session cookie (`sb-session` by default), so Devise-style server-rendered apps work without the client tracking tokens manually. Any other value raises `Supabase::Rails::ConfigError(INVALID_MODE)` when the middleware is constructed. ```ruby Rails.application.config.supabase.mode = :web ``` ### `auth` [#auth] | Type | Default | | --------------------------- | ------- | | `Symbol` or `Array` | `:user` | In `:api` mode, the list of auth modes the middleware will try in order when verifying credentials. Recognised values: `:user` (verify a Bearer JWT against the project's JWKS), `:publishable` (match the `apikey` header against `SUPABASE_PUBLISHABLE_KEY[S]`), `:secret` (match against `SUPABASE_SECRET_KEY[S]`), `:none` (skip verification — useful for public routes). Append `:name` to restrict to a single key entry (e.g. `[:publishable, "publishable:default"]`) or `:*` to accept any. Ignored in `:web` mode, where credentials come from the encrypted cookie. ```ruby Rails.application.config.supabase.auth = %i[user publishable] ``` ### `session` [#session] | Type | Default | | ------ | ------------------------------------------------------------------------------------- | | `Hash` | `{ cookie_name: "sb-session", same_site: :lax, secure: nil, domain: nil, path: "/" }` | Encrypted session cookie config for `:web` mode. The cookie is encrypted with the host's existing `secret_key_base` — **there is no new secret to manage**. Cookies are always `HttpOnly`; `secure: nil` auto-detects from `Rails.env.production?`. | Key | Default | Description | | ------------- | -------------- | ------------------------------------------------------------------------------------ | | `cookie_name` | `"sb-session"` | Cookie name. | | `same_site` | `:lax` | `:lax`, `:strict`, or `:none`. `:none` requires `secure: true`. | | `secure` | `nil` | `nil` → auto-detect (`Rails.env.production?` → `true`). Set `true`/`false` to force. | | `domain` | `nil` | Cookie `Domain` attribute. `nil` → host-only cookie. Set for subdomain sharing. | | `path` | `"/"` | Cookie `Path` attribute. | ```ruby Rails.application.config.supabase.session = { cookie_name: "myapp-session", same_site: :strict, secure: true, domain: ".myapp.com", path: "/" } ``` ### `allowed_redirect_origins` [#allowed_redirect_origins] | Type | Default | | --------------- | ------- | | `Array` | `[]` | Origin allowlist consulted by the OAuth and password-reset helpers when validating a `?redirect_to=` query param. Path-only targets (`/dashboard`) are always allowed; absolute URLs must match an entry exactly on `scheme://host[:port]`. When empty, the helpers fall back to `[request.host]` (same-origin only) at runtime, deriving the scheme from the request — so dev apps on `http://localhost:3000` still work. Off-allowlist redirects raise `Supabase::Rails::AuthError(INVALID_REDIRECT)` with HTTP 400. ```ruby Rails.application.config.supabase.allowed_redirect_origins = [ "https://app.example.com", "https://staging.example.com" ] ``` ### `oauth_providers` [#oauth_providers] | Type | Default | | --------------- | ------- | | `Array` | `[]` | Provider list rendered by the `supabase/rails/oauth/_buttons` partial. The default sign-in / sign-up views render this partial unconditionally, so an empty array means no OAuth buttons appear. Each entry becomes one "Sign in with ``" link wired to `oauth_path(provider)`. The string must match a provider id Supabase recognises (`google`, `github`, `azure`, …) — the gem does not validate the list. ```ruby Rails.application.config.supabase.oauth_providers = %i[google github] ``` ### `user_model` [#user_model] | Type | Default | | ------------------------------ | ------- | | `String` (class name) or `nil` | `nil` | Opt into a shadow ActiveRecord `User` model that mirrors the Supabase user row (FR-W14). When set, `Current.user` becomes the AR record returned by `.from_supabase(claims)` (a by-PK lookup) instead of the immutable `Supabase::Rails::User` value object. The `supabase:user_model` generator emits this class + migration and appends this line to the initializer for you. ```ruby Rails.application.config.supabase.user_model = "User" ``` ### `expose_current_user` [#expose_current_user] | Type | Default | | ------------------ | --------------------------- | | `Boolean` or `nil` | `nil` (derives from `mode`) | When `true`, the `Authentication` concern exposes `current_user` as a Rails `helper_method` so views can call it directly. When `nil` (the default), it derives from `mode` — `true` in `:web`, `false` in `:api` (avoids clashing with API hosts that define their own `current_user`). Set explicitly when you need to override the inferred behaviour. ```ruby Rails.application.config.supabase.expose_current_user = true ``` ### `insert_middleware` [#insert_middleware] | Type | Default | | --------- | ------- | | `Boolean` | `true` | Whether the Railtie should insert `Supabase::Rails::Middleware` into the host's Rack stack on boot. Set to `false` only if you want to mount the middleware yourself in a non-standard position (for example, before a custom authentication middleware). Without the middleware, `request.env[Supabase::Rails::CONTEXT_KEY]` is never populated and every authenticated request returns 401. ```ruby Rails.application.config.supabase.insert_middleware = false # Then mount it manually: Rails.application.config.middleware.insert_before MyAuthMiddleware, Supabase::Rails::Middleware, mode: :api ``` ### `env` [#env] | Type | Default | | --------------- | ------- | | `Hash` or `nil` | `nil` | Per-host overrides for the environment variables `Supabase::Rails::Env.resolve` reads. Recognised keys: `:url`, `:publishable_keys`, `:secret_keys`, `:jwks`. Each key, when present, replaces the corresponding `SUPABASE_*` env-var lookup entirely. Useful in tests (`config.supabase.env = { url: "http://localhost:54321", publishable_keys: { "default" => "sb_publishable_test" } }`) or in multi-tenant apps that source credentials from a database. ```ruby Rails.application.config.supabase.env = { url: "https://abcd1234.supabase.co", publishable_keys: { "default" => "sb_publishable_..." }, secret_keys: { "default" => "sb_secret_..." } } ``` ### `supabase_options` [#supabase_options] | Type | Default | | --------------- | ------- | | `Hash` or `nil` | `nil` | Raw options forwarded to `Supabase::Client.new` for every context client the middleware builds. Accepts the same shape as `supabase-rb`'s [`Supabase::ClientOptions`](/reference/ruby/initializing#clientoptions) (or the legacy nested-hash form). Lets you set custom global headers, swap the HTTP adapter, or tune the PostgREST / Storage timeouts. The gem rewrites `auth:` to always include `auto_refresh_token: false`, `persist_session: false`, and `detect_session_in_url: false` (hard invariants — `auto_refresh_token: true` would leak a background timer per request). ```ruby Rails.application.config.supabase.supabase_options = { global: { headers: { "X-App-Version" => MyApp::VERSION } }, postgrest: { timeout: 30 } } ``` ### `cors` [#cors] | Type | Default | | ------------------------- | ------- | | `Hash`, `nil`, or `false` | `nil` | Controls CORS behaviour on the middleware. `nil` (default) sends a permissive set of headers tuned for the Supabase clients calling your API. `false` disables CORS entirely (use this when `rack-cors` is mounted upstream and should own the response). A `Hash` is used verbatim as the `Access-Control-*` headers added to every response and to the `204` preflight reply. See [CORS behaviour](#cors-behaviour) below for the default header set. ```ruby Rails.application.config.supabase.cors = { "Access-Control-Allow-Origin" => "https://app.example.com", "Access-Control-Allow-Headers" => "authorization, apikey, content-type", "Access-Control-Allow-Methods" => "GET, POST, OPTIONS" } ``` ## Environment variables [#environment-variables] `Supabase::Rails::Env.resolve` is invoked once per request by the middleware (and lazily by the `auth` helpers). It reads: | Variable | Purpose | Required? | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `SUPABASE_URL` | Project URL, e.g. `https://abcd1234.supabase.co`. | **Yes** — `EnvError.missing_supabase_url` raises 500 when absent. | | `SUPABASE_PUBLISHABLE_KEY` | Default publishable (anon) key (`sb_publishable_…`). | At least one of the two. | | `SUPABASE_PUBLISHABLE_KEYS` | JSON map `{"default":"sb_publishable_…","tenant_a":"sb_publishable_…"}` for multi-key apps. | At least one of the two. | | `SUPABASE_SECRET_KEY` | Default secret (service-role) key (`sb_secret_…`). | Required for `:secret` auth mode or admin client access. | | `SUPABASE_SECRET_KEYS` | JSON map for multiple secret keys. | Required for `:secret` auth mode or admin client access. | | `SUPABASE_JWKS` | Inline JWKS JSON (`{"keys":[…]}` or a raw `[…]` array). | One of the two for JWT verification. | | `SUPABASE_JWKS_URL` | Remote JWKS endpoint. Must be `https://` — `http://` is only accepted for loopback hosts (`localhost`, `127.0.0.1`, `[::1]`). | One of the two for JWT verification. | The Supabase dashboard labels the same value as "anon public" on legacy projects and "publishable" on new projects. The gem only looks at `SUPABASE_PUBLISHABLE_KEY` / `SUPABASE_PUBLISHABLE_KEYS` — setting `SUPABASE_ANON_KEY` (or `SUPABASE_SERVICE_ROLE_KEY` for the secret) does nothing. Rename your env vars when copying from a Node project. ### Multiple keys (plural form) [#multiple-keys-plural-form] `SUPABASE_PUBLISHABLE_KEYS` (and `SUPABASE_SECRET_KEYS`) take precedence over the singular form when set. The value must be a JSON object mapping a key **name** to a key **value**: ```bash SUPABASE_PUBLISHABLE_KEYS='{"default":"sb_publishable_aaa","tenant_a":"sb_publishable_bbb"}' ``` The singular form is shorthand for `{"default":""}`. Auth modes can target a specific key via `auth: [:publishable, "publishable:tenant_a"]`. Unparseable JSON silently falls back to `{}` — no key is registered, and request authentication will fail rather than crash at boot. ### Interaction with `config.supabase.env` [#interaction-with-configsupabaseenv] When `config.supabase.env` is set (a Hash, not `nil`), each key present in the hash **replaces** the corresponding environment variable lookup wholesale. Keys not present in the hash still fall back to the environment: ```ruby # Reads SUPABASE_URL from ENV; uses the hash for keys. Rails.application.config.supabase.env = { publishable_keys: { "default" => "sb_publishable_test" } } ``` This is the recommended hook for tests (drive credentials from `Rails.application.credentials.dig(:supabase, :test)`) or for multi-tenant routing (build the hash per-request via a custom middleware that pre-populates `request.env[Supabase::Rails::CONTEXT_KEY]`). ## CORS behaviour [#cors-behaviour] `Supabase::Rails::Middleware` consults `config.supabase.cors`: * `nil` (default) — adds `Supabase::Rails::CORS::DEFAULT_HEADERS` to every response and replies to `OPTIONS` requests with `204` + the same headers. * `false` — skips CORS entirely. Preflights pass through to the app; no `Access-Control-*` headers are added. * `Hash` — used verbatim as the header set. The default headers are intentionally permissive so the Supabase JS / Swift / Kotlin clients can talk to your API from any origin: | Header | Default | | ------------------------------ | ------------------------------------------------------------------- | | `Access-Control-Allow-Origin` | `*` | | `Access-Control-Allow-Headers` | `authorization, x-client-info, apikey, content-type, x-retry-count` | | `Access-Control-Allow-Methods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` | ```ruby # Lock origin to your dashboard SPA while keeping the gem's allowed headers/methods. Rails.application.config.supabase.cors = Supabase::Rails::CORS::DEFAULT_HEADERS .merge("Access-Control-Allow-Origin" => "https://app.example.com") ``` CORS only governs browser fetches. Server-to-server calls and native mobile clients ignore these headers entirely — set `cors: false` if your API is consumed exclusively by trusted backends. ## Logging [#logging] `Supabase::Rails::Logging` is a thread-safe accessor for the gem's logger. The gem writes structured one-line messages (`[supabase.rails.sign_in_failure] code=… email=a***@example.com`, `[AUTH_…]` warnings on credential failures) — set a logger to capture them. | API | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `Supabase::Rails.logger=` | Assign any object that responds to `:debug`, `:info`, `:warn`, `:error`. Mutex-guarded; safe to set at boot or swap at runtime. | | `Supabase::Rails.logger` | Reads the current logger. Returns `nil` when unset. | | `Supabase::Rails::Logging.log(level, message)` | Internal helper. No-op when no logger is set; swallows exceptions so a misbehaving logger never breaks an auth flow. | ```ruby # config/initializers/supabase.rb Supabase::Rails.logger = Rails.logger ``` Email addresses in log lines are automatically redacted via `Supabase::Rails::Authentication.redact_email` (`alice@example.com` → `a***@example.com`), so you can ship sign-in failures to your log aggregator without leaking PII. Access tokens, refresh tokens, and JWT claims are never logged. ## The Railtie initializer [#the-railtie-initializer] The Railtie runs one initializer at boot: ```ruby initializer "supabase.middleware" do |app| cfg = app.config.supabase next unless cfg.insert_middleware app.middleware.use Supabase::Rails::Middleware, mode: cfg.mode, auth: cfg.auth, env: cfg.env, supabase_options: cfg.supabase_options, cors: cfg.cors, session: cfg.session, user_model: cfg.user_model end ``` Two things to note: * The middleware is appended at the end of the stack with `app.middleware.use`. If you need it earlier (for example, before a `Rack::Attack` throttler that should see the authenticated user), set `insert_middleware = false` and call `Rails.application.config.middleware.insert_before` yourself. * The middleware constructor reads its config from arguments — it does **not** re-read `Rails.application.config.supabase` per request. Changing a key after boot has no effect; restart the Rails process. ## See also [#see-also] * [Getting started](/reference/rails/getting-started) — installing the gem and setting `SUPABASE_URL` / `SUPABASE_PUBLISHABLE_KEY`. * [Generators](/reference/rails/generators) — what `supabase:install` writes into the initializer. * [Web mode](/reference/rails/web-mode) — how the encrypted session cookie is read, refreshed, and rewritten. * [Authentication](/reference/rails/authentication) — the concern that consumes `expose_current_user`, `user_model`, and `allowed_redirect_origins`. * supabase-rb: [Initializing](/reference/ruby/initializing) — `Supabase::Client.new` and `Supabase::ClientOptions`, the underlying surface `supabase_options` is forwarded to. # BaseController (/reference/rails/controllers/base) `Supabase::Rails::BaseController` is the common parent class every gem-shipped controller inherits from. It does two things — inherit from the host's `::ApplicationController` and `include Supabase::Rails::Authentication` — so the host's layouts, helpers, CSRF posture, and global `before_action`s flow through into sessions/registrations/passwords/OTP/OAuth without re-declaration. ```ruby # app/controllers/supabase/rails/base_controller.rb (in the gem) module Supabase module Rails class BaseController < ::ApplicationController include Supabase::Rails::Authentication end end end ``` ## Why inherit from `::ApplicationController`? [#why-inherit-from-applicationcontroller] The host's `::ApplicationController` typically declares: * `protect_from_forgery with: :exception` (CSRF protection). * Layout choices (`layout "application"` or per-controller overrides). * Global before-action hooks (analytics, locale switching, tenant scoping). * Helper-method registrations and `helper_method` exposures. * Rate limiting, request logging, or audit-trail concerns. Inheriting from `::ApplicationController` means every one of those host conventions applies unchanged to `Supabase::Rails::SessionsController#create` and friends. The gem deliberately does **not** declare `protect_from_forgery` itself — host posture wins. See [Engine note](/reference/rails/configuration#engine) for why `Supabase::Rails::Engine` skips `isolate_namespace` (which is what makes this `::ApplicationController` hop possible). `BaseController` resolves `::ApplicationController` at class-definition time. A host that has renamed or removed `ApplicationController` will see a `NameError` at gem load. Add a tiny shim (e.g. `ApplicationController = ::ActionController::Base`) before the gem boots if you have intentionally removed it. ## What the `Authentication` concern installs [#what-the-authentication-concern-installs] Including [`Supabase::Rails::Authentication`](/reference/rails/authentication) on every gem-shipped controller installs the full Rails-8-shape auth surface — `before_action :require_authentication`, `before_action :populate_current_attributes`, `helper_method :authenticated?`, the `allow_unauthenticated_access` class macro, and the full `supabase_*` helper set. `Sessions`, `Registrations`, `Passwords`, `Otp`, and `Oauth` all inherit from `BaseController` so they pick up this surface without re-declaring it. Each one declares `allow_unauthenticated_access only: %i[...]` for its public actions; everything else stays gated. `Authentication` is included once at `BaseController`. Re-including it in a host subclass (`include Supabase::Rails::Authentication`) re-runs the `included` block and re-registers helpers and `before_action`s. The generator-emitted host `ApplicationController` already mixes in `Authentication` for non-Supabase controllers; the gem-shipped controllers inherit from `BaseController` and do not need a second include. ## When to use it in your own controllers [#when-to-use-it-in-your-own-controllers] You usually do **not** need to subclass `Supabase::Rails::BaseController` directly. The install generator wires the [`Authentication`](/reference/rails/authentication) concern into your host's `ApplicationController`, so every controller in the host app already has `authenticated?`, `current_user`, `require_authentication`, and the `supabase_*` helpers — no extra inheritance step required. `BaseController` exists primarily as a hook the five gem-shipped controllers inherit from. Two scenarios where direct subclassing is reasonable: 1. **Adding a new auth-adjacent controller** (e.g. a "Confirm new email" flow) that wants the same parent-class shape — layouts via `::ApplicationController`, `Authentication` concern already included, `allow_unauthenticated_access` available — as the gem's controllers. ```ruby class EmailConfirmationsController < Supabase::Rails::BaseController allow_unauthenticated_access only: %i[show] def show result = supabase_verify_otp( token: params[:token], type: "email_change", email: params[:email], ) if result.success? redirect_to root_path, notice: t(".email_updated") else redirect_to root_path, alert: result.error.message end end end ``` 2. **Replacing one of the shipped controllers wholesale** by rewriting the host's 3-line subclass to inherit from `BaseController` instead of the gem's per-domain class: ```ruby # app/controllers/sessions_controller.rb class SessionsController < Supabase::Rails::BaseController allow_unauthenticated_access only: %i[new create] def new; end def create # Fully custom sign-in flow. end def destroy terminate_session redirect_to root_path end end ``` This keeps `Authentication` available but discards every action body the gem ships. See the per-controller pages for the action bodies you would be giving up. ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the routing DSL, the six controllers, and the override pattern. * [`Authentication` concern](/reference/rails/authentication) — every method `BaseController` makes available to its subclasses. * [`supabase:install` generator](/reference/rails/generators/install) — writes the 3-line host subclasses that inherit from the gem's per-domain controllers (themselves inheriting from `BaseController`). * [Engine note](/reference/rails/configuration#engine) — why `Supabase::Rails::Engine` is non-`isolate_namespace`, which is what lets `BaseController` reach `::ApplicationController`. # Controllers (/reference/rails/controllers) `supabase-rails` ships six controllers under `Supabase::Rails::*` that the `bin/rails generate supabase:install` generator subclasses into the host app's top-level namespace (`SessionsController`, `RegistrationsController`, …). Action bodies live in the gem so improvements arrive via `bundle update`; hosts override behaviour by redefining individual actions in their 3-line subclass. The [`supabase_authentication_routes`](#routes) DSL helper mounts the routes the six controllers expect — sign-in / sign-up / sign-out, password reset, OTP / magic link, and the two-leg OAuth + PKCE flow. ## Controller hierarchy [#controller-hierarchy] The host's controllers are top-level constants (e.g. `::SessionsController`) that inherit from the gem's namespaced base classes. The routes DSL resolves the unprefixed action names (`resource :session`, `resources :passwords`, …), so route lookup hits the host's top-level subclass first — that subclass either inherits the gem's action body unchanged or overrides it. | Layer | Class | Where it lives | | --------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | | Host (generated, 3-line subclass) | `::SessionsController` | `app/controllers/sessions_controller.rb` | | Gem base controller | `Supabase::Rails::SessionsController` | `app/controllers/supabase/rails/sessions_controller.rb` | | Gem common base | [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) | `app/controllers/supabase/rails/base_controller.rb` | | Host application | `::ApplicationController` | `app/controllers/application_controller.rb` | `Supabase::Rails::BaseController` inherits from `::ApplicationController`, **not** from `ActionController::Base` directly. That hop is what makes the host's layouts, helpers, `protect_from_forgery`, and `before_action`s flow through to the gem's actions. ## What lives where [#what-lives-where] | Controller | Actions | Reference | | ------------------------------------------ | --------------------------------- | ----------------------------------------------------------- | | `Supabase::Rails::BaseController` | — | [base](/reference/rails/controllers/base) | | `Supabase::Rails::SessionsController` | `new`, `create`, `destroy` | [sessions](/reference/rails/controllers/sessions) | | `Supabase::Rails::RegistrationsController` | `new`, `create` | [registrations](/reference/rails/controllers/registrations) | | `Supabase::Rails::PasswordsController` | `new`, `create`, `edit`, `update` | [passwords](/reference/rails/controllers/passwords) | | `Supabase::Rails::OtpController` | `new`, `create`, `verify` | [otp](/reference/rails/controllers/otp) | | `Supabase::Rails::OauthController` | `authorize`, `callback` | [oauth](/reference/rails/controllers/oauth) | ## Routes [#routes] The `supabase_authentication_routes` helper is installed onto `ActionDispatch::Routing::Mapper` by [`Supabase::Rails::Engine`](/reference/rails/configuration#engine) so it is callable directly inside `Rails.application.routes.draw do ... end` — no `mount` is required, and there is no engine to namespace under. ```ruby # config/routes.rb (after running `bin/rails generate supabase:install`) Rails.application.routes.draw do supabase_authentication_routes end ``` One line expands to the full route table: | Helper | Verb | URL | Controller#Action | | -------------------------------- | --------- | ---------------------------- | -------------------------------- | | `new_session_path` | GET | `/session/new` | `SessionsController#new` | | `session_path` | POST | `/session` | `SessionsController#create` | | `session_path` | DELETE | `/session` | `SessionsController#destroy` | | `new_registration_path` | GET | `/registration/new` | `RegistrationsController#new` | | `registration_path` | POST | `/registration` | `RegistrationsController#create` | | `passwords_path` | GET | `/passwords` | `PasswordsController#index` | | `passwords_path` | POST | `/passwords` | `PasswordsController#create` | | `new_password_path` | GET | `/passwords/new` | `PasswordsController#new` | | `edit_password_path(token)` | GET | `/passwords/:token/edit` | `PasswordsController#edit` | | `password_path(token)` | PATCH/PUT | `/passwords/:token` | `PasswordsController#update` | | `new_otp_path` | GET | `/otp/new` | `OtpController#new` | | `otp_index_path` | POST | `/otp` | `OtpController#create` | | `verify_otp_index_path` | GET, POST | `/otp/verify` | `OtpController#verify` | | `oauth_authorize_path(provider)` | GET | `/oauth/:provider/authorize` | `OauthController#authorize` | | `oauth_callback_path` | GET | `/oauth/callback` | `OauthController#callback` | ### Filtering with `only:` / `except:` [#filtering-with-only--except] The helper accepts an `only:` or `except:` array of group symbols — one of `:session`, `:registration`, `:passwords`, `:otp`, `:oauth`. A host that only wants email + password sign-in writes: ```ruby Rails.application.routes.draw do supabase_authentication_routes only: %i[session registration] end ``` Unknown group symbols raise `ArgumentError` at boot with a message listing the valid set (`Supabase::Rails::Routes::GROUPS`). The helper does **not** name controllers — `resource :session` resolves to `::SessionsController` (the top-level constant), which is exactly the wrapper the install generator writes. ### Nesting under a scope [#nesting-under-a-scope] The DSL is a regular routing helper, so it can be wrapped in any scope/namespace/constraint Rails supports. Hosts that want auth under `/auth` write: ```ruby Rails.application.routes.draw do scope "/auth" do supabase_authentication_routes end end ``` The shipped views call URL helpers (`session_path`, `new_password_path`, …), not raw paths — so URL helpers stay valid under any prefix without view edits. ## The override pattern [#the-override-pattern] The install generator writes a 3-line subclass for each controller into the host's top-level namespace. The subclass inherits every action body from the gem; the host overrides any action by redefining it. ```ruby # app/controllers/sessions_controller.rb (written by `bin/rails generate supabase:install`) class SessionsController < Supabase::Rails::SessionsController end ``` To customise an action, redefine it in the subclass — optionally calling `super` to keep the gem behaviour and add to it: ```ruby class SessionsController < Supabase::Rails::SessionsController def create super AnalyticsJob.perform_later(event: "sign_in", user_id: Current.user.id) if authenticated? end end ``` To replace the action wholesale, redefine it without `super`: ```ruby class SessionsController < Supabase::Rails::SessionsController def create session = authenticate_with_supabase(email: params[:email], password: params[:password]) if session start_new_session_for(session) redirect_to dashboard_path, notice: "Welcome back!" else flash.now[:alert] = "Bad credentials." render :new, status: :unauthorized end end end ``` The methods the action bodies call — `authenticate_with_supabase`, `start_new_session_for`, `terminate_session`, and the [`supabase_*` low-level helpers](/reference/rails/authentication#helper-reference) — are all instance methods on the [`Authentication` concern](/reference/rails/authentication) included via [`BaseController`](/reference/rails/controllers/base). They are available unchanged in any host subclass. ## Flash messages and I18n [#flash-messages-and-i18n] Every redirect / re-render in the shipped action bodies passes its `notice:` / `alert:` through `I18n.t("supabase.rails..")`. The English defaults ship in the gem under `config/locales/en.yml`: | Key | Default copy | | --------------------------------------------------- | --------------------------------------------------------- | | `supabase.rails.sessions.created` | Signed in successfully. | | `supabase.rails.sessions.invalid` | Try another email address or password. | | `supabase.rails.sessions.destroyed` | Signed out successfully. | | `supabase.rails.registrations.created` | Welcome — your account is ready. | | `supabase.rails.registrations.pending_confirmation` | Check your inbox to confirm your email before signing in. | | `supabase.rails.passwords.reset_sent` | Check your inbox for a password-reset link. | | `supabase.rails.passwords.updated` | Password updated. Sign in with your new password. | | `supabase.rails.otp.sent` | We sent you a code — enter it below. | | `supabase.rails.otp.verified` | Signed in successfully. | | `supabase.rails.oauth.failed` | We couldn't start that sign-in. Please try again. | | `supabase.rails.oauth.connected` | Signed in successfully. | Hosts override copy by adding the same keys to their own `config/locales/en.yml` (or any locale file). The gem-shipped defaults lose to host-provided keys via Rails' standard locale-file load order. The `:alert` flash on validation failures (`supabase_sign_up`, `supabase_reset_password`, `supabase_update_user`, `supabase_sign_in_with_otp`, `supabase_verify_otp`, `supabase_exchange_code_for_session`) is the mapped `AuthError.message` — see [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) for the full code → message table. ## See also [#see-also] * [`base`](/reference/rails/controllers/base) — the common parent class. * [`sessions`](/reference/rails/controllers/sessions) — sign-in and sign-out. * [`registrations`](/reference/rails/controllers/registrations) — sign-up. * [`passwords`](/reference/rails/controllers/passwords) — password reset. * [`otp`](/reference/rails/controllers/otp) — OTP / magic-link sign-in. * [`oauth`](/reference/rails/controllers/oauth) — OAuth + PKCE. * [`Authentication` concern](/reference/rails/authentication) — `authenticated?`, `current_user`, `require_authentication`, `start_new_session_for`, `terminate_session`, and the `supabase_*` helpers. * [`supabase:install` generator](/reference/rails/generators/install) — writes the 3-line top-level subclasses. * [`supabase:views` generator](/reference/rails/generators/views) — copies the default ERB into the host app. # OauthController (/reference/rails/controllers/oauth) `Supabase::Rails::OauthController` handles the two-leg OAuth 2.0 + PKCE flow: send the user to the upstream provider, then exchange the returned code for a Supabase session. The install generator writes a top-level `OauthController` that inherits from it. Under the hood, `authorize` wraps the `supabase-rb` [`auth.sign_in_with_oauth`](/reference/ruby/auth/signinwithoauth) call (via `supabase_sign_in_with_oauth`); `callback` wraps the same flow's `auth.exchange_code_for_session` (via `supabase_exchange_code_for_session`) to finish the PKCE round-trip. ```ruby # app/controllers/oauth_controller.rb (written by `bin/rails generate supabase:install`) class OauthController < Supabase::Rails::OauthController end ``` ## Source [#source] ```ruby # app/controllers/supabase/rails/oauth_controller.rb (in the gem) class OauthController < BaseController allow_unauthenticated_access only: %i[authorize callback] def authorize result = supabase_sign_in_with_oauth( provider: params[:provider], redirect_to: params[:redirect_to] || oauth_callback_url, ) if result.success? redirect_to result.value, allow_other_host: true else redirect_to new_session_path, alert: I18n.t("supabase.rails.oauth.failed") end end def callback result = supabase_exchange_code_for_session( code: params[:code], state: params[:state], ) if result.success? redirect_to after_authentication_url, notice: I18n.t("supabase.rails.oauth.connected") else redirect_to new_session_path, alert: result.error.message end end end ``` ## Routes [#routes] | Helper | Verb | URL | Action | | -------------------------------- | ---- | ---------------------------- | ----------- | | `oauth_authorize_path(provider)` | GET | `/oauth/:provider/authorize` | `authorize` | | `oauth_callback_path` | GET | `/oauth/callback` | `callback` | Mounted by: ```ruby get "/oauth/:provider/authorize", to: "oauth#authorize", as: :oauth_authorize get "/oauth/callback", to: "oauth#callback", as: :oauth_callback ``` inside [`supabase_authentication_routes`](/reference/rails/controllers#routes). The URLs are GETs because OAuth providers redirect via HTTP 302 — the upstream cannot POST back to your callback. ## The flow [#the-flow] ``` ┌──────┐ GET /oauth/google/authorize ┌──────────────────┐ │ User │ ────────────────────────────► │ host: authorize │ └──────┘ └────────┬─────────┘ ▲ │ build PKCE state + verifier, │ │ write sb-oauth-state- cookie, │ │ ask Supabase for authorize URL │ │ │ 302 to provider URL ▼ │ ◄────────────────────────── result.value (provider URL) │ │ user signs in at provider ────────────► provider │ │ ◄────── 302 to /oauth/callback?code=...&state=... │ │ GET /oauth/callback?code=...&state=... ┌──────────────────┐ └──────────────────────────────────────────► │ host: callback │ └────────┬─────────┘ │ read sb-oauth-state- cookie, │ exchange code for session, │ start_new_session_for(session), │ redirect_to after_authentication_url ``` The PKCE verifier survives the round-trip via a signed cookie keyed on the `state` — see [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage). The provider cannot see the verifier; only the host's signed-cookie keystore has it. The cookie name is `sb-oauth-state-` and it expires after 10 minutes. ## Actions [#actions] ### `authorize` [#authorize] Generates the PKCE state + verifier, asks Supabase Auth for the provider authorize URL, and 302s the user to it. Allowed without authentication. **Params** | Param | Source | Notes | | ------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | URL path | Required. One of `"google"`, `"github"`, `"discord"`, etc. — any provider configured in the Supabase dashboard's Authentication → Providers page. | | `redirect_to` | Query string | Optional. Defaults to `oauth_callback_url`. Validated against [`config.supabase.allowed_redirect_origins`](/reference/rails/configuration#allowed_redirect_origins) by [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) before the upstream call. | **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------------------------------------------- | -------------------------------------------------- | ------ | ---------------------------------------------- | | Success | `redirect_to result.value, allow_other_host: true` | 302 | — | | Failure — invalid `redirect_to` | `redirect_to new_session_path` | 302 | `alert: I18n.t("supabase.rails.oauth.failed")` | | Failure — provider unknown / not configured | `redirect_to new_session_path` | 302 | `alert: I18n.t("supabase.rails.oauth.failed")` | | Failure — 5xx upstream | `redirect_to new_session_path` | 302 | `alert: I18n.t("supabase.rails.oauth.failed")` | The `allow_other_host: true` flag is mandatory — the upstream provider's authorize URL is cross-origin, and Rails refuses cross-origin redirects unless explicitly told to allow them. The failure flash is **generic** ("We couldn't start that sign-in") regardless of whether the failure was a validation error, an unknown provider, or an upstream outage. The default body's `params[:redirect_to] || oauth_callback_url` is what the provider should POST back to once the user has consented. Override this only if you have a custom callback URL — passing `params[:redirect_to]` as e.g. `dashboard_path` will send the user to the dashboard before any session is established and the upstream code exchange happens. The post-sign-in destination comes from [`after_authentication_url`](/reference/rails/authentication#after_authentication_url), not from `redirect_to`. ### `callback` [#callback] Exchanges the code returned by the provider for a Supabase session. Allowed without authentication (the cookie session has not been written yet at this point). **Params** | Param | Source | Notes | | ------- | ------------ | -------------------------------------------------------------------------------------------------------- | | `code` | Query string | Required. OAuth authorization code returned by the provider. | | `state` | Query string | Required. Used to find the matching `sb-oauth-state-` signed cookie that holds the PKCE verifier. | **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------------------------------- | -------------------------------------- | ------ | -------------------------------------------------- | | Success | `redirect_to after_authentication_url` | 302 | `notice: I18n.t("supabase.rails.oauth.connected")` | | Failure — missing PKCE verifier | `redirect_to new_session_path` | 302 | `alert: result.error.message` (`PKCE_ERROR`, 400) | | Failure — invalid code / 4xx | `redirect_to new_session_path` | 302 | `alert: result.error.message` | | Failure — 5xx | `redirect_to new_session_path` | 302 | `alert: result.error.message` | [`supabase_exchange_code_for_session`](/reference/rails/authentication#helper-reference) fast-fails with `PKCE_ERROR` when the `sb-oauth-state-` cookie is missing — that is the "user clicked an expired link" / "different browser" case. The upstream is never called in that branch. On success the helper internally calls [`start_new_session_for`](/reference/rails/authentication#start_new_session_for) so the encrypted cookie is written and `Current.user` / `Current.session` are populated before the redirect runs. ## `allow_unauthenticated_access` [#allow_unauthenticated_access] ```ruby allow_unauthenticated_access only: %i[authorize callback] ``` Both actions are exempt. The session is not established until `callback` succeeds. ## Hookable callbacks [#hookable-callbacks] | Hook | Default | Where to override | | --------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | `app/controllers/concerns/authentication.rb` (preferred) or the host's `OauthController` | | `supabase_allowed_redirect_origins` (private) | `config.supabase.allowed_redirect_origins`, or `[request.host]` fallback if empty | The host's `OauthController` (private method; override to compute the allowlist dynamically per-request) | ## Linking to the start URL [#linking-to-the-start-url] The shipped [`oauth/_buttons`](/reference/rails/configuration#oauth_providers) partial iterates `config.supabase.oauth_providers` and renders one link per provider. Configure the providers in your initializer: ```ruby # config/initializers/supabase.rb Rails.application.config.supabase.oauth_providers = %i[google github] ``` The partial then renders: ```erb <%= link_to "Continue with Google", oauth_authorize_path(provider: "google"), data: { turbo_method: :post } %> <%= link_to "Continue with GitHub", oauth_authorize_path(provider: "github"), data: { turbo_method: :post } %> ``` …inside the sign-in form. With `oauth_providers = []` (the default) no buttons render at all. ## Override patterns [#override-patterns] **Provider-specific scopes.** [`supabase_sign_in_with_oauth`](/reference/rails/authentication#helper-reference) accepts a `scopes:` keyword forwarded to the provider verbatim (space-separated string per the OAuth 2.0 spec). Override `authorize` to pass scopes per-provider: ```ruby class OauthController < Supabase::Rails::OauthController PROVIDER_SCOPES = { "google" => "openid email profile https://www.googleapis.com/auth/calendar.readonly", "github" => "user:email read:org", }.freeze def authorize result = supabase_sign_in_with_oauth( provider: params[:provider], redirect_to: params[:redirect_to] || oauth_callback_url, scopes: PROVIDER_SCOPES[params[:provider].to_s], ) if result.success? redirect_to result.value, allow_other_host: true else redirect_to new_session_path, alert: t(".failed") end end end ``` **Surfacing the specific error.** The default `authorize` body shows a generic "We couldn't start that sign-in" flash because the failure is most often opaque to the user (bad provider symbol, unconfigured provider, 5xx upstream). To distinguish the cases for debugging, branch on the mapper's stable codes: ```ruby class OauthController < Supabase::Rails::OauthController def authorize result = supabase_sign_in_with_oauth( provider: params[:provider], redirect_to: params[:redirect_to] || oauth_callback_url, ) if result.success? redirect_to result.value, allow_other_host: true else msg = case result.error.code when Supabase::Rails::AuthError::INVALID_REDIRECT then t(".bad_redirect") when Supabase::Rails::AuthError::AUTH_UPSTREAM_ERROR then t(".try_again") else t(".failed") end redirect_to new_session_path, alert: msg end end end ``` **Capturing the provider's profile data.** Supabase Auth populates `user_metadata` from the provider's profile on first sign-in. Read it after `super`: ```ruby class OauthController < Supabase::Rails::OauthController def callback super if authenticated? Profile.upsert( user_id: Current.user.id, full_name: Current.user.user_metadata["full_name"], avatar_url: Current.user.user_metadata["avatar_url"], ) end end end ``` **Replacing with a from-scratch controller** — point the host subclass at [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) directly. Both [`supabase_sign_in_with_oauth`](/reference/rails/authentication#helper-reference) and [`supabase_exchange_code_for_session`](/reference/rails/authentication#helper-reference) are still available from the included [`Authentication`](/reference/rails/authentication) concern, including the PKCE round-trip behaviour and `RedirectValidator` checks. ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the full route table and the override pattern. * [`BaseController`](/reference/rails/controllers/base) — the common parent class. * [`Authentication` concern](/reference/rails/authentication) — `supabase_sign_in_with_oauth`, `supabase_exchange_code_for_session`, `start_new_session_for`. * [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) — the PKCE verifier round-trip via the `sb-oauth-state-` signed cookie. * [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) — how `redirect_to:` is screened against the allowlist. * [`oauth_providers` config](/reference/rails/configuration#oauth_providers) — driving the shipped buttons partial. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — `INVALID_REDIRECT`, `PKCE_ERROR`, `AUTH_UPSTREAM_ERROR`. * [`OtpController`](/reference/rails/controllers/otp) — passwordless sign-in (different two-leg flow). * supabase-rb: [`auth.sign_in_with_oauth`](/reference/ruby/auth/signinwithoauth) — the underlying Ruby method that starts the PKCE flow and exposes `exchange_code_for_session` for the callback leg. # OtpController (/reference/rails/controllers/otp) `Supabase::Rails::OtpController` handles passwordless sign-in via email magic links or SMS one-time codes. The install generator writes a top-level `OtpController` that inherits from it. Under the hood, `create` wraps the `supabase-rb` [`auth.sign_in_with_otp`](/reference/ruby/auth/signinwithotp) call (via `supabase_sign_in_with_otp`); `verify` wraps [`auth.verify_otp`](/reference/ruby/auth/verifyotp) (via `supabase_verify_otp`); the optional `resend` override wraps [`auth.resend`](/reference/ruby/auth/resend) (via `supabase_resend`). ```ruby # app/controllers/otp_controller.rb (written by `bin/rails generate supabase:install`) class OtpController < Supabase::Rails::OtpController end ``` The flow has two steps: request a code/link, then verify the code. The `verify` action handles both the "user pasted the code" (POST) and "user clicked the magic link" (GET) cases on the same URL. ## Source [#source] ```ruby # app/controllers/supabase/rails/otp_controller.rb (in the gem) class OtpController < BaseController allow_unauthenticated_access only: %i[new create verify] def new; end def create result = supabase_sign_in_with_otp( email: params[:email], phone: params[:phone], ) if result.success? redirect_to verify_otp_index_path, notice: I18n.t("supabase.rails.otp.sent") else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end def verify return unless request.post? result = supabase_verify_otp( token: params[:token], type: params[:type] || "email", email: params[:email], phone: params[:phone], ) if result.success? redirect_to after_authentication_url, notice: I18n.t("supabase.rails.otp.verified") else flash.now[:alert] = result.error.message render :verify, status: :unprocessable_entity end end end ``` ## Routes [#routes] | Helper | Verb | URL | Action | | ----------------------- | --------- | ------------- | -------- | | `new_otp_path` | GET | `/otp/new` | `new` | | `otp_index_path` | POST | `/otp` | `create` | | `verify_otp_index_path` | GET, POST | `/otp/verify` | `verify` | Mounted by: ```ruby resources :otp, only: %i[new create] do collection do match :verify, via: %i[get post], as: :verify end end ``` inside [`supabase_authentication_routes`](/reference/rails/controllers#routes). The `verify` collection route responds to **both** GET and POST so the magic-link flow (Supabase Auth GETs the URL after the user clicks the link) and the OTP-code flow (the user pastes the code into the form, POST) hit the same action. ## Actions [#actions] ### `new` [#new] Renders the "Send me a code" form at `/otp/new`. Action body is empty. Allowed without authentication. The shipped view at [`app/views/supabase/rails/otp/new.html.erb`](/reference/rails/generators/views) submits to `otp_index_path` with one field (`email`). ### `create` [#create] Triggers OTP / magic-link delivery and redirects to the verify page. Allowed without authentication. **Params** | Param | Source | Notes | | ------- | --------- | --------------------------------------------------------------------------- | | `email` | POST body | Either `email` or `phone` required. | | `phone` | POST body | Either `email` or `phone` required. E.164 format expected by Supabase Auth. | Additional keyword options accepted by [`supabase_sign_in_with_otp`](/reference/rails/authentication#helper-reference) — `email_redirect_to:`, `should_create_user:`, `data:`, `channel:` (`"sms"` / `"whatsapp"`), `captcha_token:` — are not wired up by the default `create` body. Override to expose them. **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------------- | ----------------------------------- | ------ | ------------------------------------------------------------------ | | Success | `redirect_to verify_otp_index_path` | 302 | `notice: I18n.t("supabase.rails.otp.sent")` | | Failure — 4xx | `render :new` | 422 | `flash.now[:alert] = result.error.message` | | Failure — 5xx | `render :new` | 422 | `flash.now[:alert] = result.error.message` (`AUTH_UPSTREAM_ERROR`) | The success branch fires whether or not the identifier exists in Supabase Auth (prevents user enumeration). No session is created at this step — the user must verify the code first. ### `verify` [#verify] The "enter your code" handler. Allowed without authentication. The action body uses `return unless request.post?` so GETs render the verify form (no token-from-URL exchange happens server-side from the default body). **On GET** — renders `otp/verify.html.erb` (the form lets the user paste the emailed code or it gets pre-filled from URL params by the shipped view). **On POST** — exchanges the supplied `token` for a session. **Params** (POST) | Param | Source | Notes | | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `token` | POST body | Required. The OTP code or magic-link token. | | `type` | POST body | Defaults to `"email"` if omitted. Accepted values include `"email"`, `"sms"`, `"magiclink"`, `"recovery"`, `"invite"`, `"signup"`. | | `email` | POST body | Optional. Required for email OTP. | | `phone` | POST body | Optional. Required for SMS OTP. | **Outcome dispatch** (POST) | Branch | Redirect / render | Status | Flash | | -------------------------------- | -------------------------------------- | ------ | ------------------------------------------------------------------ | | Success | `redirect_to after_authentication_url` | 302 | `notice: I18n.t("supabase.rails.otp.verified")` | | Failure — invalid / expired code | `render :verify` | 422 | `flash.now[:alert] = result.error.message` (`INVALID_CREDENTIALS`) | | Failure — 5xx | `render :verify` | 422 | `flash.now[:alert] = result.error.message` (`AUTH_UPSTREAM_ERROR`) | On success [`supabase_verify_otp`](/reference/rails/authentication#helper-reference) internally calls [`start_new_session_for`](/reference/rails/authentication#start_new_session_for) so the encrypted cookie is written and `Current.user` / `Current.session` are populated before the redirect runs. The post-verify redirect target comes from the [`after_authentication_url`](/reference/rails/authentication#after_authentication_url) hook. The default `verify` body's `return unless request.post?` means a magic-link GET to `/otp/verify?token=...&email=...` renders the verify form — the user must click a "Verify" button on that form to POST and exchange the token. To auto-verify on GET (better UX for magic links), override the action to call `supabase_verify_otp` regardless of the HTTP verb. ## `allow_unauthenticated_access` [#allow_unauthenticated_access] ```ruby allow_unauthenticated_access only: %i[new create verify] ``` All three actions are exempt. Verification is by definition a pre-authentication step. ## Hookable callbacks [#hookable-callbacks] | Hook | Default | Where to override | | -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | `app/controllers/concerns/authentication.rb` (preferred) or the host's `OtpController` | ## Override patterns [#override-patterns] **Auto-verify on magic-link GET.** Drop the `return unless request.post?` so magic-link GETs exchange the token without forcing the user through a form click: ```ruby class OtpController < Supabase::Rails::OtpController def verify result = supabase_verify_otp( token: params[:token], type: params[:type] || "email", email: params[:email], phone: params[:phone], ) if result.success? redirect_to after_authentication_url, notice: t(".verified") else flash.now[:alert] = result.error.message render :verify, status: :unprocessable_entity end end end ``` The flow is now: Supabase Auth email contains `/otp/verify?token=...&email=...&type=magiclink` → user clicks → host issues an authenticated session and redirects to the dashboard, no form interaction needed. **SMS-channel OTP.** Override `create` to pass `phone:` and `channel:` so `supabase_sign_in_with_otp` routes through SMS / WhatsApp: ```ruby class OtpController < Supabase::Rails::OtpController def create result = supabase_sign_in_with_otp( phone: params[:phone], channel: params[:channel] || "sms", ) if result.success? redirect_to verify_otp_index_path(phone: params[:phone], type: "sms"), notice: t(".sent_sms") else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end end ``` Note the `redirect_to verify_otp_index_path(phone: ..., type: "sms")` — the SMS verify call needs the `phone` and `type: "sms"` to make it back into the POST body, and the easiest way is to pass them as URL params and have the verify form echo them as hidden fields. **Resend.** Use [`supabase_resend`](/reference/rails/authentication#helper-reference) to re-trigger delivery without re-entering the email: ```ruby class OtpController < Supabase::Rails::OtpController def resend result = supabase_resend(type: "email", email: params[:email]) if result.success? redirect_to verify_otp_index_path, notice: t(".resent") else flash.now[:alert] = result.error.message redirect_to verify_otp_index_path end end end ``` You will also need to mount a `resend` route — add it inside the `resources :otp` block in `config/routes.rb`: ```ruby resources :otp, only: %i[] do collection do post :resend end end ``` (after `supabase_authentication_routes`, so the gem's route table comes first). **Replacing with a from-scratch controller** — point the host subclass at [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) directly. The [`supabase_sign_in_with_otp`](/reference/rails/authentication#helper-reference), [`supabase_verify_otp`](/reference/rails/authentication#helper-reference), and [`supabase_resend`](/reference/rails/authentication#helper-reference) helpers are still available from the included [`Authentication`](/reference/rails/authentication) concern. ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the full route table and the override pattern. * [`BaseController`](/reference/rails/controllers/base) — the common parent class. * [`Authentication` concern](/reference/rails/authentication) — `supabase_sign_in_with_otp`, `supabase_verify_otp`, `supabase_resend`, `start_new_session_for`. * [`OauthController`](/reference/rails/controllers/oauth) — provider sign-in (different two-leg flow). * [`supabase:views` generator](/reference/rails/generators/views) — copy `otp/new.html.erb` + `otp/verify.html.erb` into the host app. * supabase-rb: [`auth.sign_in_with_otp`](/reference/ruby/auth/signinwithotp), [`auth.verify_otp`](/reference/ruby/auth/verifyotp), and [`auth.resend`](/reference/ruby/auth/resend) — the underlying Ruby methods these actions delegate to. # PasswordsController (/reference/rails/controllers/passwords) `Supabase::Rails::PasswordsController` handles the two-step password reset flow: request a reset email, then set the new password via the recovery deep-link. The install generator writes a top-level `PasswordsController` that inherits from it. Under the hood, `create` wraps the `supabase-rb` [`auth.reset_password_for_email`](/reference/ruby/auth/resetpasswordforemail) call (via `supabase_reset_password`); `update` wraps [`auth.update_user`](/reference/ruby/auth/updateuser) (via `supabase_update_user`). ```ruby # app/controllers/passwords_controller.rb (written by `bin/rails generate supabase:install`) class PasswordsController < Supabase::Rails::PasswordsController end ``` ## Source [#source] ```ruby # app/controllers/supabase/rails/passwords_controller.rb (in the gem) class PasswordsController < BaseController allow_unauthenticated_access only: %i[new create edit update] def new; end def create result = supabase_reset_password(email: params[:email]) if result.success? redirect_to new_session_path, notice: I18n.t("supabase.rails.passwords.reset_sent") else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end def edit; end def update result = supabase_update_user(password: params[:password]) if result.success? redirect_to new_session_path, notice: I18n.t("supabase.rails.passwords.updated") else flash.now[:alert] = result.error.message render :edit, status: :unprocessable_entity end end end ``` ## Routes [#routes] | Helper | Verb | URL | Action | | --------------------------- | --------- | ------------------------ | -------- | | `new_password_path` | GET | `/passwords/new` | `new` | | `passwords_path` | POST | `/passwords` | `create` | | `edit_password_path(token)` | GET | `/passwords/:token/edit` | `edit` | | `password_path(token)` | PATCH/PUT | `/passwords/:token` | `update` | Mounted by `resources :passwords, only: %i[new create edit update], param: :token` inside [`supabase_authentication_routes`](/reference/rails/controllers#routes). The `param: :token` makes the dynamic segment `:token` instead of the default `:id`. The `:token` route segment exists so the URL the recovery email points the user at is shaped like `/passwords//edit`. The action body never reads `params[:token]` — Supabase Auth uses a session cookie (set by the recovery deep-link redirect) as the credential. The path param is presentational only; you can leave it any value the recovery email puts there. ## Actions [#actions] ### `new` [#new] Renders the "Forgot your password?" form at `/passwords/new`. Action body is empty. Allowed without authentication. The shipped view at [`app/views/supabase/rails/passwords/new.html.erb`](/reference/rails/generators/views) submits to `passwords_path` with one field (`email`). ### `create` [#create] Triggers the password-reset email. Allowed without authentication. **Params** | Param | Source | Notes | | ------- | --------- | ---------------------------------------------- | | `email` | POST body | Required. Forwarded to Supabase Auth verbatim. | **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------------- | ------------------------------ | ------ | ------------------------------------------------------- | | Success | `redirect_to new_session_path` | 302 | `notice: I18n.t("supabase.rails.passwords.reset_sent")` | | Failure — 4xx | `render :new` | 422 | `flash.now[:alert] = result.error.message` | | Failure — 5xx | `render :new` | 422 | `flash.now[:alert] = result.error.message` | The success branch fires whether or not the email address exists in Supabase Auth — the upstream call returns success either way, preventing user enumeration. The Supabase project's email templates control the recovery email content and link. By default Supabase Auth sends the user to `/auth/v1/verify?...` which redirects to the `Site URL` configured for the project (Authentication → URL Configuration in the dashboard). Set that `Site URL` to your app's origin so the user lands on `/passwords//edit` with a recovery session cookie already set. Add additional callback URLs under `Redirect URLs` for staging / preview environments. ### `edit` [#edit] Renders the "Update your password" form at `/passwords/:token/edit`. Action body is empty — Rails renders `passwords/edit.html.erb`. Allowed without authentication. The user arrives here after clicking the recovery email link. Supabase Auth's verify endpoint redirects to this URL with a recovery session cookie already set on the host's domain, so `update` can talk to Supabase as the authenticated user even though no manual sign-in has happened. The shipped view at [`app/views/supabase/rails/passwords/edit.html.erb`](/reference/rails/generators/views) submits to `password_path(params[:token])` with two fields (`password`, `password_confirmation`). ### `update` [#update] Sets the new password and redirects to sign-in. Allowed without authentication (the recovery session cookie is what's verifying the user). **Params** | Param | Source | Notes | | ---------- | -------------- | -------------------------------------------------------------------------------------------- | | `password` | PATCH/PUT body | Required. Max 72 bytes (bcrypt limit). | | `token` | URL path | Ignored by the action body. Presentational — Supabase Auth uses the recovery session cookie. | **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------------------------- | ------------------------------ | ------ | ----------------------------------------------------------------------------------------------------- | | Success | `redirect_to new_session_path` | 302 | `notice: I18n.t("supabase.rails.passwords.updated")` | | Failure — weak password | `render :edit` | 422 | `flash.now[:alert] = result.error.message` (`WEAK_PASSWORD` mapper message) | | Failure — missing session | `render :edit` | 422 | `flash.now[:alert] = result.error.message` (`SESSION_MISSING` — recovery cookie expired or never set) | | Failure — other 4xx/5xx | `render :edit` | 422 | `flash.now[:alert] = result.error.message` | The success branch deliberately redirects to `new_session_path`, not `after_authentication_url` — Supabase Auth invalidates the recovery session as soon as the password update lands, so the user must sign in again with the new password. The `SESSION_MISSING` branch fires when the user lands on the page without a valid recovery cookie (link expired, opened in a different browser, etc.). The flash surfaces the mapper's "Session missing" message; restart the flow from `new_password_path`. ## `allow_unauthenticated_access` [#allow_unauthenticated_access] ```ruby allow_unauthenticated_access only: %i[new create edit update] ``` All four actions are exempt. Even `edit` / `update` — the recovery session cookie is what Supabase Auth reads, not the standard authenticated-user cookie. ## Hookable callbacks [#hookable-callbacks] | Hook | Default | Where to override | | -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | Not used by this controller — both success redirects target `new_session_path` directly because Supabase invalidates the recovery session on update. | For a different post-update destination (e.g. an in-app banner instead of forcing a re-sign-in flow), override `update` in the host subclass: ```ruby class PasswordsController < Supabase::Rails::PasswordsController def update result = supabase_update_user(password: params[:password]) if result.success? redirect_to login_help_path, notice: t(".updated") else flash.now[:alert] = result.error.message render :edit, status: :unprocessable_entity end end end ``` ## Override patterns [#override-patterns] **Custom `redirect_to:` on the reset email.** [`supabase_reset_password`](/reference/rails/authentication#helper-reference) accepts a `redirect_to:` keyword that becomes the URL the recovery email's "Reset password" button points at — useful for routing to a `/auth/recovery` page that performs additional verification before exposing the password form. The URL is validated against [`config.supabase.allowed_redirect_origins`](/reference/rails/configuration#allowed_redirect_origins) by [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) before the upstream call: ```ruby class PasswordsController < Supabase::Rails::PasswordsController def create result = supabase_reset_password( email: params[:email], redirect_to: edit_password_url(token: "recovery"), ) if result.success? redirect_to new_session_path, notice: t(".sent") else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end end ``` A `redirect_to:` outside the allowlist fails fast with `Result.failure(AuthError(INVALID_REDIRECT, 400))` without ever bothering the upstream — an attacker who forges a sign-up form with `redirect_to=https://evil.example.com` cannot redirect users via this controller. **Password-confirmation parity check.** The shipped view sends a `password_confirmation` field; the action body ignores it because Supabase Auth handles the canonical password validation. To enforce same-value before calling the upstream, override `update`: ```ruby class PasswordsController < Supabase::Rails::PasswordsController def update if params[:password] != params[:password_confirmation] flash.now[:alert] = t(".confirmation_mismatch") return render :edit, status: :unprocessable_entity end super end end ``` **Replacing with a from-scratch controller** — point the host subclass at [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) and call [`supabase_update_user`](/reference/rails/authentication#helper-reference) (or any of the other low-level helpers) directly. The helper requires a valid encrypted session cookie — when the cookie is missing or carries no access token, it fast-fails with `Result.failure(AuthError.session_missing)` (401, `SESSION_MISSING`) before reaching the upstream. ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the full route table and the override pattern. * [`BaseController`](/reference/rails/controllers/base) — the common parent class. * [`Authentication` concern](/reference/rails/authentication) — `supabase_reset_password`, `supabase_update_user`. * [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) — the `redirect_to:` allowlist. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — `WEAK_PASSWORD`, `SESSION_MISSING`, `AUTH_UPSTREAM_ERROR`. * [`supabase:views` generator](/reference/rails/generators/views) — copy `passwords/new.html.erb` + `passwords/edit.html.erb` into the host app. * supabase-rb: [`auth.reset_password_for_email`](/reference/ruby/auth/resetpasswordforemail) and [`auth.update_user`](/reference/ruby/auth/updateuser) — the underlying Ruby methods these actions delegate to. # RegistrationsController (/reference/rails/controllers/registrations) `Supabase::Rails::RegistrationsController` handles email + password sign-up. The install generator writes a top-level `RegistrationsController` that inherits from it. Under the hood, `create` wraps the `supabase-rb` [`auth.sign_up`](/reference/ruby/auth/signup) call (via `supabase_sign_up`). ```ruby # app/controllers/registrations_controller.rb (written by `bin/rails generate supabase:install`) class RegistrationsController < Supabase::Rails::RegistrationsController end ``` ## Source [#source] ```ruby # app/controllers/supabase/rails/registrations_controller.rb (in the gem) class RegistrationsController < BaseController allow_unauthenticated_access only: %i[new create] def new; end def create result = supabase_sign_up(email: params[:email], password: params[:password]) if result.success? if registered_session_present?(result.value) redirect_to after_authentication_url, notice: I18n.t("supabase.rails.registrations.created") else redirect_to new_session_path, notice: I18n.t("supabase.rails.registrations.pending_confirmation") end else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end private def registered_session_present?(value) value.respond_to?(:session) && !value.session.nil? end end ``` ## Routes [#routes] | Helper | Verb | URL | Action | | ----------------------- | ---- | ------------------- | -------- | | `new_registration_path` | GET | `/registration/new` | `new` | | `registration_path` | POST | `/registration` | `create` | Mounted by `resource :registration, only: %i[new create]` inside [`supabase_authentication_routes`](/reference/rails/controllers#routes). Singular `registration` keeps the URL `/registration` (not `/registrations`). ## Actions [#actions] ### `new` [#new] Renders the sign-up form at `/registration/new`. Action body is empty — Rails renders `registrations/new.html.erb`. Allowed without authentication. The shipped view at [`app/views/supabase/rails/registrations/new.html.erb`](/reference/rails/generators/views) submits to `registration_path` with two fields (`email`, `password`). ### `create` [#create] Creates a new Supabase Auth user and dispatches on the upstream response shape. Allowed without authentication. **Params** | Param | Source | Notes | | ---------- | --------- | ----------------------------------------------------------------------------------------------------- | | `email` | POST body | Required. Forwarded to Supabase Auth verbatim. | | `password` | POST body | Required. Max 72 bytes (bcrypt limit). Supabase Auth enforces the project's password-strength policy. | **Outcome dispatch — two-branch happy path** The Supabase project's "Confirm email" toggle (Authentication → Sign In / Up in the dashboard) decides which branch fires: | Branch | Trigger | Redirect / render | Status | Flash | | ------------------------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------ | --------------------------------------------------------------------- | | Success — auto sign-in | Project has email confirmation **off** → upstream returns `AuthResponse` with `.session` non-`nil` | `redirect_to after_authentication_url` | 302 | `notice: I18n.t("supabase.rails.registrations.created")` | | Success — confirmation pending | Project has email confirmation **on** → upstream returns `AuthResponse` with `.session` == `nil` | `redirect_to new_session_path` | 302 | `notice: I18n.t("supabase.rails.registrations.pending_confirmation")` | | Failure — weak password | Upstream raises `AuthWeakPassword` → mapped `code: WEAK_PASSWORD, status: 422` | `render :new` | 422 | `flash.now[:alert] = result.error.message` | | Failure — other 4xx | Upstream 4xx (invalid email, already-registered conflict) → masked to `code: INVALID_CREDENTIALS, status: 401` | `render :new` | 422 | `flash.now[:alert] = "Invalid credentials"` | | Failure — 5xx | Upstream 5xx → mapped `code: AUTH_UPSTREAM_ERROR, status: 503` | `render :new` | 422 | `flash.now[:alert] = "..."` (mapper message) | The two success branches share the same upstream call ([`supabase_sign_up`](/reference/rails/authentication#helper-reference)). The branch fires on `result.value.session` — when auto-sign-in is on, the upstream returns a session immediately and [`supabase_sign_up`](/reference/rails/authentication#helper-reference) internally calls [`start_new_session_for`](/reference/rails/authentication#start_new_session_for) so the cookie is already written by the time the redirect runs. Fresh Supabase projects ship with "Confirm email" **on**. Following the default sign-up flow lands the user at `new_session_path` with the "check your inbox" notice — not the dashboard. If you want auto-sign-in in development, flip the toggle off in the Supabase dashboard before testing. **Error policy.** The error matrix above is enforced by [`translate_sign_up_error`](/reference/rails/authentication) in the concern. `WEAK_PASSWORD`, `PKCE_ERROR`, and `SESSION_MISSING` codes are preserved so the host can render a specific UI for each; every other 4xx is masked to `INVALID_CREDENTIALS` for parity with the sign-in helper. The status used for the re-render is always `:unprocessable_entity` (422) regardless of the mapped error's `status` — the form is re-rendered with the flash, and a real 401/422/503 only flows up if the host overrides the action. ## `allow_unauthenticated_access` [#allow_unauthenticated_access] ```ruby allow_unauthenticated_access only: %i[new create] ``` Sign-up is by definition unauthenticated, so both actions skip [`require_authentication`](/reference/rails/authentication#require_authentication). ## Hookable callbacks [#hookable-callbacks] | Hook | Default | Where to override | | --------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | `app/controllers/concerns/authentication.rb` (preferred) or the host's `RegistrationsController` | | `registered_session_present?` (private) | `value.respond_to?(:session) && !value.session.nil?` | The host's `RegistrationsController` (private method; override to change the branch trigger, e.g. if you want to always route through email confirmation even when auto-sign-in fires upstream) | ## Override patterns [#override-patterns] **Passing user metadata.** [`supabase_sign_up`](/reference/rails/authentication#helper-reference) accepts `data:` for `raw_user_meta_data` and `redirect_to:` for the post-confirmation URL. Override `create` and call the helper directly: ```ruby class RegistrationsController < Supabase::Rails::RegistrationsController def create result = supabase_sign_up( email: params[:email], password: params[:password], data: { full_name: params[:full_name], referrer: params[:referrer] }, redirect_to: confirmation_callback_url, ) if result.success? if result.value.respond_to?(:session) && result.value.session redirect_to after_authentication_url, notice: t(".welcome") else redirect_to new_session_path, notice: t(".pending") end else flash.now[:alert] = result.error.message render :new, status: :unprocessable_entity end end end ``` The metadata is then readable as `Current.user.user_metadata["full_name"]` after the user signs in. **Welcome email / onboarding.** Use `super` and branch on `authenticated?`: ```ruby class RegistrationsController < Supabase::Rails::RegistrationsController def create super OnboardingMailer.welcome(Current.user.email).deliver_later if authenticated? end end ``` The `authenticated?` check fires `true` only on the auto-sign-in branch — on the confirmation-pending branch `Current.user` is still `nil` at this point, so the welcome email correctly waits for confirmation. **Replacing with a from-scratch controller** — point the host subclass at [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) directly and branch on the mapper's stable codes: ```ruby class RegistrationsController < Supabase::Rails::BaseController allow_unauthenticated_access only: %i[new create] def new; end def create result = supabase_sign_up(email: params[:email], password: params[:password]) if result.success? handle_signup_success(result.value) else flash.now[:alert] = case result.error.code when Supabase::Rails::AuthError::WEAK_PASSWORD then t(".weak_password") when Supabase::Rails::AuthError::AUTH_UPSTREAM_ERROR then t(".try_again") else t(".invalid") end render :new, status: :unprocessable_entity end end private def handle_signup_success(value) if value.respond_to?(:session) && value.session redirect_to after_authentication_url, notice: t(".created") else redirect_to new_session_path, notice: t(".pending") end end end ``` ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the full route table and the override pattern. * [`BaseController`](/reference/rails/controllers/base) — the common parent class. * [`Authentication` concern](/reference/rails/authentication) — `supabase_sign_up`, `start_new_session_for`, `authenticated?`. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — the code → status table the failure branch reads. * [`SessionsController`](/reference/rails/controllers/sessions) — sign-in for confirmed users. * supabase-rb: [`auth.sign_up`](/reference/ruby/auth/signup) — the underlying Ruby method this action delegates to. # SessionsController (/reference/rails/controllers/sessions) `Supabase::Rails::SessionsController` is the email + password sign-in / sign-out controller. The install generator writes a top-level `SessionsController` that inherits from it; routes flow through that subclass into the gem's action bodies. Under the hood, `create` wraps the `supabase-rb` [`auth.sign_in_with_password`](/reference/ruby/auth/signinwithpassword) call (via `authenticate_with_supabase`); `destroy` wraps [`auth.sign_out`](/reference/ruby/auth/signout) (via `terminate_session`). ```ruby # app/controllers/sessions_controller.rb (written by `bin/rails generate supabase:install`) class SessionsController < Supabase::Rails::SessionsController end ``` ## Source [#source] ```ruby # app/controllers/supabase/rails/sessions_controller.rb (in the gem) class SessionsController < BaseController allow_unauthenticated_access only: %i[new create] def new; end def create if (supabase_session = authenticate_with_supabase(email: params[:email], password: params[:password])) start_new_session_for(supabase_session) redirect_to after_authentication_url, notice: I18n.t("supabase.rails.sessions.created") else flash.now[:alert] = I18n.t("supabase.rails.sessions.invalid") render :new, status: :unauthorized end end def destroy terminate_session redirect_to root_path, notice: I18n.t("supabase.rails.sessions.destroyed") end end ``` ## Routes [#routes] | Helper | Verb | URL | Action | | ------------------ | ------ | -------------- | --------- | | `new_session_path` | GET | `/session/new` | `new` | | `session_path` | POST | `/session` | `create` | | `session_path` | DELETE | `/session` | `destroy` | Mounted by `resource :session, only: %i[new create destroy]` inside [`supabase_authentication_routes`](/reference/rails/controllers#routes). The `resource` (singular) form is what makes the URL `/session` (not `/sessions`). ## Actions [#actions] ### `new` [#new] Renders the sign-in form at `/session/new`. Action body is empty — Rails renders `sessions/new.html.erb`. Allowed without authentication. The shipped view at [`app/views/supabase/rails/sessions/new.html.erb`](/reference/rails/generators/views) submits to `session_path` with two fields (`email`, `password`), the [`oauth/_buttons`](/reference/rails/configuration#oauth_providers) partial, and links to `new_password_path` and `new_registration_path`. ### `create` [#create] Authenticates the supplied `params[:email]` + `params[:password]` against Supabase Auth and writes the encrypted session cookie on success. Allowed without authentication. **Params** | Param | Source | Notes | | ---------- | --------- | ------------------------------------------------------------------------------------------- | | `email` | POST body | Required. Forwarded to Supabase Auth verbatim. | | `password` | POST body | Required. Max 72 bytes (bcrypt limit; the shipped view enforces this with `maxlength: 72`). | **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | -------------------------------------------------- | -------------------------------------- | ------ | --------------------------------------------------------------- | | Success — session returned | `redirect_to after_authentication_url` | 302 | `notice: I18n.t("supabase.rails.sessions.created")` | | Failure — 4xx upstream (invalid credentials, etc.) | `render :new` | 401 | `flash.now[:alert] = I18n.t("supabase.rails.sessions.invalid")` | | Failure — 5xx upstream | raises `AuthError` (status 503) | 503 | — | The 4xx failure flash is **generic** — the action body calls [`authenticate_with_supabase`](/reference/rails/authentication#authenticate_with_supabase), which returns `nil` on any 4xx (invalid email, invalid password, user not found, weak password) so the response leaks no information about which field was wrong. The 5xx case raises out of the action so the host's standard 500-page handler runs. On success [`start_new_session_for`](/reference/rails/authentication#start_new_session_for) writes the encrypted `sb-session` cookie and populates `Current.user` / `Current.session`. The post-sign-in redirect target comes from the [`after_authentication_url`](/reference/rails/authentication#after_authentication_url) hook (defaults to `stored_location_for_redirect || root_url`). ### `destroy` [#destroy] Signs the user out. Required to be authenticated (no `allow_unauthenticated_access` for this action). **Params** None. The session being terminated is the request's own cookie. **Outcome dispatch** | Branch | Redirect / render | Status | Flash | | ------ | ----------------------- | ------ | ----------------------------------------------------- | | Always | `redirect_to root_path` | 302 | `notice: I18n.t("supabase.rails.sessions.destroyed")` | [`terminate_session`](/reference/rails/authentication#terminate_session) makes a best-effort upstream `auth.sign_out` call, then clears the encrypted cookie and `Current.user` / `Current.session` regardless of whether the upstream call succeeded. The local clear is the source of truth — an upstream 5xx will not leave the user signed in locally. ## `allow_unauthenticated_access` [#allow_unauthenticated_access] ```ruby allow_unauthenticated_access only: %i[new create] ``` The class macro [delegates](/reference/rails/authentication#allow_unauthenticated_access) to `skip_before_action :require_authentication`. `new` and `create` are exempted because an unauthenticated user must reach them to sign in. `destroy` stays gated — only authenticated users can sign out. ## Hookable callbacks [#hookable-callbacks] Two override hooks shape `create`'s redirect behaviour. Both are defined on the [`Authentication` concern](/reference/rails/authentication#override-hooks): | Hook | Default | Where to override | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `after_authentication_url` | `stored_location_for_redirect \|\| root_url` | `app/controllers/concerns/authentication.rb` (preferred — applies to OTP/OAuth/registration too) or the host's `SessionsController` | | `store_location_for_redirect` | Stashes `request.url` in `session[:return_to_after_authenticating]` on GET requests. Called by [`request_authentication`](/reference/rails/authentication#request_authentication) when a gated action 302s to `new_session_path`. | `app/controllers/concerns/authentication.rb` | For analytics / audit hooks alongside the gem's behaviour, redefine `create` in the host subclass and call `super`: ```ruby class SessionsController < Supabase::Rails::SessionsController def create super AnalyticsJob.perform_later(event: "sign_in", user_id: Current.user.id) if authenticated? end end ``` For a `Current.user.admin?`-aware redirect, override `after_authentication_url` in the concern (so it applies to every post-auth redirect, including OTP/OAuth/registration): ```ruby # app/controllers/concerns/authentication.rb module Authentication extend ActiveSupport::Concern include Supabase::Rails::Authentication private def after_authentication_url Current.user&.admin? ? admin_dashboard_path : stored_location_for_redirect || root_path end end ``` ## Override patterns [#override-patterns] **Subclassing** (the install-generator default) — the 3-line `SessionsController < Supabase::Rails::SessionsController` inherits every action body. Redefine the actions you want to change; the rest stay on the gem. ```ruby class SessionsController < Supabase::Rails::SessionsController def create super Rails.logger.info("Signed in: #{Current.user&.id}") end end ``` **Replacing the action wholesale** — redefine without calling `super`. The `authenticate_with_supabase`, `start_new_session_for`, and `terminate_session` helpers are all still available from the included [`Authentication`](/reference/rails/authentication) concern: ```ruby class SessionsController < Supabase::Rails::SessionsController def create email = params[:email].to_s.downcase.strip session = authenticate_with_supabase(email: email, password: params[:password]) if session start_new_session_for(session) redirect_to dashboard_path, notice: t(".welcome", name: Current.user.email) else flash.now[:alert] = t(".invalid") render :new, status: :unauthorized end end end ``` **Replacing with a from-scratch controller** — point the host subclass at [`Supabase::Rails::BaseController`](/reference/rails/controllers/base) directly. The [`supabase_*` low-level helpers](/reference/rails/authentication#helper-reference) (`supabase_sign_in_with_password`) give you the same upstream call wrapped in a `Result` so you can branch on stable error codes (`INVALID_CREDENTIALS`, `WEAK_PASSWORD`, `AUTH_UPSTREAM_ERROR`): ```ruby class SessionsController < Supabase::Rails::BaseController allow_unauthenticated_access only: %i[new create] def new; end def create result = supabase_sign_in_with_password(email: params[:email], password: params[:password]) if result.success? redirect_to after_authentication_url, notice: t(".welcome") else flash.now[:alert] = case result.error.code when Supabase::Rails::AuthError::WEAK_PASSWORD t(".weak_password") when Supabase::Rails::AuthError::AUTH_UPSTREAM_ERROR t(".try_again") else t(".invalid") end render :new, status: result.error.status end end def destroy terminate_session redirect_to root_path, notice: t(".bye") end end ``` ## See also [#see-also] * [Controllers overview](/reference/rails/controllers) — the full route table and the override pattern. * [`BaseController`](/reference/rails/controllers/base) — the common parent class. * [`Authentication` concern](/reference/rails/authentication) — `authenticate_with_supabase`, `start_new_session_for`, `terminate_session`, `after_authentication_url`. * [`supabase_sign_in_with_password`](/reference/rails/authentication#helper-reference) — `Result`-returning low-level alternative used by from-scratch overrides. * [`supabase:views` generator](/reference/rails/generators/views) — copy `sessions/new.html.erb` into the host app to restyle the form. * supabase-rb: [`auth.sign_in_with_password`](/reference/ruby/auth/signinwithpassword) and [`auth.sign_out`](/reference/ruby/auth/signout) — the underlying Ruby methods these actions delegate to. # Generators (/reference/rails/generators) `supabase-rails` ships three generators under the `Supabase::Generators::*` namespace: `supabase:install` (controllers, concern, initializer, routes patch), `supabase:user_model` (UUID-keyed `users` table + upsert model), and `supabase:views` (copy default view templates for customisation). # supabase:install (/reference/rails/generators/install) `bin/rails generate supabase:install` is the one-shot setup generator for `supabase-rails`. It scaffolds the host-app glue for `:web` mode — an `Authentication` concern, five thin controller subclasses, a `Current` model, and a `config/initializers/supabase.rb` — and patches `config/routes.rb` and `app/controllers/application_controller.rb` so everything is wired up after a single `bin/rails server`. Run it once after `bundle add supabase-rails`. The generator is idempotent: it skips files that already match its templates, runs Thor's standard overwrite prompt for files that have diverged, and no-ops the two file patches when the directives are already present. ```bash bin/rails generate supabase:install ``` Expected output on a fresh Rails 8 app: ``` create app/controllers/concerns/authentication.rb create app/controllers/sessions_controller.rb create app/controllers/registrations_controller.rb create app/controllers/passwords_controller.rb create app/controllers/otp_controller.rb create app/controllers/oauth_controller.rb create app/models/current.rb create config/initializers/supabase.rb insert config/routes.rb insert app/controllers/application_controller.rb ``` ## Files created [#files-created] Eight files. Each one is small on purpose — the gem ships the real implementation; the host app only owns the override hooks. | Path | What it is | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `app/controllers/concerns/authentication.rb` | A 7-line concern that `include`s [`Supabase::Rails::Authentication`](/reference/rails/authentication). `ApplicationController` includes this concern, which is what gives every host controller `authenticated?`, `current_user`, `require_authentication`, and the full `supabase_*` helper surface. | | `app/controllers/sessions_controller.rb` | 3-line subclass of [`Supabase::Rails::SessionsController`](/reference/rails/controllers). Handles `GET /session/new`, `POST /session`, `DELETE /session`. Empty body — override actions here to customise. | | `app/controllers/registrations_controller.rb` | 3-line subclass of [`Supabase::Rails::RegistrationsController`](/reference/rails/controllers). Handles `GET /registration/new`, `POST /registration`. | | `app/controllers/passwords_controller.rb` | 3-line subclass of [`Supabase::Rails::PasswordsController`](/reference/rails/controllers). Handles the "forgot password" / "reset password" flow at `/passwords` and `/passwords/:token`. | | `app/controllers/otp_controller.rb` | 3-line subclass of [`Supabase::Rails::OtpController`](/reference/rails/controllers). Handles email/SMS one-time codes at `/otp/new`, `/otp`, `/otp/verify`. | | `app/controllers/oauth_controller.rb` | 3-line subclass of [`Supabase::Rails::OauthController`](/reference/rails/controllers). Handles `/oauth/:provider/authorize` (kicks off PKCE) and `/oauth/callback` (exchanges the code). | | `app/models/current.rb` | `class Current < ActiveSupport::CurrentAttributes` with `attribute :user, :session`. Per-request thread-local store the `Authentication` concern populates after a successful verify. | | `config/initializers/supabase.rb` | Sets `Rails.application.config.supabase.mode = :web`. Ships with commented examples for `allowed_redirect_origins`, `expose_current_user`, and the `session` cookie hash. The framework default is `:api`, so this file is what flips the gem into cookie-session mode. | ### Generated file contents [#generated-file-contents] The concern (`app/controllers/concerns/authentication.rb`): ```ruby # frozen_string_literal: true module Authentication extend ActiveSupport::Concern included do include Supabase::Rails::Authentication end end ``` A controller subclass (`app/controllers/sessions_controller.rb` — the other four are identical except for the parent class): ```ruby # frozen_string_literal: true class SessionsController < Supabase::Rails::SessionsController end ``` The `Current` model (`app/models/current.rb`): ```ruby # frozen_string_literal: true class Current < ActiveSupport::CurrentAttributes attribute :user, :session end ``` The initializer (`config/initializers/supabase.rb`): ```ruby # frozen_string_literal: true Rails.application.config.supabase.mode = :web # Origins the OAuth + password-reset helpers will accept as redirect targets. # Path-only redirects are always allowed; absolute URLs must match an entry # below. Defaults to [request.host] at runtime when this list is empty. # Rails.application.config.supabase.allowed_redirect_origins = ["https://example.com"] # Expose `current_user` as a view helper. nil = derive from mode # (true in :web, false in :api). # Rails.application.config.supabase.expose_current_user = nil # Encrypted session cookie defaults. `secure: nil` = auto-detect from Rails.env. # Rails.application.config.supabase.session = { # cookie_name: "sb-session", # same_site: :lax, # secure: nil, # domain: nil, # path: "/" # } ``` See [Configuration](/reference/rails/configuration) for every key the initializer can set, including the ones the template omits (`auth`, `cors`, `env`, `supabase_options`, `oauth_providers`, `user_model`, `insert_middleware`). ## Files modified [#files-modified] Two files in the host app are patched in place. Both patches are guarded by a substring check, so re-running the generator after a successful first run is a no-op on each. ### `config/routes.rb` [#configroutesrb] A single line is injected on the line directly after `Rails.application.routes.draw do`: ```diff # config/routes.rb Rails.application.routes.draw do + supabase_authentication_routes # ... your existing routes end ``` `supabase_authentication_routes` is a DSL helper installed onto `ActionDispatch::Routing::Mapper` by `Supabase::Rails::Engine`. One line expands to the full session / registration / passwords / OTP / OAuth route table — see the [routes reference](/reference/rails/controllers) for the per-route breakdown, including the `only:` / `except:` filters for mounting a subset. The patch is skipped if `config/routes.rb` already contains the string `supabase_authentication_routes`, or when the file does not exist (e.g. an `--api`-only Rails app without a generated routes file). ### `app/controllers/application_controller.rb` [#appcontrollersapplication_controllerrb] A single line is injected into the `ApplicationController` class body via Thor's `inject_into_class`: ```diff # app/controllers/application_controller.rb class ApplicationController < ActionController::Base + include Authentication # ... your existing code end ``` This is the one line that activates everything else — every controller that inherits from `ApplicationController` now gets the `supabase_*` helpers, the `before_action :require_authentication`, and the `current_user` / `authenticated?` view helpers. The patch is skipped if the file already contains the string `include Authentication`, or when `app/controllers/application_controller.rb` does not exist. The install generator writes no migrations — Supabase Auth owns the user table, and `Current` is `ActiveSupport::CurrentAttributes` (per-request, in-memory). If you want a shadow `users` row in your own database so `belongs_to :user` resolves, run [`bin/rails generate supabase:user_model`](/reference/rails/generators) afterwards. ## Diff summary [#diff-summary] A complete "what changed" view of a freshly-generated app, by file: ```diff + app/controllers/concerns/authentication.rb + app/controllers/sessions_controller.rb + app/controllers/registrations_controller.rb + app/controllers/passwords_controller.rb + app/controllers/otp_controller.rb + app/controllers/oauth_controller.rb + app/models/current.rb + config/initializers/supabase.rb config/routes.rb + supabase_authentication_routes app/controllers/application_controller.rb + include Authentication ``` Total: **8 files created, 2 files modified, 0 migrations, 0 lines deleted.** ## Options [#options] The generator declares no custom flags — it inherits Thor's standard generator option set. The flags worth knowing: | Flag | Effect | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--force`, `-f` | Overwrite every file that already exists, without prompting. Use when re-running the generator after you've intentionally rolled back an earlier version. | | `--skip`, `-s` | Keep host copies for every file that already exists. Useful when you want the routes/`ApplicationController` patches but not to touch existing controller files. | | `--pretend`, `-p` | Dry-run. Prints the create/insert log lines without writing anything. | | `--quiet`, `-q` | Suppress the create/insert log lines. | | `--skip-namespace` | Don't include the generator's namespace in generated paths. No-op for this generator — paths are absolute (`app/controllers/...`, not `app/controllers//...`). | | `--skip-collision-check` | Skip Rails' check for filename collisions with existing Ruby constants. | | `--help`, `-h` | Print the generator's description and option list. | When a file already exists and its content differs from the template, Thor prompts interactively with `[Ynaqdh]`: | Key | Meaning | | ---------- | ------------------------------------------------------------------------------------ | | `Y` (yes) | Overwrite this file. | | `n` (no) | Keep the existing file. | | `a` (all) | Overwrite all remaining conflicts (equivalent to `--force` for the rest of the run). | | `q` (quit) | Abort the generator. Files already written stay written. | | `d` (diff) | Show a unified diff of host file vs. template, then re-prompt. | | `h` (help) | List the keys above. | ## After the generator [#after-the-generator] Three things to do before `bin/rails server`: 1. **Set `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, and `SUPABASE_SECRET_KEY`** — the gem reads these at request time (not at boot). See the env-var resolution table in [Configuration](/reference/rails/configuration#environment-variables). The full Supabase-dashboard → env-var mapping is in step 4 of the [Getting started guide](/reference/rails/getting-started). 2. **Open the initializer and uncomment what you need.** The defaults are sensible for local dev (path-only redirects allowed, host-only session cookie, `expose_current_user` derived from mode). Set `allowed_redirect_origins` before you ship to production, and set `oauth_providers` if you want the `oauth/_buttons` partial to render anything. 3. **Verify with a sign-up.** [`/registration/new`](/reference/rails/getting-started#8-sign-up-your-first-user) should render the gem's default form. After submitting, `Current.user` is populated from the verified JWT claims — drop `<%= Current.user&.email %>` anywhere in a view to confirm. ## Customising the generated controllers [#customising-the-generated-controllers] The five generated controllers exist solely as override points. Add an action and call `super` — the base class handles the Supabase round-trip, your override handles the host-app-specific behaviour: ```ruby # app/controllers/sessions_controller.rb class SessionsController < Supabase::Rails::SessionsController def create super # Custom analytics, audit log, welcome flash, etc. AnalyticsJob.perform_later(:signed_in, user_id: Current.user.id) if authenticated? end end ``` Override the entire action by **not** calling `super`; the base class's behaviour is documented per-controller in the [controllers reference](/reference/rails/controllers). For overrides of the `supabase_*` helpers themselves (e.g. custom error formatting), override the corresponding hook on the [`Authentication`](/reference/rails/authentication) concern instead. To replace the gem's default views with your own ERB, run `bin/rails generate supabase:views` — it copies the eight default templates into `app/views/supabase/rails/` where Rails' view-resolution order picks the host copies ahead of the gem's. See the [`supabase:views` generator page](/reference/rails/generators) for details. ## Re-running and rolling back [#re-running-and-rolling-back] The generator is safe to re-run any number of times: * Files with content identical to the template print `identical` and are not rewritten. * Files that have diverged trigger Thor's `[Ynaqdh]` overwrite prompt (unless `--force` or `--skip` is passed). * The two file patches (`routes.rb`, `application_controller.rb`) substring-check before injecting — never duplicated. There is no `supabase:uninstall` generator. To roll back manually: 1. Remove the eight generated files (the concern, five controllers, `Current`, the initializer). 2. Delete the `supabase_authentication_routes` line from `config/routes.rb`. 3. Delete `include Authentication` from `app/controllers/application_controller.rb`. 4. `bundle remove supabase-rails`. ## See also [#see-also] * [Getting started](/reference/rails/getting-started) — the end-to-end quickstart that invokes this generator as step 3. * [Configuration](/reference/rails/configuration) — every `config.supabase.*` key the initializer can set. * [Authentication](/reference/rails/authentication) — the `Authentication` concern's full helper surface and override hooks, plus the `Current.user` / `Current.session` value objects. * [Controllers](/reference/rails/controllers) — the five base controllers the generated subclasses inherit from, plus the full route table `supabase_authentication_routes` produces. * [`supabase:user_model`](/reference/rails/generators) — opt-in shadow AR `User` model + migration. * [`supabase:views`](/reference/rails/generators) — copy the default ERB templates into the host app for customisation. # supabase:user_model (/reference/rails/generators/user-model) `bin/rails generate supabase:user_model` adds an opt-in shadow `User` ActiveRecord model whose primary key is the Supabase user UUID. It emits a `users` migration (UUID PK, `email` column, timestamps), writes `app/models/user.rb` with a `User.from_supabase(claims)` upsert helper, and patches `config/initializers/supabase.rb` to set `config.supabase.user_model = "User"`. Once that config key is set, `Current.user` is the AR record returned by `User.from_supabase(claims)` instead of the default `Supabase::Rails::User` value object — so `belongs_to :user` reflections on the rest of your domain models Just Work. ```bash bin/rails generate supabase:user_model bin/rails db:migrate ``` Expected output on a fresh Rails app that has already run `supabase:install`: ``` create db/migrate/_create_supabase_users.rb create app/models/user.rb append config/initializers/supabase.rb ``` Run it when your app has per-user domain data (posts, projects, subscriptions, etc.) and you want to keep AR-style `belongs_to :user` / `has_many` reflections. Skip it when the only user data your app needs is what Supabase already stores — the default value-object `Current.user` is faster (no DB row, no `find_by`) and zero-maintenance. ## Files created [#files-created] | Path | What it is | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `db/migrate/_create_supabase_users.rb` | Reversible migration: `create_table :users, id: :uuid` with `email:string` + `timestamps`. UUID PK matches Supabase's `auth.users.id` so JWT `sub` claims drop straight in. | | `app/models/user.rb` | `class User < ApplicationRecord` with `self.primary_key = :id` and a `User.from_supabase(claims)` upsert class method. | ### Generated file contents [#generated-file-contents] The migration (`db/migrate/_create_supabase_users.rb`): ```ruby # frozen_string_literal: true class CreateSupabaseUsers < ActiveRecord::Migration[7.1] def change create_table :users, id: :uuid do |t| t.string :email t.timestamps end end end ``` The `:uuid` primary-key type requires `pgcrypto` (or `uuid-ossp`) to be enabled on your Postgres database. Supabase projects enable `pgcrypto` by default, so no extra step is needed there; on a self-hosted Postgres add `enable_extension "pgcrypto"` to an earlier migration if it isn't already present. The migration timestamp prefix comes from `UserModelGenerator.next_migration_number`, which mirrors Rails' built-in `Time.now.utc.strftime("%Y%m%d%H%M%S")` so the generator works even when ActiveRecord isn't loaded. The model (`app/models/user.rb`): ```ruby # frozen_string_literal: true class User < ApplicationRecord self.primary_key = :id def self.from_supabase(claims) find_or_create_by!(id: claims["sub"]) do |u| u.email = claims["email"] end end end ``` Two things to notice: * **`self.primary_key = :id`** — without this, ActiveRecord assumes an integer `id` even though the column is `uuid`. The explicit declaration keeps `User.find("abc-uuid")` and `belongs_to :user, primary_key: :id` working. * **The block on `find_or_create_by!`** only fires on the *create* path. On the resume of an existing user the block is skipped, so a stale `claims["email"]` will never overwrite a row's `email` column. See [Sync model](#sync-model) below. ## Files modified [#files-modified] One file is patched in place, guarded by a regex so re-running the generator is a no-op. ### `config/initializers/supabase.rb` [#configinitializerssupabaserb] A single line is appended to the end of the initializer: ```diff # config/initializers/supabase.rb Rails.application.config.supabase.mode = :web # ...existing config... + +Rails.application.config.supabase.user_model = "User" ``` The patch is skipped when the file already contains a non-commented `config.supabase.user_model = ` assignment (matched by `/^\s*[^#\n]*config\.supabase\.user_model\s*=/`), or when `config/initializers/supabase.rb` does not exist (e.g. you haven't run `supabase:install` yet). `config.supabase.user_model` accepts either a class name string (`"User"`) or the class itself. The string form is what the generator writes so the model can be autoloaded lazily — see [Configuration → `user_model`](/reference/rails/configuration#user_model) for the full semantics. ## Sync model [#sync-model] The shadow `users` table mirrors a small slice of Supabase's `auth.users`. The sync is **lazy and read-only from Supabase's side** — the gem never writes back to Supabase, and your local row is reconciled with JWT claims only at well-defined moments. ### When local records are created [#when-local-records-are-created] A local `users` row is inserted the first time `User.from_supabase(claims)` is called for a Supabase user UUID it hasn't seen before. That happens in two places in the `Authentication` concern: | Trigger | What runs | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Sign-in success (`start_new_session_for`) | The concern calls `build_current_user(session)` which calls `User.from_supabase(jwt_claims_from(session))`. On a brand-new user the row is `INSERT`-ed with `id = claims["sub"]` and `email = claims["email"]`. | | Subsequent request resume | `resume_session` calls `current_user_from_context(ctx)` which calls `User.from_supabase(ctx.jwt_claims)`. Same code path — `find_or_create_by!` is a cheap by-PK lookup on the hot path, and only inserts when an existing row is missing (e.g. you cleared the local DB but the user's encrypted session cookie is still valid). | `find_or_create_by!` is racy on its own, but `id` is the primary key, so a duplicate insert raises a unique-constraint error rather than producing two rows — and the next call resolves to the now-existing row. For high-concurrency sign-in flows you can wrap the upsert in `transaction { … }` or replace `find_or_create_by!` with a Postgres `INSERT … ON CONFLICT DO NOTHING` upsert. ### When local records are updated [#when-local-records-are-updated] **Never automatically.** The generated `from_supabase` updates the row only via the block on `find_or_create_by!`, which Rails skips entirely when the record already exists. This is intentional: * The user's row in Supabase is the source of truth — overwriting your local `email` from JWT claims would lose any column you added locally (display name, preferences, etc.) if it got out of sync. * If you *want* fields to be re-mirrored on every request, override `from_supabase` to do so explicitly. See [Customising the User model](#customising-the-user-model). ### What fields mirror Supabase [#what-fields-mirror-supabase] The generated `users` table mirrors only `id` and `email`. Everything else available on the verified JWT — `role`, `app_metadata`, `user_metadata`, raw claims — stays on the `supabase_context` value object and is **not** copied into the row. | Local column | Mirrored from | Notes | | ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `users.id` (`uuid` PK) | `claims["sub"]` | The Supabase user UUID. Stable for the lifetime of the user. | | `users.email` | `claims["email"]` | Set on create only — see above. May go stale if the user changes their email in Supabase and your code doesn't re-sync. | | `users.created_at` | (local Rails timestamp) | When the *local* row was inserted — typically the user's first request after the shadow model was enabled. NOT `auth.users.created_at`. | | `users.updated_at` | (local Rails timestamp) | Bumped only if your code explicitly updates the row. | If you need the full claim set on every request, read it from `supabase_context.jwt_claims` (the raw verified payload) or `supabase_context.current_user` (the `Supabase::Rails::User` value object — still built when `user_model` is set, just not promoted to `Current.user`). See [`Authentication`](/reference/rails/authentication) for the full helper surface. ## Querying and associating with the local `User` [#querying-and-associating-with-the-local-user] With `config.supabase.user_model = "User"` set, `Current.user` is the AR record, so all of the usual ActiveRecord patterns work without ceremony. ### `belongs_to :user` on your domain models [#belongs_to-user-on-your-domain-models] The local `users.id` column is a `uuid`, so any model with a `user_id` reflection needs to store the FK as `uuid` too: ```ruby # db/migrate/_create_posts.rb class CreatePosts < ActiveRecord::Migration[7.1] def change create_table :posts, id: :uuid do |t| t.references :user, type: :uuid, foreign_key: true, null: false t.string :title, null: false t.text :body t.timestamps end end end # app/models/post.rb class Post < ApplicationRecord belongs_to :user end ``` `t.references :user, type: :uuid, foreign_key: true` is the important detail — the default `t.references :user` creates a `bigint` FK column and the FK constraint to `users(id)` will fail to build because the parent column is `uuid`. ### Reading and writing with `Current.user` [#reading-and-writing-with-currentuser] ```ruby # app/controllers/posts_controller.rb class PostsController < ApplicationController def create post = Current.user.posts.create!(post_params) redirect_to post end def index @posts = Current.user.posts.order(created_at: :desc) end private def post_params params.require(:post).permit(:title, :body) end end ``` `Current.user` is the same `User` AR record `Post.belongs_to :user` resolves to, so the associations chain like any other AR setup. ### Querying directly [#querying-directly] ```ruby User.find(Current.user.id) # PK lookup, hits the unique uuid index User.where(email: "alice@example.com").first User.includes(:posts).find(uuid) # eager-load like any AR query ``` ### Adding scopes and validations [#adding-scopes-and-validations] Because `User < ApplicationRecord`, scopes, validations, and callbacks behave normally — there are no magic methods the gem injects. Add a `validates :email, presence: true` if you want to enforce the email column non-null at the AR layer (the migration leaves it nullable). ## Customising the User model [#customising-the-user-model] The generated `User` is intentionally minimal so hosts can add columns and associations. ### Adding columns [#adding-columns] Generate a follow-up migration in the usual way: ```bash bin/rails generate migration AddProfileToUsers display_name:string avatar_url:string bin/rails db:migrate ``` Then teach `from_supabase` to mirror the new fields on the *create* path (and decide whether you also want to re-sync them on each request — see below): ```ruby class User < ApplicationRecord self.primary_key = :id has_many :posts, dependent: :destroy def self.from_supabase(claims) find_or_create_by!(id: claims["sub"]) do |u| u.email = claims["email"] u.display_name = claims.dig("user_metadata", "full_name") u.avatar_url = claims.dig("user_metadata", "avatar_url") end end end ``` `claims.dig("user_metadata", "full_name")` is the safe pattern — `user_metadata` is nil for users who haven't set anything, and `claims` is just the raw verified JWT payload, so `dig` short-circuits gracefully. ### Re-syncing fields on every request [#re-syncing-fields-on-every-request] If you want a field to track changes in Supabase, do the update explicitly outside the `find_or_create_by!` block. The generated `from_supabase` is the override point: ```ruby def self.from_supabase(claims) user = find_or_create_by!(id: claims["sub"]) do |u| u.email = claims["email"] end # Re-sync email on every request — overwrites local changes. user.update!(email: claims["email"]) if user.email != claims["email"] user end ``` This adds a write on every request when claims drift, so use it sparingly. If you need full Supabase parity (not just claims parity), call `supabase_context.supabase.auth.get_user` for the live `Supabase::Auth::Types::User` instead. ## Re-running and rolling back [#re-running-and-rolling-back] The generator is safe to re-run: * The migration step uses Rails' `migration_template` action — re-running prompts for overwrite if `db/migrate/_create_supabase_users.rb` already exists with a different timestamp. Thor's standard `[Ynaqdh]` overwrite prompt applies (see [`supabase:install`](/reference/rails/generators/install#options) for the full key reference). * The `app/models/user.rb` template uses Thor's `template` action — prompts on overwrite if the file has diverged from the template. * The initializer patch's regex guard means a second run skips the `append_to_file` step entirely, even if you've moved the `config.supabase.user_model = "User"` line elsewhere in the file (the regex matches any non-commented assignment). There is no `supabase:user_model` uninstall. To roll back manually: 1. **Roll back the migration** — `bin/rails db:rollback STEP=1` (or whichever step the migration occupies). The migration is reversible because `create_table` automatically reverses to `drop_table`. 2. Delete `app/models/user.rb`. 3. Delete (or comment out) the `Rails.application.config.supabase.user_model = "User"` line in `config/initializers/supabase.rb`. The gem treats `user_model = nil` as "no shadow model" and `Current.user` reverts to the default `Supabase::Rails::User` value object. 4. Remove any `belongs_to :user` reflections you added on the assumption of the shadow AR model, or change them to store the user UUID as a plain `string`/`uuid` column without a FK constraint. ## See also [#see-also] * [`supabase:install`](/reference/rails/generators/install) — the prerequisite generator that writes the initializer this one patches. * [Configuration → `user_model`](/reference/rails/configuration#user_model) — the full `config.supabase.user_model` reference, including the class-vs-string semantics. * [Authentication](/reference/rails/authentication) — the `Authentication` concern, `Current.user` lifecycle, and the `Supabase::Rails::User` value object that `Current.user` falls back to when `user_model` is unset. * [Generators](/reference/rails/generators) — the index page for all three generators. # supabase:views (/reference/rails/generators/views) `bin/rails generate supabase:views` copies the eight default ERB templates the gem ships under `app/views/supabase/rails/` into the host app at the same path. Standard Rails view resolution then picks the host copies ahead of the engine's — there is no `prepend_view_path` to manage, no opt-in flag to flip — so any edit you make to a copied file takes effect on the next request. ```bash bin/rails generate supabase:views ``` Expected output on a fresh app that has already run [`supabase:install`](/reference/rails/generators/install): ``` create app/views/supabase/rails/oauth/_buttons.html.erb create app/views/supabase/rails/otp/new.html.erb create app/views/supabase/rails/otp/verify.html.erb create app/views/supabase/rails/passwords/edit.html.erb create app/views/supabase/rails/passwords/new.html.erb create app/views/supabase/rails/registrations/new.html.erb create app/views/supabase/rails/sessions/new.html.erb create app/views/supabase/rails/shared/_flash.html.erb ``` Run it when you want to restyle the default forms, add fields, swap in your design system, or replace the bare ERB with Hotwire/View Components. Skip it if the gem's defaults are good enough — the templates are functional out of the box and you can ship to production without ever touching them. ## Files created [#files-created] Eight files, all copied byte-verbatim from the gem's `app/views/supabase/rails/` directory. Files have `.html.erb` extensions (not `.tt`), so there is no ERB pre-processing pass — what's in the gem is what lands in your repo. | Path | What it renders | Mounted at | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | `app/views/supabase/rails/sessions/new.html.erb` | Sign-in form: email + password, plus links to "Forgot password?" / "Create an account" and the `oauth/_buttons` partial. | `GET /session/new` | | `app/views/supabase/rails/registrations/new.html.erb` | Sign-up form: email + password, plus a link back to the sign-in page. | `GET /registration/new` | | `app/views/supabase/rails/passwords/new.html.erb` | "Forgot password" request form: email only, posts to `POST /passwords` to trigger the reset email. | `GET /passwords/new` | | `app/views/supabase/rails/passwords/edit.html.erb` | "Reset password" form: password + confirmation. Posts to `PUT /passwords/:token`. Rendered when the user clicks the reset link in their email. | `GET /passwords/:token/edit` | | `app/views/supabase/rails/otp/new.html.erb` | One-time-code request form: email only, posts to `POST /otp` to send the code. | `GET /otp/new` | | `app/views/supabase/rails/otp/verify.html.erb` | One-time-code verify form: hidden email/phone/type fields + the visible 6-digit token field. Posts to `POST /otp/verify`. | `GET /otp/verify` | | `app/views/supabase/rails/oauth/_buttons.html.erb` | Partial. Iterates `Rails.application.config.supabase.oauth_providers` (defaults to `[]`) and renders one `"Sign in with "` link per entry. Each link kicks off the PKCE flow via `OauthController#authorize`. Rendered by `sessions/new.html.erb` unconditionally. | partial | | `app/views/supabase/rails/shared/_flash.html.erb` | Partial. Renders `flash[:alert]` in red and `flash[:notice]` in green. Rendered by every gem-shipped form so sign-in errors and "check your email" success messages surface consistently. | partial | `oauth/_buttons.html.erb` reads `Rails.application.config.supabase.oauth_providers` — the default is an empty array, so the sign-in form renders no OAuth buttons until you populate that list in `config/initializers/supabase.rb`. See [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) for the list of supported providers and what each link's `href` resolves to. ## Override precedence [#override-precedence] Rails resolves view templates in the order their paths appear on `ActionController::Base.view_paths`. The host app's `app/views/` is always first; engine-added paths come after. The gem's `Supabase::Rails::Engine` adds its own `app/views/` to the path, but **after** the host's, so any file the host ships at the same relative path wins. The resolution order for `render "supabase/rails/sessions/new"` is: 1. `/app/views/supabase/rails/sessions/new.html.erb` — if present, this wins. 2. `/app/views/supabase/rails/sessions/new.html.erb` — the fallback the gem ships. The generator's only job is to drop the gem's copy into bucket 1 so you have something to edit. There is no flag on the controller, no `prepend_view_path` call to make, no initializer toggle. **Copy → edit → reload** is the entire override loop. This applies file-by-file: * Copy only `sessions/new.html.erb` and the other seven still resolve from the gem. * Delete a host copy and Rails silently falls back to the gem version on the next request. * The `oauth/_buttons` and `shared/_flash` partials follow the same precedence rule — overriding them changes how every gem-shipped form renders, because each top-level template renders the shared partials by relative path (`render "supabase/rails/shared/flash"`, `render "supabase/rails/oauth/buttons"`). Unlike [`supabase:user_model`](/reference/rails/generators/user-model), this generator does not touch `config/initializers/supabase.rb`. It only writes the eight ERB files. Once they exist on disk, Rails' standard view-path resolution does the rest. ## Worked example: customising the sign-in form [#worked-example-customising-the-sign-in-form] The default `sessions/new.html.erb` is intentionally minimal — bare `

`, no styling, no field grouping. Most apps will want to wrap it in their layout's form components. Here's the full edit loop end to end. ### Step 1 — Run the generator [#step-1--run-the-generator] ```bash bin/rails generate supabase:views ``` This drops the eight templates into `app/views/supabase/rails/`. Confirm with: ```bash ls app/views/supabase/rails/sessions/ # new.html.erb ``` ### Step 2 — Inspect the default template [#step-2--inspect-the-default-template] `app/views/supabase/rails/sessions/new.html.erb` (the file the generator just copied): ```erb

Sign in

<%= render "supabase/rails/shared/flash" %> <%= form_with url: session_path do |form| %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email] %>
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
<%= form.submit "Sign in" %> <% end %>
<%= link_to "Forgot password?", new_password_path %>
<%= link_to "Create an account", new_registration_path %> <%= render "supabase/rails/oauth/buttons" %> ``` The contract worth preserving across any edit: * `form_with url: session_path` — posts to `POST /session`, which `Supabase::Rails::SessionsController#create` handles. Change the URL helper and you break sign-in. * `email` and `password` are the field names the controller reads from `params` — rename them and the controller will see `nil`. * `maxlength: 72` matches Supabase Auth's bcrypt password limit. Lowering it is fine; raising it lets users enter passwords Supabase will reject. * `render "supabase/rails/shared/flash"` surfaces the controller's sign-in-error messages. Drop it and failed sign-ins will silently re-render the form. ### Step 3 — Replace the markup with your design system [#step-3--replace-the-markup-with-your-design-system] Wrap the form in your app's standard layout helpers — here a fictional `card` / `field_group` / `primary_button` set: ```erb <%# app/views/supabase/rails/sessions/new.html.erb %> <%= render layout: "shared/card", locals: { title: "Welcome back" } do %> <%= render "supabase/rails/shared/flash" %> <%= form_with url: session_path, class: "auth-form" do |form| %> <%= render "shared/field_group", label: "Email", field: form.email_field(:email, required: true, autofocus: true, autocomplete: "username", placeholder: "you@example.com", value: params[:email], class: "input input--full" ) %> <%= render "shared/field_group", label: "Password", field: form.password_field(:password, required: true, autocomplete: "current-password", placeholder: "••••••••", maxlength: 72, class: "input input--full" ) %> <%= form.submit "Sign in", class: "btn btn--primary btn--full" %> <% end %> <%= render "supabase/rails/oauth/buttons" %> <% end %> ``` The structural changes only — the four contract pieces above (URL helper, field names, `maxlength: 72`, flash partial render) are preserved verbatim, so the controller round-trip still works. ### Step 4 — Reload and verify [#step-4--reload-and-verify] No restart needed — Rails picks up view-template changes on the next request in development: ```bash bin/rails server ``` Open `http://localhost:3000/session/new`. The host's restyled form should render. To prove the override is doing the work (not the gem still serving its own copy), temporarily rename `app/views/supabase/rails/sessions/new.html.erb` to `.bak` — the page should fall back to the gem's bare default on the next reload. ### Step 5 — Style the shared partials too [#step-5--style-the-shared-partials-too] If you styled the sign-in form, the same look almost certainly applies to the other seven templates. The two highest-leverage edits: * **`shared/_flash.html.erb`** — every form renders it, so a single change updates sign-in error styling, password-reset success messages, OTP "code sent" notices, and registration errors all at once. * **`oauth/_buttons.html.erb`** — the loop body controls how every OAuth button looks. Replace `link_to "Sign in with #{provider.to_s.capitalize}"` with a `button_to` + provider-specific icon and you have a polished social sign-in row. The other five top-level forms (registrations/new, passwords/new+edit, otp/new+verify) follow the same shape as `sessions/new`: an `

`, the flash partial, a `form_with` with a `session_path`-style URL helper, the field whitelist the controller reads from `params`, and a back-link. Apply the same restyling pattern to each. ## Re-running and rolling back [#re-running-and-rolling-back] The generator uses Thor's `directory` action, which compares each source file against its destination: * Files that **don't exist** in the host print `create` and are written. * Files that are **byte-identical** to the gem source print `identical` and are not rewritten. * Files that **differ** trigger Thor's standard `[Ynaqdh]` overwrite prompt — `Y` overwrites, `n` keeps the host copy, `d` shows a diff, `a` overwrites all remaining conflicts, `q` aborts. (Same prompt as [`supabase:install`](/reference/rails/generators/install#options) — see that page for the full key reference.) The flags worth knowing — all inherited from Thor, the generator declares none of its own: | Flag | Effect | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--force`, `-f` | Overwrite every host file that has diverged, without prompting. Use when you want to reset your customisations and start from the current gem defaults. | | `--skip`, `-s` | Keep host copies for every file that has diverged. Useful when re-running after a gem upgrade to pick up *new* view files without losing your edits to existing ones. | | `--pretend`, `-p` | Dry-run. Prints the create/identical/conflict log lines without writing anything. | | `--quiet`, `-q` | Suppress the log lines. | After a gem upgrade that adds a new view template, the typical workflow is: ```bash bin/rails generate supabase:views --skip ``` `--skip` keeps every file you've customised, but new files (which don't exist in the host) are still `create`d. This is the lowest-friction way to stay in sync with the gem's view surface without an interactive prompt for each existing file. To roll back manually — there is no `supabase:views` uninstall: 1. Delete the file you no longer want to customise (e.g. `rm app/views/supabase/rails/sessions/new.html.erb`). 2. Rails immediately falls back to the gem's bundled version on the next request — no restart, no config change. Delete the entire `app/views/supabase/` tree to revert every template at once. ## See also [#see-also] * [`supabase:install`](/reference/rails/generators/install) — the prerequisite generator that wires up the controllers these templates render under. * [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) — populate this to make the `oauth/_buttons` partial render anything. * [Authentication](/reference/rails/authentication) — the `Authentication` concern and the helpers (`authenticated?`, `current_user`) available inside customised templates. * [Controllers](/reference/rails/controllers) — the five base controllers that render these templates, including the `params` whitelist each one reads from the forms. * [Generators](/reference/rails/generators) — the index page for all three generators. # Getting started with Rails Supabase auth (/reference/rails/getting-started) This is the quickstart for setting up Rails Supabase auth with `supabase-rails` in `:web` mode — encrypted cookie sessions, generator-scaffolded sign-in / sign-up / password-reset / OAuth / OTP, and a `Current.user` object populated from the verified Supabase JWT. Follow it top-to-bottom and you'll end with a running Rails app and a signed-in user. Use this guide when you want `supabase-rb` on Rails (no `supabase-js` on the front end) handling the entire auth surface for a server-rendered monolith. You need: Ruby ≥ 3.1, Rails ≥ 7.1, and a Supabase project. If you don't have a project yet, create one at [supabase.com/dashboard](https://supabase.com/dashboard) — the free tier is enough. ## 1. Create a Rails app [#1-create-a-rails-app] Skip this step if you already have one. ```bash rails new myapp cd myapp ``` ## 2. Add the gem [#2-add-the-gem] Add `supabase-rails` to your `Gemfile`: ```ruby # Gemfile gem "supabase-rails" ``` Then install: ```bash bundle install ``` Or in one step with [`bundle add`](https://bundler.io/v2.5/man/bundle-add.1.html): ```bash bundle add supabase-rails ``` `supabase-rails` depends on [`supabase-rb`](/reference/ruby/initializing) — the same gem the standalone Ruby docs cover — so a single `bundle install` brings both. The Rails layer adds the Rack middleware, the encrypted-cookie session model, generators, and Devise-shape controllers on top. ## 3. Run the install generator [#3-run-the-install-generator] ```bash bin/rails generate supabase:install ``` This writes the host-app glue for `:web` mode — one concern, five 3-line controller subclasses, a `Current` model, and an initializer that sets `mode = :web`: ``` create app/controllers/concerns/authentication.rb create app/controllers/sessions_controller.rb create app/controllers/registrations_controller.rb create app/controllers/passwords_controller.rb create app/controllers/otp_controller.rb create app/controllers/oauth_controller.rb create app/models/current.rb create config/initializers/supabase.rb insert config/routes.rb insert app/controllers/application_controller.rb ``` `config/routes.rb` gets one line: `supabase_authentication_routes`, which expands to the full sign-in / sign-up / password-reset / OTP / OAuth route table. `application_controller.rb` gets `include Authentication`. The generator is idempotent, so it's safe to re-run — see [Generators](/reference/rails/generators) for the per-route breakdown. ## 4. Get your Supabase URL and keys [#4-get-your-supabase-url-and-keys] In the [Supabase dashboard](https://supabase.com/dashboard), open your project, then go to **Project Settings → API Keys**. You need three values: | Dashboard label | Looks like | Env var | | ----------------------------------------------------- | ---------------------------------- | -------------------------- | | **Project URL** | `https://abcd1234.supabase.co` | `SUPABASE_URL` | | **Publishable key** (or `anon` on legacy projects) | `sb_publishable_...` (or `eyJ...`) | `SUPABASE_PUBLISHABLE_KEY` | | **Secret key** (or `service_role` on legacy projects) | `sb_secret_...` (or `eyJ...`) | `SUPABASE_SECRET_KEY` | The **publishable** key is safe to expose in browser code; the **secret** key bypasses Row-Level Security and must stay server-side. On legacy projects the dashboard still labels these `anon` and `service_role` — the values work identically in either format. `:web` mode verifies tokens against your project's JWKS — you need a fourth env var, `SUPABASE_JWKS_URL`, pointing at `https://.supabase.co/auth/v1/.well-known/jwks.json` (or your self-hosted equivalent). For air-gapped setups, set `SUPABASE_JWKS` to the inline JSON instead. The gem rejects sign-ins with `INVALID_CREDENTIALS` if neither is configured. ## 5. Set the environment variables [#5-set-the-environment-variables] For local development, the simplest path is a `.env` file at the repo root plus the [`dotenv-rails`](https://github.com/bkeepers/dotenv) gem: ```bash bundle add dotenv-rails --group "development,test" ``` ```bash # .env (add to .gitignore!) SUPABASE_URL=https://abcd1234.supabase.co SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx SUPABASE_SECRET_KEY=sb_secret_xxx SUPABASE_JWKS_URL=https://abcd1234.supabase.co/auth/v1/.well-known/jwks.json ``` ```bash echo ".env" >> .gitignore ``` For production, set the same four variables through your hosting provider's secret manager (Heroku config vars, Fly secrets, Kamal env, Rails encrypted credentials, etc.). See [Configuration](/reference/rails/configuration) for the full env-var resolution table, including named-key (`SUPABASE_PUBLISHABLE_KEYS={"web":"..."}`) and `SUPABASE_JWKS` (inline) overrides. ## 6. Set up the database [#6-set-up-the-database] `supabase-rails` itself doesn't add migrations — Supabase Auth owns the user table. But a fresh Rails app still needs its own database to boot: ```bash bin/rails db:prepare ``` ## 7. Start the server [#7-start-the-server] ```bash bin/rails server ``` The app boots on `http://localhost:3000`. On boot, the Railtie auto-inserts `Supabase::Rails::Middleware` into the stack and the engine installs the `supabase_authentication_routes` DSL helper — no extra wiring required. ## 8. Add a landing page [#8-add-a-landing-page] A fresh Rails app has no `root` route, and the registration controller redirects to `root_url` on a successful sign-up. Add a minimal landing controller so the post-sign-up redirect resolves and so the layout renders for the sign-in check below: ```bash bin/rails generate controller Home show ``` ```ruby # config/routes.rb — replace the commented `root` line at the bottom root "home#show" ``` Then add an authenticated-state marker to `app/views/layouts/application.html.erb` inside ``: ```erb <% if authenticated? %> Signed in as <%= Current.user.email %> <%= button_to "Sign out", session_path, method: :delete %> <% else %> <%= link_to "Sign in", new_session_path %> <% end %> ``` ## 9. Sign up your first user [#9-sign-up-your-first-user] Open [`http://localhost:3000/registration/new`](http://localhost:3000/registration/new) and submit the sign-up form. The default flow: 1. `RegistrationsController#create` calls `supabase_sign_up(email:, password:)` against `/auth/v1/signup` on your Supabase project. 2. If your project has **Confirm email** disabled (Authentication → Sign In / Up → "Confirm email" toggle in the dashboard), the response includes a session and you're signed in immediately — the encrypted `sb-session` cookie is written and `Current.user` is populated, then the controller redirects to `/`. 3. If **Confirm email** is enabled (the default on new projects), Supabase emails the user a confirmation link instead. Click it, then sign in at `/session/new`. You should land on the home page with "Signed in as [you@example.com](mailto:you@example.com)" and a sign-out button. ## What you just got [#what-you-just-got] After running the install generator, every host controller inherits an `Authentication` concern that: * Installs `before_action :require_authentication` on every action by default. * Exposes `authenticated?`, `current_user`, `Current.user`, and `Current.session` to controllers and views. * Adds the `allow_unauthenticated_access(only: …)` class macro for opting individual actions out. `Current.user` is a [`Supabase::Rails::User`](/reference/rails/authentication) value object built from the verified JWT claims — no extra round-trip, no AR record. Opt into a host-app AR `User` table with `bin/rails generate supabase:user_model` when you need `belongs_to :user` reflections to resolve. ## Next steps [#next-steps] # Views (/reference/rails/views) The gem ships eight ERB templates under `app/views/supabase/rails/` — six top-level forms (sign-in, sign-up, password request, password reset, OTP request, OTP verify) and two partials (the shared flash and the OAuth provider buttons). They render out of the box and can be overridden file-by-file by dropping a matching file into the host app at the same path. The fastest way to get an editable copy of all eight is `bin/rails generate supabase:views` — see the [generator page](/reference/rails/generators/views) for the override-precedence mechanics. This page is the per-template reference: for each view, exactly which fields it renders, which params the controller reads from those fields, where the form submits, and which partials it depends on. Use it when customising a template to make sure your edit preserves the controller contract. ## Template inventory [#template-inventory] Top-level templates, in route-table order: | Template | Renders | Submits to | Params read | Partials | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------- | --------------------------------- | | [`sessions/new.html.erb`](#sessions-new-html-erb) | Sign-in form: email + password, with "Forgot password?" and "Create an account" links. | `POST /session` | `:email`, `:password` | `shared/_flash`, `oauth/_buttons` | | [`registrations/new.html.erb`](#registrations-new-html-erb) | Sign-up form: email + password, with a link back to sign-in. | `POST /registration` | `:email`, `:password` | `shared/_flash` | | [`passwords/new.html.erb`](#passwords-new-html-erb) | Forgot-password request: email only. | `POST /passwords` | `:email` | `shared/_flash` | | [`passwords/edit.html.erb`](#passwords-edit-html-erb) | Reset-password form: password + confirmation. Rendered after the user clicks the recovery email link. | `PUT /passwords/:token` | `:password` (and `:password_confirmation`, client-only) | `shared/_flash` | | [`otp/new.html.erb`](#otp-new-html-erb) | One-time-code request: email only. | `POST /otp` | `:email`, `:phone` | `shared/_flash` | | [`otp/verify.html.erb`](#otp-verify-html-erb) | One-time-code verify: hidden email/phone/type + visible token field. | `POST /otp/verify` | `:token`, `:type`, `:email`, `:phone` | `shared/_flash` | Partials: | Partial | Rendered by | Locals | Reads from | | ----------------------------------------------------- | ------------------------------------------ | ------ | --------------------------------------------------- | | [`shared/_flash.html.erb`](#shared-_flash-html-erb) | All six top-level templates above. | None. | `flash[:notice]`, `flash[:alert]` | | [`oauth/_buttons.html.erb`](#oauth-_buttons-html-erb) | `sessions/new.html.erb` (unconditionally). | None. | `Rails.application.config.supabase.oauth_providers` | The gem keeps the originals under [`app/views/supabase/rails/`](https://github.com/supabase-ruby/supabase-rb/tree/main/supabase-rails/app/views/supabase/rails) inside the gem source. Rails' standard view-path resolution puts the host's `app/views/supabase/rails/` first, so any file you drop at the same relative path overrides the gem's copy — no `prepend_view_path`, no opt-in flag, no initializer change. See [Override precedence](/reference/rails/generators/views#override-precedence) on the generator page for the full mechanics. ## `sessions/new.html.erb` [#sessionsnewhtmlerb] Sign-in form rendered at `GET /session/new`. Submits to `POST /session`, which routes to `SessionsController#create` — see [Controllers → Sessions](/reference/rails/controllers/sessions). ```erb

Sign in

<%= render "supabase/rails/shared/flash" %> <%= form_with url: session_path do |form| %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email] %>
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
<%= form.submit "Sign in" %> <% end %>
<%= link_to "Forgot password?", new_password_path %>
<%= link_to "Create an account", new_registration_path %> <%= render "supabase/rails/oauth/buttons" %> ``` Fields: | Field | Type | HTML attributes | Notes | | ---------- | ---------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `email` | `email_field` | `required`, `autofocus`, `autocomplete="username"`, `value: params[:email]` | Pre-fills from `params[:email]` so a failed sign-in keeps the email the user typed. | | `password` | `password_field` | `required`, `autocomplete="current-password"`, `maxlength: 72` | `maxlength: 72` matches Supabase Auth's bcrypt limit — raising it lets users type passwords Supabase will reject. | Customisation contract (preserve across any edit): * `form_with url: session_path` — the URL helper that resolves to `POST /session`. * Field names `:email` and `:password` — the controller reads these from `params` verbatim. * `render "supabase/rails/shared/flash"` — surfaces `flash.now[:alert]` set by the controller on bad credentials (otherwise failed sign-ins re-render silently). * `render "supabase/rails/oauth/buttons"` — renders the OAuth row when `config.supabase.oauth_providers` is non-empty; safe to drop if you never want OAuth. ## `registrations/new.html.erb` [#registrationsnewhtmlerb] Sign-up form rendered at `GET /registration/new`. Submits to `POST /registration`, which routes to `RegistrationsController#create` — see [Controllers → Registrations](/reference/rails/controllers/registrations). ```erb

Create an account

<%= render "supabase/rails/shared/flash" %> <%= form_with url: registration_path do |form| %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email] %>
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Choose a password", maxlength: 72 %>
<%= form.submit "Sign up" %> <% end %>
<%= link_to "Already have an account? Sign in", new_session_path %> ``` Fields: | Field | Type | HTML attributes | Notes | | ---------- | ---------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `email` | `email_field` | `required`, `autofocus`, `autocomplete="username"`, `value: params[:email]` | Pre-fills after a `WEAK_PASSWORD` re-render so the user doesn't retype the email. | | `password` | `password_field` | `required`, `autocomplete="new-password"`, `maxlength: 72` | Use `autocomplete="new-password"` (not `current-password`) so password managers offer to generate. | Customisation contract: * `form_with url: registration_path` resolves to `POST /registration`. * Field names `:email` and `:password` are read verbatim by `RegistrationsController#create`. * The flash partial surfaces the mapped error message from `supabase_sign_up` — `WEAK_PASSWORD`, `PKCE_ERROR`, `SESSION_MISSING` come through with the upstream copy; other 4xx errors are masked to a generic "Invalid credentials" line. See [Controllers → Registrations](/reference/rails/controllers/registrations) for the full error policy. ## `passwords/new.html.erb` [#passwordsnewhtmlerb] Forgot-password request form rendered at `GET /passwords/new`. Submits to `POST /passwords`, which routes to `PasswordsController#create` — see [Controllers → Passwords](/reference/rails/controllers/passwords). ```erb

Forgot your password?

<%= render "supabase/rails/shared/flash" %> <%= form_with url: passwords_path do |form| %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email] %>
<%= form.submit "Email reset instructions" %> <% end %>
<%= link_to "Back to sign in", new_session_path %> ``` Fields: | Field | Type | HTML attributes | Notes | | ------- | ------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `email` | `email_field` | `required`, `autofocus`, `autocomplete="username"`, `value: params[:email]` | Single-field form — the address Supabase Auth will email a reset link to. | Customisation contract: * `form_with url: passwords_path` resolves to `POST /passwords`. * Field name `:email` is read verbatim by `PasswordsController#create`. * On success the controller redirects to `new_session_path` with the `supabase.rails.passwords.reset_sent` flash — your customised template doesn't need to handle a "submitted" state inline. ## `passwords/edit.html.erb` [#passwordsedithtmlerb] Reset-password form rendered at `GET /passwords/:token/edit` after the user clicks the recovery link in their inbox. Submits to `PUT /passwords/:token`, which routes to `PasswordsController#update`. ```erb

Update your password

<%= render "supabase/rails/shared/flash" %> <%= form_with url: password_path(params[:token]), method: :put do |form| %> <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %>
<%= form.submit "Save" %> <% end %> ``` Fields: | Field | Type | HTML attributes | Notes | | ----------------------- | ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `password` | `password_field` | `required`, `autocomplete="new-password"`, `maxlength: 72` | The new password the controller passes to `supabase_update_user`. | | `password_confirmation` | `password_field` | `required`, `autocomplete="new-password"`, `maxlength: 72` | Client-side parity check only — the controller does **not** read this. The browser's `required` attribute and your own JS (if any) are what catch mismatches. | The `params[:token]` value embedded in the URL is **not** what authenticates the request. Supabase Auth uses the recovery session cookie that was set when the user clicked the email link. The `:token` segment exists only to give the URL a stable shape; the controller body reads `params[:password]` and nothing else. Don't try to validate `params[:token]` yourself — see [Controllers → Passwords](/reference/rails/controllers/passwords) for the full flow. Customisation contract: * `form_with url: password_path(params[:token]), method: :put` — preserve the `:put` method and the `:token` URL segment. * Field name `:password` is read verbatim. `:password_confirmation` is client-side only; remove it if you don't want the second field. ## `otp/new.html.erb` [#otpnewhtmlerb] One-time-code request form rendered at `GET /otp/new`. Submits to `POST /otp`, which routes to `OtpController#create` — see [Controllers → OTP](/reference/rails/controllers/otp). ```erb

Sign in with a one-time code

<%= render "supabase/rails/shared/flash" %> <%= form_with url: otp_index_path do |form| %> <%= form.email_field :email, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email] %>
<%= form.submit "Send me a code" %> <% end %>
<%= link_to "Back to sign in", new_session_path %> ``` Fields: | Field | Type | HTML attributes | Notes | | ------- | ------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `email` | `email_field` | `autofocus`, `autocomplete="username"`, `value: params[:email]` | **Not** marked `required` — the controller also accepts `:phone`, so adding a phone field means dropping the email's `required`. | Customisation contract: * `form_with url: otp_index_path` resolves to `POST /otp`. * Field name `:email` is read by the controller; the controller also reads `:phone` (not in the default template) for SMS OTP flows. To add a phone option, drop a second field with `name="phone"` and remove `required` on the email field. ## `otp/verify.html.erb` [#otpverifyhtmlerb] One-time-code verify form rendered at `GET /otp/verify` and submitted at `POST /otp/verify` (the same route handles both). Routes to `OtpController#verify`. ```erb

Enter your code

<%= render "supabase/rails/shared/flash" %> <%= form_with url: verify_otp_index_path do |form| %> <%= form.hidden_field :email, value: params[:email] %> <%= form.hidden_field :phone, value: params[:phone] %> <%= form.hidden_field :type, value: params[:type] || "email" %> <%= form.text_field :token, required: true, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric", placeholder: "Enter the code we just sent" %>
<%= form.submit "Verify" %> <% end %>
<%= link_to "Back to sign in", new_session_path %> ``` Fields: | Field | Type | HTML attributes | Notes | | ------- | -------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | `hidden_field` | `value: params[:email]` | Round-trips the email from `otp/new` to `otp/verify`. | | `phone` | `hidden_field` | `value: params[:phone]` | Round-trips the phone from `otp/new` (when SMS OTP is in use). | | `type` | `hidden_field` | `value: params[:type] \|\| "email"` | OTP type as defined by Supabase Auth (`"email"`, `"sms"`, `"phone_change"`, …). Defaults to `"email"`. | | `token` | `text_field` | `required`, `autofocus`, `autocomplete="one-time-code"`, `inputmode="numeric"` | The 6-digit code from the email or SMS. `inputmode="numeric"` pops the numeric keypad on mobile; `autocomplete="one-time-code"` lets iOS/Safari auto-fill from SMS. | Customisation contract: * `form_with url: verify_otp_index_path` resolves to `POST /otp/verify`. The same route renders the form on GET — see the [`verify` action body](/reference/rails/controllers/otp#verify) for the `request.post?` gating that turns GET into a render and POST into an exchange. * Field names `:email`, `:phone`, `:type`, `:token` are all read by `OtpController#verify`. * Keep `autocomplete="one-time-code"` — without it, iOS will not auto-fill the SMS code and mobile UX degrades sharply. ## `shared/_flash.html.erb` [#shared_flashhtmlerb] Shared partial rendered by every top-level template. Surfaces `flash[:alert]` (red) and `flash[:notice]` (green) so every gem-shipped form shows controller-set error and success messages consistently. ```erb <%# Flash partial — shared by every gem-shipped view. Uses the conventional `notice` / `alert` keys (AC-4). Hosts can override this partial by shipping their own at `app/views/supabase/rails/shared/_flash.html.erb`. %> <%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %> <%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %> ``` **Locals:** none. The partial reads directly from the Rails `flash` hash. **Keys read:** `flash[:alert]` and `flash[:notice]`. Other keys (e.g. `flash[:warning]`) are ignored — add them to your override if you set custom keys from a subclassed controller. **Why it matters:** the action controllers set `flash.now[:alert]` on every failed branch (`SessionsController#create` on bad credentials, `RegistrationsController#create` on weak passwords, `OtpController#verify` on invalid codes, etc.). If you customise a top-level template and forget to render this partial, your users won't see *any* error feedback — sign-in just appears to silently re-render. Always keep `render "supabase/rails/shared/flash"` (or a host equivalent that reads `flash[:alert]`) in every form. **Overriding it:** drop a file at `app/views/supabase/rails/shared/_flash.html.erb` in your host app. The partial accepts no locals, so the only thing to change is the markup. Because every form renders this same partial, a single edit propagates to sign-in, sign-up, password reset, password edit, OTP request, and OTP verify in one shot — making it the highest-leverage single override in the section. A typical Tailwind replacement: ```erb <%# app/views/supabase/rails/shared/_flash.html.erb %> <% if flash[:alert] %>
<%= flash[:alert] %>
<% end %> <% if flash[:notice] %>
<%= flash[:notice] %>
<% end %> ``` ## `oauth/_buttons.html.erb` [#oauth_buttonshtmlerb] Shared partial rendered by `sessions/new.html.erb` (unconditionally). Iterates `Rails.application.config.supabase.oauth_providers` and renders one `"Sign in with "` link per entry. Each link kicks off the PKCE flow via `OauthController#authorize`. ```erb <%# OAuth provider buttons. Renders one link per provider listed in `config.supabase.oauth_providers` (defaults to none — host sets this in the supabase initializer). Each link initiates the PKCE flow via `OauthController#authorize`. %> <% providers = (Rails.application.config.supabase.oauth_providers if defined?(::Rails)) %> <% Array(providers).each do |provider| %> <%= link_to "Sign in with #{provider.to_s.capitalize}", oauth_authorize_path(provider: provider), data: { turbo_method: :get } %>
<% end %> ``` **Locals:** none. The partial reads `Rails.application.config.supabase.oauth_providers` directly. Wrap or replace with a partial that takes a local `providers:` argument if you want to render different button sets in different contexts. **Default state:** `config.supabase.oauth_providers` defaults to `[]`, so out of the box this partial renders **nothing**. Your sign-in page will show no OAuth row until you populate the list. ### Enabling providers [#enabling-providers] Add the provider keys you want in `config/initializers/supabase.rb`: ```ruby Rails.application.config.supabase.oauth_providers = %i[google github] ``` Each entry becomes one link, in the order listed. The string must match a provider id Supabase Auth recognises (`google`, `github`, `azure`, `apple`, `discord`, `gitlab`, `linkedin`, …) — the gem does **not** validate the list, so a typo silently sends users to a Supabase URL that returns an upstream error. See [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) for the full option reference. You also need to enable the provider in the Supabase dashboard (Authentication → Providers) and configure the OAuth client there. The Rails-side list only controls which buttons render. ### Disabling all OAuth [#disabling-all-oauth] Leave `config.supabase.oauth_providers` as `[]` (the default) — the partial's `Array(providers).each` no-ops and the section vanishes. If you want to hide the section header / divider as well, override the partial and skip the wrapping markup when the list is empty: ```erb <%# app/views/supabase/rails/oauth/_buttons.html.erb %> <% providers = Array(Rails.application.config.supabase.oauth_providers) %> <% if providers.any? %>

or continue with

<% providers.each do |provider| %> <%= link_to "Sign in with #{provider.to_s.capitalize}", oauth_authorize_path(provider: provider), class: "btn btn--oauth btn--#{provider}", data: { turbo_method: :get } %> <% end %>
<% end %> ``` ### Disabling a single provider temporarily [#disabling-a-single-provider-temporarily] Remove the entry from `oauth_providers` and restart the Rails server. No template change needed. ### Customising the button copy or markup [#customising-the-button-copy-or-markup] Override the partial at `app/views/supabase/rails/oauth/_buttons.html.erb`. The contract worth preserving: * `oauth_authorize_path(provider: provider)` resolves to `GET /oauth/:provider/authorize` — the route the `OauthController#authorize` action handles. Change the helper and you break the OAuth start leg. * `data: { turbo_method: :get }` — the default link uses Turbo. If your host disables Turbo, drop the `data` hash. * The loop variable `provider` is a Symbol or String exactly as it appears in `oauth_providers`. Use `to_s` before string interpolation. ### Customising per-provider styling [#customising-per-provider-styling] Use the loop variable to vary classes / icons per provider: ```erb <% Array(Rails.application.config.supabase.oauth_providers).each do |provider| %> <%= link_to oauth_authorize_path(provider: provider), class: "btn btn--oauth btn--#{provider}", data: { turbo_method: :get } do %> <%= image_tag "oauth/#{provider}.svg", alt: "", class: "icon" %> Sign in with <%= provider.to_s.capitalize %> <% end %> <% end %> ``` The implementation behind each button — PKCE state generation, signed-cookie verifier storage, callback exchange — lives in [`OauthController`](/reference/rails/controllers/oauth) and [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage). The view layer's only job is the link itself. ## Worked example: adding a logo and restyling with Tailwind [#worked-example-adding-a-logo-and-restyling-with-tailwind] End-to-end: generate the templates, drop a logo at the top, restyle the sign-in form with Tailwind utilities, and update the shared partials once so the rest of the views inherit the look. ### Step 1 — Copy the templates into your app [#step-1--copy-the-templates-into-your-app] ```bash bin/rails generate supabase:views ``` All eight files land under `app/views/supabase/rails/`. See [`supabase:views`](/reference/rails/generators/views) for re-run semantics, the `--skip` upgrade workflow, and the manual rollback steps. ### Step 2 — Add a logo to `sessions/new.html.erb` [#step-2--add-a-logo-to-sessionsnewhtmlerb] Replace the gem default with a layout-wrapping version that puts a logo at the top, restyles the form with Tailwind classes, and keeps the four-piece controller contract intact: ```erb <%# app/views/supabase/rails/sessions/new.html.erb %>
<%= image_tag "logo.svg", alt: "Acme", class: "mx-auto mb-8 h-10 w-auto" %>

Sign in to your account

<%= render "supabase/rails/shared/flash" %> <%= form_with url: session_path, class: "mt-8 space-y-4" do |form| %>
<%= form.label :email, class: "block text-sm font-medium text-gray-700" %> <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", value: params[:email], class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %> <%= form.password_field :password, required: true, autocomplete: "current-password", maxlength: 72, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
<%= form.submit "Sign in", class: "w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" %> <% end %>
<%= link_to "Forgot password?", new_password_path, class: "text-indigo-600 hover:text-indigo-500" %> <%= link_to "Create an account", new_registration_path, class: "text-indigo-600 hover:text-indigo-500" %>
<%= render "supabase/rails/oauth/buttons" %>
``` The contract preserved (verify against the [sessions/new contract](#sessions-new-html-erb)): * `form_with url: session_path` — still posts to `POST /session`. * `:email` and `:password` field names — unchanged, controller reads them as-is. * `maxlength: 72` — preserved on the password field. * `render "supabase/rails/shared/flash"` — flash partial still rendered, so bad-credential errors still surface. ### Step 3 — Restyle `shared/_flash.html.erb` once for every form [#step-3--restyle-shared_flashhtmlerb-once-for-every-form] This is the highest-leverage edit in the section — every form renders this partial, so a single Tailwind rewrite styles flash messages everywhere: ```erb <%# app/views/supabase/rails/shared/_flash.html.erb %> <% if flash[:alert] %> <% end %> <% if flash[:notice] %>
<%= flash[:notice] %>
<% end %> ``` After this edit, sign-up errors, password-reset success messages, OTP "code sent" notices, and bad-credential warnings all pick up the new styling automatically — no per-template change required. ### Step 4 — Restyle `oauth/_buttons.html.erb` with provider icons [#step-4--restyle-oauth_buttonshtmlerb-with-provider-icons] Wrap the loop in a divider row and add per-provider classes (assumes `oauth_providers` is populated): ```erb <%# app/views/supabase/rails/oauth/_buttons.html.erb %> <% providers = Array(Rails.application.config.supabase.oauth_providers) %> <% if providers.any? %>
or continue with
<% providers.each do |provider| %> <%= link_to oauth_authorize_path(provider: provider), class: "inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50", data: { turbo_method: :get } do %> <%= image_tag "oauth/#{provider}.svg", alt: "", class: "mr-2 h-5 w-5" %> Sign in with <%= provider.to_s.capitalize %> <% end %> <% end %>
<% end %> ``` The contract preserved (verify against the [`oauth/_buttons` contract](#oauth-_buttons-html-erb)): * `oauth_authorize_path(provider: provider)` — same URL helper, OAuth start leg still hits the gem's controller. * `data: { turbo_method: :get }` — Turbo behaviour preserved. * `Array(...)` guard — still no-ops when `oauth_providers` is empty. ### Step 5 — Apply the same shape to the remaining forms [#step-5--apply-the-same-shape-to-the-remaining-forms] `registrations/new`, `passwords/new`, `passwords/edit`, `otp/new`, and `otp/verify` all follow the same skeleton as `sessions/new`: a heading, the flash partial, a `form_with` with its specific URL helper and field whitelist, and a back-link. Copy the layout wrapper + field grouping from Step 2 across each, preserving each template's per-form contract from the per-template sections above. ### Step 6 — Verify in the browser [#step-6--verify-in-the-browser] ```bash bin/rails server ``` Hit `http://localhost:3000/session/new`, then `/registration/new`, `/passwords/new`, `/otp/new`. The logo and Tailwind styles should render across all of them. To prove the override is active (not the gem still serving its own copy), temporarily rename `app/views/supabase/rails/sessions/new.html.erb` to `.bak` — the page should fall back to the gem's bare default on the next reload. ## See also [#see-also] * [`supabase:views`](/reference/rails/generators/views) — generator that drops these templates into your app, plus the full override-precedence mechanics, re-run semantics, and rollback steps. * [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) — populate this to make the OAuth buttons partial render anything. * [Controllers](/reference/rails/controllers) — the five base controllers that render these templates, including each action's params whitelist and outcome dispatch table. * [Controllers → Sessions](/reference/rails/controllers/sessions) · [Registrations](/reference/rails/controllers/registrations) · [Passwords](/reference/rails/controllers/passwords) · [OTP](/reference/rails/controllers/otp) · [OAuth](/reference/rails/controllers/oauth) — per-controller pages with the full action bodies and override patterns. * [Authentication](/reference/rails/authentication) — the `Authentication` concern and the helpers (`authenticated?`, `current_user`) available inside customised templates. # AuthClientFactory (/reference/rails/web-mode/auth-client-factory) `Supabase::Rails::Web::AuthClientFactory` is the single place every `:web`-mode component goes when it needs an `Supabase::Auth::Client` — sign-in, sign-up, OAuth start/callback, OTP request/verify, password reset, refresh. The factory builds one client per request, caches it in `request.env["supabase.rails.auth_client"]`, and wires it with the invariants the cookie-session model requires: `auto_refresh_token: false` (no background timers in workers), `flow_type: "pkce"` (required for OAuth), `storage:` set to a per-request [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage). You almost never call `build` directly. The gem-shipped controllers and [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) call it internally; hosts writing custom OAuth or admin flows can call it from their own controller actions to reuse the same per-request client (and the same PKCE storage, which matters for OAuth). The module is documented here because (a) the four constructor invariants are load-bearing for the cookie-session model and a hand-rolled `Supabase::Auth::Client.new` will almost certainly violate one of them, (b) the env-key cache is the only mechanism that keeps `RequestScopedStorage` shared within a request, and (c) the publishable-key resolution path is the one place the `keys["default"]` requirement surfaces as an `EnvError`. ```ruby # In a custom controller — get the per-request auth client. client = Supabase::Rails::Web::AuthClientFactory.build(request) client.sign_up(email: "alice@example.com", password: "...") ``` ## `AuthClientFactory.build(request, env: nil, supabase_options: nil)` [#authclientfactorybuildrequest-env-nil-supabase_options-nil] | Returns | Raises | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `Supabase::Auth::Client` — the same instance for the duration of the request | `Supabase::Rails::EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY)` if no `default` publishable key is resolvable | | Argument | Type | Default | Notes | | ------------------- | -------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request` | `ActionDispatch::Request` (or any duck-type with `#env` and `#cookie_jar`) | required | The request the client should be scoped to. `RequestScopedStorage` reads `request.env` for memoization and `request.cookie_jar` for PKCE-verifier fallback. | | `env:` | `SupabaseEnv` \| `Hash` \| `nil` | `nil` | Passed to `Supabase::Rails::Env.resolve`. When already a `SupabaseEnv`, it is used as-is. | | `supabase_options:` | `Hash` \| `nil` | `nil` | Extra options merged into the client. Only `:global => { :headers => Hash }` is honoured today; future keys are silently passed through. | ### Caching [#caching] The factory caches the constructed client in `request.env[ENV_KEY]` where `ENV_KEY = "supabase.rails.auth_client"`. The second call within a request returns the cached instance: ```ruby def build(request, env: nil, supabase_options: nil) cached = request.env[ENV_KEY] return cached unless cached.nil? request.env[ENV_KEY] = build_client(request, env: env, supabase_options: supabase_options) end ``` Two consequences worth knowing: * **The `env:` / `supabase_options:` arguments to the second call are ignored.** Whoever called `build` first locked in the client's configuration for the rest of the request. A controller that wants to override env/options has to call `build` *before* anything else in the pipeline does — or directly construct its own `Supabase::Auth::Client` and skip the factory. In practice the middleware always calls `build` first via `CookieCredentialStrategy`, so subsequent controller calls inherit those options. * **The client's `RequestScopedStorage` is shared across callers within a request.** This is the load-bearing property: when `OauthController#callback` calls `client.exchange_code_for_session`, the PKCE verifier written by `OauthController#create` (in a *previous* request) is read from the same storage instance the cookie fallback was bound to. The cache is per-request: when the request finishes and the Rack env is discarded, the client and its storage are eligible for GC. There is no module-level state. ## Invariants [#invariants] The factory hard-codes four `Supabase::Auth::Client.new` options. They are documented as code constants only inline — the factory is the contract. | Option | Value | Why | | -------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auto_refresh_token` | `false` | `supabase-rb` would otherwise spawn a background `Timer` thread per session that outlives the request, leaking across Puma worker fork cycles. Refresh is performed *inline* by [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy). This is enforced repo-wide by `spec/supabase/rails/auto_refresh_token_invariant_spec.rb` — see [Codebase patterns](/reference/rails). | | `flow_type` | `"pkce"` | Required for the OAuth round-trip. Without PKCE, `sign_in_with_oauth` falls back to the implicit flow, which doesn't work with the cookie-session model. | | `persist_session` | `true` | Tells the upstream client to call `storage.set_item` on the session. Because `storage` is request-scoped, "persist" means "within this request" — the encrypted `sb-session` cookie is what carries the session across requests, written by [`SessionStore`](/reference/rails/authentication/session-store) from `start_new_session_for`. | | `storage` | `RequestScopedStorage.new(request)` | Per-request `Supabase::Auth::SupportedStorage` implementation. See [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) for the PKCE-verifier cookie fallback. | Every `Supabase::Auth::Client` constructed by this gem must have `auto_refresh_token: false`. Setting `config.supabase.auth = { auto_refresh_token: true }` does **not** flip this — the factory's hard-coded value wins. A repo-wide invariant spec (`spec/supabase/rails/auto_refresh_token_invariant_spec.rb`) greps the gem source to catch any regression. ## Auth URL derivation [#auth-url-derivation] ```ruby def auth_url(base_url) "#{base_url.to_s.chomp('/')}/auth/v1" end ``` The factory takes the resolved `env.url` (your `SUPABASE_URL`, e.g. `https://abcd1234.supabase.co`) and appends `/auth/v1`. The trailing-slash normalisation handles either `SUPABASE_URL=https://…` or `SUPABASE_URL=https://…/`. This is the same URL `supabase-rb` would derive internally, but pinning it here lets the factory route through proxies (via `supabase_options`) without re-deriving. ## Publishable-key resolution [#publishable-key-resolution] ```ruby def resolve_publishable_key(keys) key = keys["default"] return key unless key.nil? || key.to_s.empty? raise EnvError.missing_default_publishable_key end ``` The factory pulls `resolved_env.publishable_keys["default"]` and raises `EnvError(MISSING_DEFAULT_PUBLISHABLE_KEY)` if it's missing or empty. The "default" name comes from `SupabaseEnv` — the env reader normalises both the singular `SUPABASE_PUBLISHABLE_KEY` and the plural JSON-map `SUPABASE_PUBLISHABLE_KEYS={"default":"sb_publishable_…", "internal":"…"}` into a Hash keyed by name, with `"default"` populated from the singular when only the singular is set. Unlike the user-facing `:api` path (which can resolve a per-`auth_key_name` publishable key), the auth-client factory always uses `"default"` — there is no concept of multiple Supabase projects per request in `:web` mode. The other entries in `publishable_keys` exist for `verify_supabase_auth(key_name: ...)` against a multi-key API surface and are ignored here. ## Header construction [#header-construction] ```ruby def build_headers(publishable_key, supabase_options) base = { "apikey" => publishable_key, "Authorization" => "Bearer #{publishable_key}" } global = option_value(supabase_options, :global) || {} extra = option_value(global, :headers) || {} base.merge(stringify_keys(extra)) end ``` The factory sends the publishable key as both `apikey` and `Authorization: Bearer` — this is the supabase-rb idiom for unauthenticated auth-endpoint calls (sign-in, sign-up, OAuth start, password reset). Once a session is established, the upstream client overrides the `Authorization` header on subsequent calls with the user's `access_token`. The `supabase_options[:global][:headers]` extension point lets a host inject custom headers (a tracing header, a per-environment routing header) that the factory merges over the base. `stringify_keys` normalises symbol keys to strings so `:user_agent` and `"User-Agent"` both work. Symbol-or-string indirection is consistent with how `option_value` resolves `:global` — either `supabase_options[:global]` or `supabase_options["global"]` is found. ## When to call from custom code [#when-to-call-from-custom-code] The factory is the right entrypoint for any custom controller action that needs to call Supabase Auth — `sign_up`, `reset_password_for_email`, `verify_otp`, `sign_in_with_oauth`, and `exchange_code_for_session`. Three reasons to prefer it over a hand-rolled `Supabase::Auth::Client.new`: 1. **Sharing the per-request storage with the OAuth round-trip.** Without the factory, your custom `OauthController#callback` would have a fresh `RequestScopedStorage`, the cookie fallback wouldn't fire, and `exchange_code_for_session` would raise "verifier missing". 2. **Sharing the same client with the middleware's refresh path.** If the middleware already constructed a client (during refresh on this request), your controller reuses it via the env cache instead of allocating a second one — saving an allocation and ensuring options stay consistent. 3. **Inheriting the four invariants automatically.** A hand-rolled client that forgets `auto_refresh_token: false` would leak Timer threads. The factory takes that decision out of your hands. ```ruby # app/controllers/custom_otp_controller.rb class CustomOtpController < ApplicationController allow_unauthenticated_access def create client = Supabase::Rails::Web::AuthClientFactory.build(request) client.sign_in_with_otp(email: params[:email]) flash.notice = "Check your email for a sign-in link." redirect_to root_path rescue Supabase::Auth::Errors::AuthError => e error = Supabase::Rails::Web::AuthErrorMapper.translate(e) flash.alert = "Sign-in failed: #{error.code}" redirect_to root_path end end ``` [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) translates the upstream errors to the gem's stable `AuthError` codes — see that page for the mapping. ## Errors [#errors] | Condition | Exception | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | No `default` publishable key resolvable from `resolved_env.publishable_keys` | `Supabase::Rails::EnvError` with `code: "MISSING_DEFAULT_PUBLISHABLE_KEY"`, `status: 500` | | `request.env` is nil or frozen (would not be caused by Rails directly) | `NoMethodError` / `RuntimeError` from `request.env[ENV_KEY] = ...` — defensive: don't call with a synthetic request that's missing an env Hash. | The factory does not catch or translate upstream `Supabase::Auth::Errors::*` — those propagate from the client methods you call (`sign_in_with_password`, etc.). Use [`AuthErrorMapper.translate`](/reference/rails/web-mode/auth-error-mapper) to convert them to `Supabase::Rails::AuthError` with stable codes for error-handling. ## Examples [#examples] ### Default usage (no overrides) [#default-usage-no-overrides] ```ruby client = Supabase::Rails::Web::AuthClientFactory.build(request) # - url: /auth/v1 # - headers: { "apikey" => , "Authorization" => "Bearer " } # - storage: RequestScopedStorage tied to `request` # - auto_refresh: false # - flow_type: "pkce" # - persist_session: true ``` ### Custom headers via `supabase_options` [#custom-headers-via-supabase_options] ```ruby client = Supabase::Rails::Web::AuthClientFactory.build( request, supabase_options: { global: { headers: { "X-Trace-Id" => request.request_id } } } ) ``` ### Reusing within one request [#reusing-within-one-request] ```ruby # In :web mode, the middleware has already called .build during refresh check. # This call returns the same client instance — no second allocation. a = Supabase::Rails::Web::AuthClientFactory.build(request) b = Supabase::Rails::Web::AuthClientFactory.build(request) a.equal?(b) # => true ``` ### Custom OAuth state propagation [#custom-oauth-state-propagation] ```ruby # The factory always constructs RequestScopedStorage WITHOUT setting oauth_state # (it sets it to nil). To opt into the PKCE cookie fallback in a custom callback, # fetch the storage and set the state before calling exchange_code_for_session. client = Supabase::Rails::Web::AuthClientFactory.build(request) client.storage.oauth_state = params[:state] client.exchange_code_for_session(auth_code: params[:code]) ``` The gem-shipped `OauthController#callback` does exactly this — see [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) for the cookie-fallback details. ## What this module does *not* do [#what-this-module-does-not-do] * **It does not configure refresh.** Refresh is owned by [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy), which calls `client.refresh_session(refresh_token)` inline. The factory's job ends at constructing the client. * **It does not handle session writes to the encrypted cookie.** That's [`SessionStore.write`](/reference/rails/authentication/session-store), called from `start_new_session_for` in the `Authentication` concern after the controller obtains a `Types::Session`. * **It does not retry on `EnvError`.** The factory raises immediately on missing-key configuration. The middleware then surfaces the failure as a 500 with stable `code: MISSING_DEFAULT_PUBLISHABLE_KEY` — an explicit operator error, not a transient. * **It does not pool or recycle clients across requests.** The cache is keyed on `request.env`, which is destroyed at the end of each request. There is no module-level Hash of clients. ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode) — the request lifecycle context. * [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) — the storage the factory wires into the client. * [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) — the first per-request caller of `build`, and the place refresh happens. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — the translator you'll pair with this factory when calling Supabase Auth from custom controllers. * [Configuration → Environment variables](/reference/rails/configuration#environment-variables) — `SUPABASE_PUBLISHABLE_KEY` / `SUPABASE_PUBLISHABLE_KEYS` and the `default` key resolution. * [Configuration → `supabase_options`](/reference/rails/configuration#supabase_options) — the source of the `global[:headers]` extension point. * supabase-rb: [Initializing](/reference/ruby/initializing) — the underlying `Supabase::Auth::Client` constructor this factory wraps with the four `:web`-mode invariants. # AuthErrorMapper (/reference/rails/web-mode/auth-error-mapper) `Supabase::Rails::Web::AuthErrorMapper` is the single place every `Supabase::Auth::Errors::*` exception from `supabase-rb` is translated into the gem's stable `Supabase::Rails::AuthError` surface. The output carries a `code` (one of nine constants on `AuthError`) and an HTTP `status` that controllers, the Rack middleware, and any host error-handling code can match on without inspecting upstream exception classes. You will use this module any time you call `Supabase::Auth::Client` methods from custom code — `sign_in_with_otp`, `verify_otp`, `update_user`, `reauthenticate`, etc. The mapper is also called internally by the gem-shipped controllers (`SessionsController`, `PasswordsController`, `OauthController`, `OtpController`, `RegistrationsController`) so flash messages and JSON error bodies stay consistent. The module is documented here because (a) the dispatch is `case/when` against `Supabase::Auth::Errors::*` ancestors and ordering matters, (b) `AuthApiError`'s status-based subdispatch is the only mapping with a numeric branch, and (c) the `503` outcomes are the ones operators most need to recognise during incidents. ```ruby # Typical use from a custom controller. def reauthenticate client = Supabase::Rails::Web::AuthClientFactory.build(request) client.reauthenticate head :no_content rescue ::Supabase::Auth::Errors::AuthError => e err = Supabase::Rails::Web::AuthErrorMapper.translate(e) Rails.logger.warn("Reauth failed: [#{err.code}] #{err.message}") render json: { code: err.code, message: err.message }, status: err.status end ``` ## `AuthErrorMapper.translate(err)` [#autherrormappertranslateerr] | Returns | Visibility | | ------------------------------------------------------------ | ---------------------- | | `Supabase::Rails::AuthError` with stable `code` and `status` | Public, module-method. | | Argument | Type | Notes | | -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `err` | `Supabase::Auth::Errors::AuthError` or any `StandardError` | The upstream exception. Non-Supabase exceptions fall through the default branch and become `AUTH_GENERIC_ERROR` / `500`. | `translate` never raises. The message is extracted via `err.respond_to?(:message) ? err.message.to_s : err.to_s`, so an exception with a `nil` message produces an `AuthError` with `message: ""` rather than blowing up. ## The translation table [#the-translation-table] The `case` dispatch covers every documented `Supabase::Auth::Errors::*` class. The first match wins, so the table also shows the visiting order — leaf classes before their ancestors, since the upstream hierarchy nests some subclasses several levels deep. | Upstream `supabase-rb` error class | `AuthError::CODE` | HTTP `status` | Notes | | ---------------------------------- | --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `AuthInvalidCredentialsError` | `INVALID_CREDENTIALS` | `401` | Wrong email/password, invalid OTP. | | `AuthInvalidJwtError` | `INVALID_CREDENTIALS` | `401` | A token-shape problem upstream (rare in `:web` mode — JWT verify happens inside the gem via [`JWT.verify`](/reference/rails/authentication/jwt)). | | `AuthSessionMissing` | `SESSION_MISSING` | `401` | The upstream client tried to act on a session that didn't exist (e.g. `reauthenticate` without a current session). | | `AuthWeakPassword` | `WEAK_PASSWORD` | `422` | Sign-up or password update rejected by Supabase's strength rules. | | `AuthPKCEError` | `PKCE_ERROR` | `400` | OAuth round-trip failed — verifier mismatch, missing state, etc. | | `AuthRetryableError` | `AUTH_RETRYABLE` | `503` | Upstream signalled "try again" (rate-limited, brief unavailability). | | `AuthApiError` 4xx | `AUTH_API_ERROR` | preserved 4xx | The upstream HTTP status flows through verbatim. | | `AuthApiError` 5xx | `AUTH_UPSTREAM_ERROR` | `503` | Every 5xx collapses to 503 — your app is healthy, Supabase is degraded. | | `AuthUnknownError` | `AUTH_GENERIC_ERROR` | `500` | Upstream raised something the supabase-rb hierarchy doesn't classify. | | Any other `StandardError` | `AUTH_GENERIC_ERROR` | `500` | Defensive — out-of-band errors (e.g. `Timeout::Error`) hit the default branch. | ### Why `AuthApiError` is split by status [#why-authapierror-is-split-by-status] `Supabase::Auth::Errors::AuthApiError` is a single class that wraps any non-2xx HTTP response from `/auth/v1/*`. The mapper sub-dispatches on `err.status`: ```ruby def map_api_error(message, status) if status.is_a?(Integer) && status >= 500 AuthError.new(message, AuthError::AUTH_UPSTREAM_ERROR, 503) else upstream = status.is_a?(Integer) ? status : 400 AuthError.new(message, AuthError::AUTH_API_ERROR, upstream) end end ``` * **4xx** preserves the upstream status. A `422 Unprocessable Entity` from Supabase becomes `AuthError(AUTH_API_ERROR, 422)` — caller error semantics are kept intact. * **5xx** collapses to `503 Service Unavailable`. The caller (your app) is fine; the dependency is degraded. Returning the literal `502`/`504` Supabase reported would be confusing — operators reading your app's metrics would assume *your* upstream returned that status. * **Non-Integer status** (defensive: if the upstream ever yields a `nil` status) is treated as `400`. The 5xx-to-503 collapse is also what keeps the [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) refresh path's `REFRESH_UNAVAILABLE` semantics consistent — `:transient` is the same shape regardless of whether Supabase reported `500`, `502`, `503`, or `504`. ## Ordering of the `case/when` [#ordering-of-the-casewhen] The dispatch is `case/when err`, which uses `Class#===` (i.e. `is_a?`). Five of the leaf classes (`AuthRetryableError`, `AuthSessionMissing`, `AuthInvalidCredentialsError`, `AuthInvalidJwtError`, `AuthWeakPassword`) inherit from `CustomAuthError < AuthError`; `AuthApiError`, `AuthPKCEError`, and `AuthUnknownError` inherit directly from `AuthError`. None of them inherit from *each other*, so the order within those two leaf groups is cosmetic — but the leaf-before-ancestor rule still matters because: * `AuthApiError` is the only class that needs status-based sub-dispatch — placing it before `AuthError` ensures it isn't swallowed by a too-broad ancestor. * A future upstream class added under one of these branches will need to be placed in the dispatch before its parent. The mapper is small enough that this is easy to spot in PR review, but the module docstring calls it out explicitly to keep the rule in mind. ## Code constants [#code-constants] `Supabase::Rails::AuthError` defines nine `code` constants — eight surface through this mapper and one (`INVALID_REDIRECT`) is gem-side only (see [`RedirectValidator`](/reference/rails/web-mode/redirect-validator)): | Constant | String value | Produced by | | ----------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `AuthError::INVALID_CREDENTIALS` | `"INVALID_CREDENTIALS"` | `AuthInvalidCredentialsError`, `AuthInvalidJwtError`, and gem-internal JWT verify failures. | | `AuthError::SESSION_MISSING` | `"SESSION_MISSING"` | `AuthSessionMissing`. | | `AuthError::WEAK_PASSWORD` | `"WEAK_PASSWORD"` | `AuthWeakPassword`. | | `AuthError::PKCE_ERROR` | `"PKCE_ERROR"` | `AuthPKCEError`. | | `AuthError::AUTH_RETRYABLE` | `"AUTH_RETRYABLE"` | `AuthRetryableError`. | | `AuthError::AUTH_API_ERROR` | `"AUTH_API_ERROR"` | `AuthApiError` (4xx). | | `AuthError::AUTH_UPSTREAM_ERROR` | `"AUTH_UPSTREAM_ERROR"` | `AuthApiError` (5xx). | | `AuthError::AUTH_GENERIC_ERROR` | `"AUTH_ERROR"` | `AuthUnknownError` and any unrecognised `StandardError`. | | `AuthError::REFRESH_UNAVAILABLE` | `"REFRESH_UNAVAILABLE"` | Not produced by `translate` — raised directly by `CookieCredentialStrategy` for the cookie-refresh transient path. | | `AuthError::CREATE_SUPABASE_CLIENT_ERROR` | `"CREATE_SUPABASE_CLIENT_ERROR"` | Not produced by `translate` — raised by `Supabase::Rails.build_context_result` on `Supabase::SupabaseException`. | | `AuthError::INVALID_REDIRECT` | `"INVALID_REDIRECT"` | Not produced by `translate` — raised by [`RedirectValidator`](/reference/rails/web-mode/redirect-validator). | The `AUTH_GENERIC_ERROR` constant uses the *string* `"AUTH_ERROR"` (without the `GENERIC_` infix) — a small naming asymmetry that matters when matching on the string in tests. Match on the constants (`e.code == AuthError::INVALID_CREDENTIALS`), not the strings — the strings are stable, but the constants are clearer and let your IDE jump to definition. ## Errors that don't go through the mapper [#errors-that-dont-go-through-the-mapper] Three `AuthError` paths bypass `translate` because they are raised directly by the gem rather than by the upstream client: | Error | Raised by | Why bypass | | ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `AuthError.invalid_credentials` (manual constructor) | `Supabase::Rails::JWT.verify` and `Core.verify_credentials` | The gem fails JWT verification before talking to upstream — there's no upstream exception to translate. | | `AuthError.refresh_unavailable` | [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) | The strategy already classified the upstream error as `:transient` and produces the `503` directly. Sending it back through the mapper would duplicate work. | | `AuthError.invalid_redirect(uri)` | [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) | Open-redirect rejection is a gem-side check that never sees an upstream call. | | `AuthError.session_missing` (manual constructor) | `Authentication` concern, when `terminate_session` is called without an active session | Same — gem-side, no upstream call. | The mapper is exclusively for **upstream-to-stable-AuthError** translation. Matching on `e.code` works the same regardless of which path produced the error. ## How errors surface [#how-errors-surface] ### `:web` mode [#web-mode] Most upstream errors during sign-in / sign-up / OAuth / OTP / password-reset are caught inside the gem-shipped controllers and rendered as a flash message + redirect back to the form. The mapper is what populates the `code` you see logged via the `[supabase.rails.sign_in_failure] code=` line — `` is the string value of `AuthError::CODE`. For the cookie-refresh transient path specifically, the middleware renders a JSON 503 body (`{ "message": "...", "code": "REFRESH_UNAVAILABLE" }`) — see [`CookieCredentialStrategy` → Anonymous downgrade vs. JSON 503](/reference/rails/web-mode/cookie-credential-strategy#anonymous-downgrade-vs-json-503). ### `:api` mode [#api-mode] The middleware translates upstream errors via the mapper and renders the JSON body: ```json { "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" } ``` with the mapped `status`. Clients can match on `code` to display a localised message. ## Examples [#examples] ### Translating an upstream error in a custom controller [#translating-an-upstream-error-in-a-custom-controller] ```ruby def update_password client = Supabase::Rails::Web::AuthClientFactory.build(request) client.update_user(password: params[:password]) flash.notice = "Password updated." redirect_to account_path rescue ::Supabase::Auth::Errors::AuthError => e err = Supabase::Rails::Web::AuthErrorMapper.translate(e) case err.code when Supabase::Rails::AuthError::WEAK_PASSWORD flash.alert = "Password is too weak. #{err.message}" when Supabase::Rails::AuthError::SESSION_MISSING flash.alert = "Please sign in again to change your password." else flash.alert = "Couldn't update password (#{err.code})." end redirect_to edit_account_path end ``` ### Translating a non-Supabase error [#translating-a-non-supabase-error] ```ruby err = Supabase::Rails::Web::AuthErrorMapper.translate(Timeout::Error.new("oops")) err.code # => "AUTH_ERROR" err.status # => 500 err.message # => "oops" ``` The default branch catches anything not in the upstream hierarchy and produces a `500 AUTH_GENERIC_ERROR` — a safe fallback that won't accidentally surface as a 401 or a credential-shaped error to the user. ### Mapping an `AuthApiError` 5xx to 503 [#mapping-an-authapierror-5xx-to-503] ```ruby upstream = ::Supabase::Auth::Errors::AuthApiError.new("Bad gateway", status: 502) err = Supabase::Rails::Web::AuthErrorMapper.translate(upstream) err.code # => "AUTH_UPSTREAM_ERROR" err.status # => 503 ``` Your app's `502` is now a `503` — the right semantic for "we couldn't talk to Supabase" rather than "we couldn't talk to ourselves". ### Mapping a 4xx through to its upstream status [#mapping-a-4xx-through-to-its-upstream-status] ```ruby upstream = ::Supabase::Auth::Errors::AuthApiError.new("Email taken", status: 422) err = Supabase::Rails::Web::AuthErrorMapper.translate(upstream) err.code # => "AUTH_API_ERROR" err.status # => 422 ``` The 4xx is the caller's problem — preserving it lets the host UI distinguish `409 Conflict` from `422 Unprocessable Entity` without parsing the message. ## What this module does *not* do [#what-this-module-does-not-do] * **It does not log.** Logging is the caller's responsibility (the gem-shipped controllers log a redacted `[supabase.rails.sign_in_failure]` line; the middleware logs through `Supabase::Rails::Logging`). The mapper is pure — call it, then decide how loudly to react. * **It does not raise.** A nil message, a missing status, or a non-Supabase exception all produce a valid `AuthError`. The mapper is safe to call from `rescue` blocks without further `begin/rescue`. * **It does not produce gem-side error codes.** `INVALID_REDIRECT`, `REFRESH_UNAVAILABLE`, and `CREATE_SUPABASE_CLIENT_ERROR` are raised directly by their respective modules — see the table in [Errors that don't go through the mapper](#errors-that-dont-go-through-the-mapper). * **It does not localise messages.** The `message` is whatever the upstream said. Hosts that want localised error text should match on `err.code` and look up an I18n key. ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode#errors-and-observability) — the table of upstream-to-`AuthError` mappings repeated in the section's overview. * [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) — the refresh path, which produces `REFRESH_UNAVAILABLE` without going through this mapper. * [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) — the per-request client factory; pair `build` with `AuthErrorMapper.translate` when calling upstream methods from custom code. * [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) — the source of `INVALID_REDIRECT`, the one upstream-independent `AuthError` code. * [Authentication](/reference/rails/authentication) — the controller-side `AuthError` rescue patterns. * [JWT verification → Errors raised](/reference/rails/authentication/jwt#errors-raised) — the JWT verifier's own `AuthError` paths. # CookieCredentialStrategy (/reference/rails/web-mode/cookie-credential-strategy) `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`](/reference/rails/authentication/session-store), 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`](#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. ```ruby # 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 [#constructor] ```ruby 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` [#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](/reference/rails/authentication/jwt#time-skew) for the distinction. ## `#call(rack_env)` [#callrack_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 [#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](#anonymous-downgrade-vs-json-503) for the rationale. ### What "user context" means [#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`](/reference/rails/authentication/jwt) 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. ```ruby # 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 [#refresh-path] When `expires_at <= now + 10s` and a `refresh_token` is present, the strategy hands off to [`RefreshCoordinator.synchronize`](/reference/rails/web-mode/refresh-coordinator) 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`](/reference/rails/web-mode/auth-client-factory) 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 [#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 [#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 [#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)` with `auth_mode: :none`. The middleware places the context on `request.env`, and `Current.user` is `nil` downstream. Authorization-required controllers redirect to `new_session_path` via [`require_authentication`](/reference/rails/authentication#require_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 with `Content-Type: application/json` and 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. 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 [#logging] The strategy logs four events through [`Supabase::Rails::Logging`](/reference/rails/configuration#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 [#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 [#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)`](/reference/rails/web-mode#hybrid-web--api) inside the controller. ## Calling from a test [#calling-from-a-test] ```ruby 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 end ``` Injecting a `session_store:` is the cleanest test seam — the strategy never touches Rails' encrypted-cookie jar in your specs. ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode) — the full request/refresh/sign-out flow that this strategy implements one branch of. * [`RefreshCoordinator`](/reference/rails/web-mode/refresh-coordinator) — the mutex pool the refresh branch runs inside. * [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) — builds the per-request `Supabase::Auth::Client` the refresh path uses. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — the central upstream-to-`AuthError` mapping (not used by this strategy directly, but consistent with the same `code` / `status` surface). * [Session store](/reference/rails/authentication/session-store) — the encrypted-cookie I/O the strategy reads, writes, and clears. * [JWT verification](/reference/rails/authentication/jwt) — the verifier the user-context branch shims the cookie's `access_token` into. * [Authentication → `require_authentication`](/reference/rails/authentication#require_authentication) — the controller-side gate that redirects to sign-in when the strategy returns an anonymous context. * [Configuration → `session`](/reference/rails/configuration#session) — the cookie attributes `SessionStore.new(session)` reads. # Web mode (/reference/rails/web-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](/reference/rails/configuration); for the controller-side helpers see [Authentication](/reference/rails/authentication). ```ruby # config/initializers/supabase.rb Rails.application.config.supabase.mode = :web ``` ## When to use `:web` vs. `:api` [#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 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 ` 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`](#hybrid-web--api) below. ## The cookie-session flow [#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`](/reference/rails/authentication/session-store), 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` [#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:)`](/reference/rails/authentication#authenticate_with_supabase) which returns a `Supabase::Auth::Types::Session` (or `nil` on bad credentials). On success it calls [`start_new_session_for(session)`](/reference/rails/authentication#start_new_session_for), which serializes the session into the encrypted cookie via [`SessionStore`](/reference/rails/authentication/session-store) 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-` signed cookie via [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage). ### 2. Subsequent request — cookie is valid [#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`](/reference/rails/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 [#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`](/reference/rails/web-mode/refresh-coordinator) 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` [#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:)`](/reference/rails/authentication#terminate_session) 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 [#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`](/reference/rails/authentication/session-store#middleware) | Rack middleware. Dispatches by `mode`, places `supabase_context` on `request.env`, merges CORS headers. | | [`SessionStore`](/reference/rails/authentication/session-store) | Encrypted-cookie I/O. `read` / `write` / `clear`. | | [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) | The phase-2/3 dispatch logic above — cookie read, expiry check, inline refresh, clear-or-503 outcome routing. | | [`RefreshCoordinator`](/reference/rails/web-mode/refresh-coordinator) | `SHA256(refresh_token)`-keyed mutex pool that serializes concurrent refresh attempts within a worker. | | [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) | 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`](/reference/rails/web-mode/request-scoped-storage) | 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`](/reference/rails/web-mode/auth-error-mapper) | Translates `Supabase::Auth::Errors::*` to `Supabase::Rails::AuthError` with a stable `code` and HTTP `status`. Centralises the 401/422/503/etc. mapping. | | [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) | Validates OAuth and password-reset `redirect_to` targets against `config.supabase.allowed_redirect_origins`. Raises `AuthError(INVALID_REDIRECT)` on mismatch. | | [`JWT.verify`](/reference/rails/authentication/jwt) | Same JWKS-backed verifier as `:api` mode. The cookie's `access_token` is passed through verbatim. | | [`Authentication`](/reference/rails/authentication) concern | The controller-side API: `authenticated?`, `require_authentication`, `current_user`, `start_new_session_for`, `terminate_session`. | ## Cookie payload [#cookie-payload] The encrypted cookie's plaintext is a JSON object that mirrors `Supabase::Auth::Types::Session`: ```json { "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`](/reference/rails/configuration#session) for the full key reference. ## Hybrid `:web` + `:api` [#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: ```ruby # 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 [#errors-and-observability] All upstream `Supabase::Auth::Errors::*` are routed through [`Web::AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) 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= email=***@` — `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](/reference/rails/configuration#logging). ## See also [#see-also] * [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) — phase-by-phase dispatch logic for cookie read, expiry check, refresh, and clear-or-503 outcome routing. * [`RefreshCoordinator`](/reference/rails/web-mode/refresh-coordinator) — the per-worker mutex pool that serializes concurrent refresh calls keyed by `SHA256(refresh_token)`. * [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) — per-request `Supabase::Auth::Client` construction with `auto_refresh_token: false` and PKCE flow. * [`RequestScopedStorage`](/reference/rails/web-mode/request-scoped-storage) — `SupportedStorage` adapter that keeps PKCE verifiers and auth state scoped to a single Rack request. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — the central mapping from `Supabase::Auth::Errors::*` to `AuthError` with stable `code` and `status`. * [`RedirectValidator`](/reference/rails/web-mode/redirect-validator) — validates OAuth and password-reset `redirect_to` targets against `config.supabase.allowed_redirect_origins`. * [Authentication](/reference/rails/authentication) — the controller-side API (`authenticated?`, `current_user`, `start_new_session_for`, `terminate_session`). * [Session store](/reference/rails/authentication/session-store) — the encrypted-cookie wrapper and the Rack middleware that reads it on every `:web`-mode request. * [JWT verification](/reference/rails/authentication/jwt) — the JWKS-backed verifier the strategy delegates to. * [Configuration → `mode`](/reference/rails/configuration#mode) — the config flag that flips between `:api` and `:web`. * [Configuration → `session`](/reference/rails/configuration#session) — the cookie-attribute keys. * [Configuration → `allowed_redirect_origins`](/reference/rails/configuration#allowed_redirect_origins) — the OAuth and password-reset redirect allowlist. * [Getting started](/reference/rails/getting-started) — the end-to-end install path that lands you on `:web` mode by default. # RedirectValidator (/reference/rails/web-mode/redirect-validator) `Supabase::Rails::Web::RedirectValidator` is the open-redirect guard for every place the gem honours a caller-supplied redirect target — OAuth start (`?redirect_to=…`), OAuth callback (`?next=…`), password-reset email links, and any controller that wants to round-trip a return URL after sign-in. A target is accepted only when it is either a same-origin path (`/dashboard`, `/foo?a=1`) or an absolute URL whose origin (`scheme://host:port`) matches an entry in `config.supabase.allowed_redirect_origins`. You may call this module directly from custom controllers that pass through a `?redirect_to=` param; the gem-shipped controllers (`OauthController`, `PasswordsController`) already call it before issuing the `redirect_to`. The module is documented here because (a) misconfiguring `allowed_redirect_origins` is the single most common way to brick OAuth in production, (b) the validator's exception is what surfaces to the user as a 400, and (c) the path-vs-origin rules are subtle enough (protocol-relative URLs, `javascript:` URIs, port handling) that a hand-rolled check is almost always wrong. ```ruby # config/initializers/supabase.rb Rails.application.config.supabase.allowed_redirect_origins = [ "https://myapp.com", "https://staging.myapp.com" ] # app/controllers/sessions_controller.rb (custom override) def create # ...sign in... target = Supabase::Rails::Web::RedirectValidator.validate( params[:redirect_to], allowed_origins: Rails.application.config.supabase.allowed_redirect_origins ) redirect_to target rescue Supabase::Rails::AuthError => e flash.alert = e.message redirect_to root_path end ``` ## `RedirectValidator.validate(uri, allowed_origins:)` [#redirectvalidatorvalidateuri-allowed_origins] | Returns | Raises | | ------------------------------------------- | --------------------------------------------------------------------------- | | The input `uri.to_s`, unchanged, when valid | `Supabase::Rails::AuthError` with `code: "INVALID_REDIRECT"`, `status: 400` | `uri` can be a `String`, a `URI::Generic`, or `nil`. The validator coerces to a string via `to_s` and parses with `URI.parse`. A `nil`, empty, or unparseable input raises immediately — the caller is responsible for catching the `AuthError` and translating it into whatever response is appropriate (a flash + redirect, a 400 JSON body, etc.). `allowed_origins` is an `Array` — typically the value of `config.supabase.allowed_redirect_origins`. Each entry is parsed as an absolute URL (`URI.parse("https://myapp.com")`) and compared by *origin* (scheme + host + port). Entries that don't parse, or parse without both a scheme and a host, are silently skipped — they can never match anything. ### Acceptance rules [#acceptance-rules] A target is accepted in exactly two cases: | Shape | Example | Accepted because | | ------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Path-only (no scheme, no host, non-empty path) | `/dashboard`, `/foo?a=1#bar` | Same-origin by definition — the browser will resolve it against the current host. | | Absolute URL with scheme + host whose origin matches an entry | `https://myapp.com/dashboard` (with `["https://myapp.com"]` in the allowlist) | The origin (`scheme://host:port`) matches an allowed origin exactly. | Everything else is rejected. ### Rejection rules [#rejection-rules] | Shape | Example | Rejected because | | ----------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `nil` | `nil` | Defensive — callers passing `nil` haven't read a real param. | | Empty string | `""` | Same as `nil`. | | Unparseable URL | `"http://[invalid"` | `URI.parse` raises `URI::InvalidURIError`, which is caught and treated as invalid. | | Protocol-relative URL | `"//evil.com/x"` | `URI.parse` sets `host = "evil.com"`, so it's not path-only. Origin check fails (assuming `evil.com` is not allowlisted) and the target is rejected. | | Scheme-only URI | `"javascript:alert(1)"`, `"data:text/html,..."` | `scheme` is non-nil, `host` is nil — not path-only. Origin check fails (no host to compare). | | Absolute URL with an off-allowlist origin | `"https://evil.com/take-over"` | Origin does not match any entry. | | Absolute URL with a different scheme on an allowlisted host | `"http://myapp.com"` (allowlist has `https://myapp.com`) | Scheme is part of the origin — `http://` and `https://` are distinct origins. | | Absolute URL with a non-default port on an allowlisted host | `"https://myapp.com:8443"` (allowlist has `https://myapp.com`) | Port mismatches — `myapp.com:443` ≠ `myapp.com:8443`. | The protocol-relative case is the load-bearing one for attack mitigation. A naive `params[:redirect_to].start_with?("/")` check accepts `//evil.com/x` because it starts with `/` — the validator's `path_only?` predicate is `scheme.nil? && host.nil? && !path.empty?`, which correctly rejects protocol-relative targets. The `javascript:` case is similarly load-bearing for XSS prevention. `javascript:alert(1)` parses as `scheme: "javascript", host: nil, path: "alert(1)"`. It's not path-only (scheme is set) and fails origin matching (host is nil), so it's rejected before reaching `redirect_to`. ## Origin matching [#origin-matching] The validator normalises both sides before comparing: | Component | Normalisation | | --------- | ----------------------------------------------------------------------------------------------------- | | `scheme` | Lowercased (`"HTTPS"` → `"https"`) | | `host` | Lowercased (`"MyApp.com"` → `"myapp.com"`) | | `port` | `uri.port` if present, else the scheme's default port (`URI.scheme_list[scheme.upcase].default_port`) | The origin string is then `"#{scheme}://#{host}:#{port}"`. Two origins match iff their normalised string is identical. ```ruby # Equivalent — both yield "https://myapp.com:443" origin_of(URI.parse("https://myapp.com")) # port defaults to 443 origin_of(URI.parse("https://myapp.com:443")) # explicit port # Distinct — "https://myapp.com:8443" ≠ "https://myapp.com:443" origin_of(URI.parse("https://myapp.com:8443")) ``` A few practical consequences: * `"https://myapp.com"` and `"https://myapp.com/"` (trailing slash) match — only the origin is compared, not the path. * `"https://myapp.com:443"` and `"https://myapp.com"` match — the validator fills in scheme-default ports on both sides. * `"https://myapp.com:8443"` and `"https://myapp.com"` do **not** match — the explicit non-default port is part of the origin. * `"HTTPS://MYAPP.com"` (any casing) matches `"https://myapp.com"` — scheme and host are case-insensitive per RFC 3986. * Path, query, and fragment in the allowlist entries are ignored. `"https://myapp.com/auth"` and `"https://myapp.com"` allow the same set of targets. ### What the allowlist should contain [#what-the-allowlist-should-contain] The allowlist is the **origin of your Rails app**, not the origin of Supabase. The values you put in `config.supabase.allowed_redirect_origins` are the hosts you *return to* after OAuth or password reset — i.e. the hosts that serve your Rails controllers. Supabase Auth's URL never appears here. ```ruby # config/initializers/supabase.rb Rails.application.config.supabase.allowed_redirect_origins = [ "https://myapp.com", # production "https://staging.myapp.com", # staging "http://localhost:3000" # local dev (HTTPS not required for loopback) ] ``` In production, every entry should be HTTPS — an HTTP origin in the allowlist invites a man-in-the-middle to swap the redirect target. The validator does not enforce this (it would break local dev), but lint and review should. ## `AuthError(INVALID_REDIRECT)` [#autherrorinvalid_redirect] The single exception the module raises is `Supabase::Rails::AuthError`: | Field | Value | | --------- | ------------------------------------------------------------------------------------------ | | `message` | `%(redirect target #{uri.inspect} is not in `config.supabase.allowed\_redirect\_origins`)` | | `code` | `"INVALID_REDIRECT"` | | `status` | `400` | The `code` and `status` are stable — error-handling code in upstream middleware can match on `e.code == Supabase::Rails::AuthError::INVALID_REDIRECT` without parsing the message. The message itself includes the rejected `uri.inspect` so logs can pinpoint the offending value (no credentials are in a redirect URL, so logging the input is safe). ```ruby rescue Supabase::Rails::AuthError => e case e.code when Supabase::Rails::AuthError::INVALID_REDIRECT Rails.logger.warn("Open-redirect attempt blocked: #{e.message}") flash.alert = "That redirect target isn't allowed." redirect_to root_path else raise end end ``` ## Where the gem calls this [#where-the-gem-calls-this] The gem-shipped controllers call `RedirectValidator.validate` in three places, all of which fall back to `[request.host]` when `config.supabase.allowed_redirect_origins` is empty so a vanilla install still functions: | Caller | What's validated | | ---------------------------------------------- | ---------------------------------------------------------------------------- | | `OauthController#create` | The `?redirect_to=` query param the host app passes to `sign_in_with_oauth`. | | `OauthController#callback` | The `?next=` query param Supabase echoes back from the OAuth provider. | | `PasswordsController#create` (forgot-password) | The `redirect_to` (the URL the password-reset email link should land on). | The fallback to `[request.host]` is a guardrail, not a recommendation — production apps should configure `allowed_redirect_origins` explicitly so the allowlist doesn't grow implicitly by virtue of whatever hostname the request happened to arrive on. ## Examples [#examples] ### Path-only target [#path-only-target] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "/dashboard", allowed_origins: ["https://myapp.com"] ) # => "/dashboard" ``` ### Allowed absolute URL [#allowed-absolute-url] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "https://myapp.com/welcome", allowed_origins: ["https://myapp.com"] ) # => "https://myapp.com/welcome" ``` ### Blocked off-allowlist URL [#blocked-off-allowlist-url] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "https://evil.com/x", allowed_origins: ["https://myapp.com"] ) # => raises Supabase::Rails::AuthError( # code: "INVALID_REDIRECT", # status: 400, # message: 'redirect target "https://evil.com/x" is not in `config.supabase.allowed_redirect_origins`' # ) ``` ### Blocked protocol-relative URL [#blocked-protocol-relative-url] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "//evil.com/x", allowed_origins: ["https://myapp.com"] ) # => raises AuthError(INVALID_REDIRECT) — protocol-relative URLs have a host and are NOT path-only ``` ### Blocked `javascript:` URI [#blocked-javascript-uri] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "javascript:alert(1)", allowed_origins: ["https://myapp.com"] ) # => raises AuthError(INVALID_REDIRECT) — scheme is set but host is nil, fails both branches ``` ### Cross-port mismatch [#cross-port-mismatch] ```ruby Supabase::Rails::Web::RedirectValidator.validate( "https://myapp.com:8443/x", allowed_origins: ["https://myapp.com"] # default port (443) ) # => raises AuthError(INVALID_REDIRECT) ``` ## What this module does *not* do [#what-this-module-does-not-do] * **It does not normalise paths.** `"/dashboard/../admin"` is accepted as-is — the browser will resolve `..` itself, but if the next hop is your Rails app, the request hits `/admin`. Rails' router enforces path validity; the validator's job is the *host*, not the path. * **It does not strip credentials from URLs.** `"https://user:pass@myapp.com/x"` matches if `https://myapp.com` is allowlisted (the userinfo is not part of the origin). Browsers strip these before navigating; if you want to forbid them anyway, post-process the return value. * **It does not validate scheme equality for path-only targets.** A path-only target is always accepted regardless of the current request's scheme — `https://myapp.com/foo` redirecting to `/bar` lands on `https://myapp.com/bar`, which is the right answer. * **It does not consult `allowed_redirect_origins` for path-only targets.** A path-only target is always same-origin and bypasses the origin check entirely. If your allowlist is empty, path-only targets still work. * **It is not a CSRF guard.** Open-redirect and CSRF are separate threats. CSRF is handled by Rails' `protect_from_forgery`; the validator only addresses "should I redirect to this URL?". ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode) — the section landing page. * [Configuration → `allowed_redirect_origins`](/reference/rails/configuration#allowed_redirect_origins) — the config key the gem reads. * [`AuthErrorMapper`](/reference/rails/web-mode/auth-error-mapper) — the central `AuthError` translator (note that `INVALID_REDIRECT` is raised by this module directly, not via the mapper, because it's a gem-side check, not an upstream error). * [Authentication → Errors](/reference/rails/authentication) — the controller-side error-handling story. # RefreshCoordinator (/reference/rails/web-mode/refresh-coordinator) `Supabase::Rails::Web::RefreshCoordinator` is the in-process mutex pool that prevents two concurrent threads in the same Puma worker from both calling `auth.refresh_session` for the same Supabase session. The pool is keyed by `SHA256(refresh_token)` — two requests carrying the same cookie cooperate on a single outbound call; two requests carrying different cookies run independently. You almost never call this module directly — [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) wraps every refresh attempt in `RefreshCoordinator.synchronize` before deciding whether to actually issue the upstream call. The module is documented here because (a) the per-worker scope has visible behaviour during clustered deploys, (b) the refcounted cleanup is what keeps the hash from growing unboundedly in long-lived workers, and (c) the `reset!` / `entry_count` API exists for tests that need deterministic mutex state. ```ruby # Pseudocode for what CookieCredentialStrategy runs. outcome = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do current = read_session_cookie if current && !near_expiry?(current) current # someone else already refreshed inside the mutex; reuse else auth_client.refresh_session(refresh_token) end end ``` ## Why a mutex pool [#why-a-mutex-pool] The `:web`-mode cookie is the only credential the browser carries, and Supabase's refresh tokens rotate: a successful `refresh_session` returns a *new* refresh token and invalidates the old one. Two threads that hand the *same* old refresh token to Supabase at the same time will see one succeed and the other fail with a 4xx — even though both requests are perfectly legitimate. The losing thread would clear the cookie, signing the user out mid-page-load. `RefreshCoordinator.synchronize` is the fix: while one thread holds the mutex, every other thread for the same `refresh_token` blocks. Inside the mutex, the strategy re-reads the cookie — by the time the second waiter wakes, the first has already written a fresh session, so the second reuses it instead of issuing a second refresh call. From the user's perspective, both requests are served with the new tokens; from Supabase's perspective, exactly one rotation happened. The mutex pool is a per-process `Hash`. On Puma with multiple workers, two simultaneous requests routed to *different* workers can both refresh — each worker has its own mutex. In practice browsers serialize cookie-bearing requests over HTTP/2 (and almost always over HTTP/1.1), so this race is observable only with hand-crafted concurrent clients. The PRD treats it as acceptable for v0.2 and revisitable if telemetry shows refresh contention. ## Key derivation — `SHA256(refresh_token)` [#key-derivation--sha256refresh_token] Refresh tokens are kept out of memory beyond the request that carries them. The mutex pool keys by `Digest::SHA256.hexdigest(refresh_token.to_s)` instead of the raw token so: * A heap dump or crash log that captures `@entries` shows hex digests, not credentials. * Two `RefreshCoordinator.synchronize(rt)` calls in different parts of the codebase share a mutex even if one passes a `String` and the other passes a `String.dup`. * Coercing `nil` or an unexpected type via `.to_s` keys reliably (`""` digests to a fixed hex string, which is fine — a `nil` refresh token shouldn't reach this code path, but defensive coercion avoids a `NoMethodError`). The digest is a one-way mapping; there is no way to recover the refresh token from `@entries`. This matters during incident debugging — operators can safely log `entry_count` or even iterate keys to confirm the pool is doing what it should. ## `RefreshCoordinator.synchronize(refresh_token, &block)` [#refreshcoordinatorsynchronizerefresh_token-block] | Returns | Raises | | ------------------------ | ---------------------------------------------------------- | | The block's return value | Whatever the block raises (the mutex is released on raise) | Yield the block while holding the per-token mutex. The pool entry is acquired before the block, released after — even if the block raises. The return value is the block's return value, unmodified. ```ruby result = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do # exclusive section — only one thread per refresh_token enters perform_refresh(refresh_token) end ``` ### Reentrancy [#reentrancy] `Mutex#synchronize` is not reentrant — calling `RefreshCoordinator.synchronize(rt)` from inside another `RefreshCoordinator.synchronize(rt)` for the *same* refresh token (and on the same thread) deadlocks the thread on itself. In practice, the only caller is `CookieCredentialStrategy#refresh_or_clear`, which never re-enters; this is documented for hosts writing custom middleware. ### Exception safety [#exception-safety] The `begin/ensure` wrapper guarantees `checkin(key)` runs even if the block raises. The refcount is decremented and the entry dropped when it hits zero, so a block that raises does not leak its mutex slot. The exception itself propagates to the caller (`CookieCredentialStrategy`'s `rescue StandardError` clauses then classify it as `:transient`). ## Refcounted entry cleanup [#refcounted-entry-cleanup] The pool is a `Hash` keyed by digest. Each entry is `{ mutex: Mutex.new, refs: Integer }`. The refcount tracks concurrent holders so the entry can be dropped when no thread is holding it: ```ruby @entries = { "9f86d0..." => { mutex: #, refs: 2 }, # two threads currently refreshing this session "84983e..." => { mutex: #, refs: 1 } } ``` | Step | What happens | | --------------------------------------------- | ---------------------------------------------------------------------------------- | | `checkout(key)` (called before `synchronize`) | Under `@entries_mutex`, find-or-create the entry, `refs += 1`, return the `Mutex`. | | Block executes | The returned mutex is `.synchronize`d. | | `checkin(key)` (called in `ensure`) | Under `@entries_mutex`, `refs -= 1`. If `refs <= 0`, `@entries.delete(key)`. | The two-mutex design (`@entries_mutex` guarding the Hash, per-entry mutex guarding the critical section) keeps the entry-lookup path short — the Hash is only locked for the find-or-create-and-incref window, not for the duration of the refresh. Without the refcount, the pool would grow with every unique refresh token ever observed by the worker — over weeks of uptime that's a memory leak. With it, the pool's size tracks the *current* concurrency: typically 0 in steady state, briefly 1 during a refresh, occasionally 2+ during a coordinated reload. ## `RefreshCoordinator.entry_count` [#refreshcoordinatorentry_count] | Returns | Visibility | | ----------------------------------------- | --------------------------------------------- | | `Integer` — current count of live entries | Public, intended for tests and introspection. | Snapshot the number of live mutex entries in this worker. Mostly useful for tests that assert the pool was cleaned up after a refresh — a non-zero count after every test thread joined indicates a leaked `checkin`, which would mean a bug in the refcount accounting. ```ruby it "drops the entry when the refresh completes" do expect { described_class.synchronize("rt") { "ok" } }.not_to change(described_class, :entry_count) end ``` The reading is under `@entries_mutex` so it sees a consistent count even with concurrent refreshes in flight. ## `RefreshCoordinator.reset!` [#refreshcoordinatorreset] | Returns | Visibility | | ---------------------------------------- | ---------------------------------------------------------------- | | `nil` (the internal `Hash#clear` result) | Public but underscore-spelled `reset!` — test-only escape hatch. | Forcibly drop every entry. Intended for tests where a previous example may have leaked a `synchronize` block (e.g. via a forced thread kill) and the next example needs a clean pool. Calling this while another thread is inside `synchronize` is safe — the held mutex object still exists (Ruby's GC keeps it alive while the holding thread references it), but new `checkout` calls allocate a fresh entry. ```ruby RSpec.configure do |config| config.before(:each) { Supabase::Rails::Web::RefreshCoordinator.reset! } end ``` There is no production use case for `reset!` — the refcounting handles steady-state cleanup. Calling it in a running app would not break anything (worst case, two concurrent refreshes for the same token would briefly race), but there is no reason to. ## Worked example — two concurrent requests [#worked-example--two-concurrent-requests] Two browser tabs share the same cookie. Both fire `GET /dashboard` at the moment `expires_at <= now + 10s`. ``` Thread A Thread B │ │ │ CookieCredentialStrategy#call │ CookieCredentialStrategy#call │ refresh_or_clear(session) │ refresh_or_clear(session) │ │ │ RefreshCoordinator.synchronize(rt) │ RefreshCoordinator.synchronize(rt) │ checkout(key) refs: 0 → 1 │ checkout(key) refs: 1 → 2 │ mutex.synchronize ─► acquired │ mutex.synchronize ─► waiting... │ read cookie → stale │ │ auth.refresh_session(rt) │ │ ─► new Session │ │ write_cookie(new_session) │ │ return new_session │ │ mutex.synchronize ─► released │ mutex.synchronize ─► acquired │ checkin(key) refs: 2 → 1 │ read cookie → fresh enough! │ │ return existing session │ │ mutex.synchronize ─► released │ │ checkin(key) refs: 1 → 0 │ │ @entries.delete(key) │ │ │ apply_outcome(new_session) │ apply_outcome(existing_session) │ ─► user_context │ ─► user_context ``` Thread B's second cookie read is the load-bearing optimization. Without it, Thread B would wait for the mutex, then redundantly call `auth.refresh_session(rt')` with the *new* refresh token Thread A just wrote — at best a wasted network round-trip, at worst a 4xx if the upstream considers same-second double rotation an error. The re-read makes Thread B reuse Thread A's work. ## What this module does *not* do [#what-this-module-does-not-do] * **It does not own retry logic.** A failed refresh raises; `CookieCredentialStrategy` decides whether to clear the cookie or surface `REFRESH_UNAVAILABLE`. The coordinator's job ends at "exactly one thread per token runs the block". * **It does not span workers.** Two requests routed to different Puma workers each acquire their own worker-local mutex and may both refresh. See the [per-worker scope callout](#why-a-mutex-pool). * **It does not enforce a timeout.** `Mutex#synchronize` blocks indefinitely. The upstream `Supabase::Auth::Client#refresh_session` has its own connect/read timeouts (defaults from `Net::HTTP`), so a hung Supabase Auth backend bounds the wait without the coordinator needing its own timer. * **It does not log.** Logging happens in `CookieCredentialStrategy` — the coordinator is a pure synchronization primitive. ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode) — where the refresh phase fits into the request lifecycle. * [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) — the only production caller of `synchronize`, and the place where refresh outcomes are classified. * [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) — builds the per-request `Supabase::Auth::Client` that `refresh_session` is called on inside the mutex. * [Session store](/reference/rails/authentication/session-store) — the encrypted-cookie wrapper that the inside-mutex re-read calls. # RequestScopedStorage (/reference/rails/web-mode/request-scoped-storage) `Supabase::Rails::Web::RequestScopedStorage` implements the `Supabase::Auth::SupportedStorage` duck-type (`get_item` / `set_item` / `remove_item`) against a per-request `Hash` stored in `request.env["supabase.rails.auth_storage"]`. It's what the upstream `Supabase::Auth::Client` calls when it needs to persist transient state (PKCE code verifiers, in-flight session shapes) during a single request — without leaking that state to a concurrent request on the same Puma thread or a different user on the next request. You almost never instantiate this directly. [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) constructs one per request and passes it as `storage:` to `Supabase::Auth::Client.new`. The class is documented here because (a) the per-request scoping is the load-bearing safety property for a multi-threaded Puma worker, (b) the PKCE-verifier cookie fallback is the only mechanism that lets the OAuth round-trip work at all (the verifier is *written* on one request and *read* on another), and (c) hosts writing custom OAuth flows need the `oauth_state` accessor to opt into the cookie fallback. ```ruby # Pseudocode for what AuthClientFactory does on every :web request. storage = Supabase::Rails::Web::RequestScopedStorage.new(request) storage.oauth_state = params[:state] if params[:state] # opt into cookie fallback ::Supabase::Auth::Client.new( storage: storage, auto_refresh_token: false, flow_type: "pkce", # ... ) ``` ## Per-request scoping [#per-request-scoping] The backing Hash lives in `request.env[ENV_KEY]` where `ENV_KEY = "supabase.rails.auth_storage"`. It's lazy: `backing_hash` allocates an empty `{}` only on first access, so requests that never touch storage cost nothing. Subsequent `RequestScopedStorage.new(request)` calls for the same request observe the *same* Hash via the env key — memoized across instantiations within one request. Because the storage is keyed off `request.env`, two requests served concurrently by the same worker get two independent Hashes. A PKCE verifier written by request A is invisible to request B; a session shape written by request B never bleeds into request A. There is no module-level state — every read and write goes through the request's env. ``` ┌────────────────────────────────────────────┐ Request A ──► │ request.env["supabase.rails.auth_storage"] │ → {} (A's slot) └────────────────────────────────────────────┘ ┌────────────────────────────────────────────┐ Request B ──► │ request.env["supabase.rails.auth_storage"] │ → {} (B's slot, different object) └────────────────────────────────────────────┘ ``` This is the multi-threaded-worker safety property: even with Puma's `threads 5,5`, no thread can observe another thread's storage Hash. ## Constructor [#constructor] ```ruby RequestScopedStorage.new( request, # ActionDispatch::Request or any object responding to #env oauth_state: nil # String — opt-in to the PKCE-verifier cookie fallback ) ``` `oauth_state` is also writeable via the public `attr_accessor`: ```ruby storage = RequestScopedStorage.new(request) storage.oauth_state = "abc123" ``` When `oauth_state` is `nil` or an empty string, the cookie fallback is disabled — only the in-memory Hash is used. When it's a non-empty string, writes to PKCE-verifier keys (those ending in `code-verifier`) mirror into a signed cookie `sb-oauth-state-`; reads consult the cookie if the Hash misses. The gem-shipped `OauthController#create` sets `oauth_state` to the `state` Supabase Auth returns from `sign_in_with_oauth`; `OauthController#callback` sets it to `params[:state]` to read the verifier back. Hosts writing custom OAuth flows must do the same — without `oauth_state`, the verifier dies with the request. ## Constants [#constants] | Constant | Value | Meaning | | -------------------- | ------------------------------- | ------------------------------------------------------------------------------ | | `ENV_KEY` | `"supabase.rails.auth_storage"` | Rack env key the backing Hash is memoized under. | | `COOKIE_NAME_PREFIX` | `"sb-oauth-state-"` | Signed-cookie name prefix; the full name is `"sb-oauth-state-#{oauth_state}"`. | | `COOKIE_TTL_SECONDS` | `600` | 10-minute TTL on the PKCE-verifier cookie. | | `PKCE_KEY_SUFFIX` | `"code-verifier"` | Suffix the storage matches on to decide whether a key is a PKCE verifier. | `COOKIE_TTL_SECONDS = 600` is generous for an OAuth round-trip — typical IdP latency is under 30 seconds end-to-end, including user interaction. Ten minutes covers the slowest realistic flow (the user paused on the IdP's consent page) without keeping verifiers alive long enough to be useful to an attacker. ## `#get_item(key)` [#get_itemkey] | Returns | Behaviour | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | The stored value (any type the upstream client wrote) | First consults the per-request Hash; if missing AND the key is a PKCE verifier AND `oauth_state` is set, falls back to the signed cookie. | ```ruby def get_item(key) value = backing_hash[key] return value unless value.nil? read_pkce_cookie(key) end ``` The cookie fallback only fires for missing values. A value of `false` or `0` in the Hash is returned as-is (only `nil` triggers the fallback). The cookie read goes through `request.cookie_jar.signed[cookie_name]` — Rails' signed-jar tampering protection applies, so a forged `sb-oauth-state-` cookie is rejected at decode time. ## `#set_item(key, value)` [#set_itemkey-value] | Returns | Side effects | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `value` (unchanged) | Writes to the per-request Hash. If the key is a PKCE verifier AND `oauth_state` is set, mirrors the value into the signed cookie. | ```ruby def set_item(key, value) backing_hash[key] = value write_pkce_cookie(value) if pkce_key?(key) && oauth_state_present? value end ``` The mirrored cookie is written with these attributes: | Attribute | Value | | ----------- | ------------------------------------------------------------------------------------------- | | `value` | The PKCE verifier (a base64-url-safe random string). | | `expires` | `Time.now + 600` (10 minutes). | | `httponly` | `true` — JavaScript cannot read the verifier. | | `same_site` | `:lax` — the cookie is sent on the OAuth provider's top-level redirect back to `/callback`. | | `path` | `"/"`. | | `secure` | `Rails.env.production?` — required in production, optional locally. | The signed jar adds a tamper-evident HMAC over the value using the host's `secret_key_base`. The cookie *is* readable as plaintext base64 in the browser; the HMAC just prevents the user from forging a different verifier. This is fine — the verifier is single-use and bound to the `state` param. ## `#remove_item(key)` [#remove_itemkey] | Returns | Side effects | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | The deleted value (or `nil` if the key was absent) | Deletes from the per-request Hash. If the key is a PKCE verifier AND `oauth_state` is set, also deletes the signed cookie. | ```ruby def remove_item(key) existed = backing_hash.key?(key) value = backing_hash.delete(key) clear_pkce_cookie if pkce_key?(key) && oauth_state_present? existed ? value : nil end ``` The `existed` flag preserves the standard `Hash#delete` semantics — returning `nil` for a key that wasn't present, even if the same key happened to map to a stored `nil` (which would never happen in practice for PKCE verifiers, but matters for the upstream client's contract). The PKCE cookie is cleared via `jar.delete(cookie_name, path: "/")` on the base (unsigned) jar — `Rails`'s cookie jar's `delete` always operates on the base jar, since the signed wrapper is only for reads/writes. ## PKCE-verifier cookie fallback [#pkce-verifier-cookie-fallback] PKCE (Proof Key for Code Exchange) requires the verifier written during `sign_in_with_oauth` to be readable when the IdP redirects back to your app's `/callback`. Those are **two different HTTP requests** — the in-memory Hash from the first request is long gone by the time the second arrives. The signed-cookie fallback bridges that gap. ``` Request 1: POST /oauth/start ├─► Supabase::Auth::Client#sign_in_with_oauth │ ├─► storage.set_item("supabase.auth.code-verifier", "VERIFIER_X") │ │ ├─► backing_hash["supabase.auth.code-verifier"] = "VERIFIER_X" │ │ └─► (oauth_state set) cookies.signed["sb-oauth-state-ABC"] = "VERIFIER_X" │ └─► returns redirect URL to IdP └─► browser redirected to IdP Request 2: GET /oauth/callback?state=ABC&code=... ├─► OauthController#callback │ ├─► storage.oauth_state = "ABC" │ └─► Supabase::Auth::Client#exchange_code_for_session │ └─► storage.get_item("supabase.auth.code-verifier") │ ├─► backing_hash[...] → nil (new request, empty Hash) │ └─► cookies.signed["sb-oauth-state-ABC"] → "VERIFIER_X" ✓ └─► session established, cookie cleared via remove_item ``` The `state` param binds the verifier to a specific round-trip. An attacker who triggers a parallel OAuth flow for the same user gets a different `state`, hits a different `sb-oauth-state-` cookie, and cannot consume the legitimate verifier. Concurrent OAuth attempts by the same user (multiple browser tabs) each get their own state-keyed verifier and don't collide. ### Why a Hash key suffix instead of an exact key [#why-a-hash-key-suffix-instead-of-an-exact-key] `pkce_key?(key)` matches any string ending with `"code-verifier"`. The upstream `Supabase::Auth::Client` writes verifiers under keys like `"supabase.auth.code-verifier"` — the prefix is the upstream library's namespace, which the storage doesn't depend on. Matching by suffix keeps `RequestScopedStorage` insulated from upstream renames; any new code-verifier-like key the upstream introduces is automatically mirrored. Non-PKCE keys (`"supabase.auth.token"`, session shapes, etc.) hit only the in-memory Hash and never touch a cookie — they're transient by design and don't need to survive beyond the request. ## `#backing_hash` [#backing_hash] | Returns | Visibility | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `Hash` — the per-request storage Hash | Public (the implementation needs it externally callable on the duck-type, though hosts almost never read it directly). | Allocates `env[ENV_KEY] ||= {}` on first access and returns it. Inspecting `backing_hash` in a debugger is a useful smoke test: ```ruby binding.pry # mid-request storage = Supabase::Rails::Web::RequestScopedStorage.new(request) storage.backing_hash # => {"supabase.auth.token" => {...}, "supabase.auth.code-verifier" => "VERIFIER_X"} ``` ## Customising the cookie attributes [#customising-the-cookie-attributes] There is no config knob — `same_site: :lax`, `httponly: true`, the prefix, and the TTL are hardcoded. The reasoning: * `same_site: :lax` is required: an OAuth provider's `302` back to `/callback` is a top-level navigation, which `:lax` allows. `:strict` would silently drop the cookie and PKCE would fail with "verifier missing". * `httponly: true` prevents a same-origin XSS from exfiltrating in-flight verifiers. * The `sb-oauth-state-` prefix avoids collisions with the host app's cookies and groups the gem's cookies for easy inspection. * The 10-minute TTL is the maximum reasonable round-trip window. Tighter would break slow IdPs (some enterprise SSO setups round-trip through MFA); looser would keep verifier material alive past its useful life. If you need different attributes, the supported path is to subclass `RequestScopedStorage` and override `write_pkce_cookie` — though no production deployment has needed this so far. ## Concurrency [#concurrency] The class is thread-safe by construction: every instance is bound to a single `request`, and Rails serves one thread per request. Two threads in the same Puma worker cannot share a `RequestScopedStorage` because they can't share a request. The signed-cookie jar is similarly per-request. There is no module-level mutex because there is no module-level state. The `@request` instance variable and the `request.env` Hash are owned by the request. ## What this class does *not* do [#what-this-class-does-not-do] * **It does not encrypt the cookie.** The PKCE-verifier cookie is *signed* (tamper-evident), not encrypted. The verifier is visible to the user in their cookie store — that's fine because it's single-use, state-bound, and short-lived. The encrypted-session cookie (`sb-session`) is handled by [`SessionStore`](/reference/rails/authentication/session-store), which is a different module. * **It does not implement a TTL on the in-memory Hash.** The Hash lives for the duration of the request and is discarded with the Rack env when the request completes. No expiry needed. * **It does not survive Puma worker restarts.** The cookie fallback survives across requests *to the same browser*. It does not survive a worker restart in any meaningful way (verifiers are bound to a specific OAuth round-trip, which a worker restart wouldn't normally interrupt), but the cookie itself stays valid until the 10-minute TTL elapses regardless of restarts. * **It does not handle session persistence.** The upstream client's `persist_session: true` flag tells `Supabase::Auth::Client` to call `set_item` on the session, but persistence ends at the request boundary. The encrypted `sb-session` cookie is written by [`SessionStore`](/reference/rails/authentication/session-store) from `start_new_session_for`, not by this storage. ## See also [#see-also] * [Web mode overview](/reference/rails/web-mode) — the request lifecycle, including the OAuth round-trip. * [`AuthClientFactory`](/reference/rails/web-mode/auth-client-factory) — constructs the `Supabase::Auth::Client` and wires `RequestScopedStorage` into its `storage:` option. * [Session store](/reference/rails/authentication/session-store) — the *long-lived* encrypted-cookie wrapper, distinct from this per-request storage. * [Configuration → `oauth_providers`](/reference/rails/configuration#oauth_providers) — the config key that drives whether OAuth buttons appear in the gem's views. * [`CookieCredentialStrategy`](/reference/rails/web-mode/cookie-credential-strategy) — the strategy that wraps everything `:web`-mode does, including the OAuth round-trip's per-request storage lifecycle. # Create a user (admin) (/reference/ruby/auth/admin-createuser) Create a user from the server, bypassing the normal sign-up / confirmation flow. The user is created in the confirmed state by default (when `email_confirm: true` or `phone_confirm: true` is passed), so you can immediately issue them a session via `sign_in_with_password` or by generating a magic link. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] `attributes` is a hash. Pass it as a literal (`{ email: "..." }`) or use Ruby's hash-literal shorthand (`email: "..."`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with a single `:user` field carrying the full `Types::User` (id, email, phone, app\_metadata, user\_metadata, identities, factors, timestamps). On failure raises `Supabase::Auth::Errors::AuthApiError` — common causes are duplicate email/phone (422) or a malformed payload. ## Example — confirmed user with metadata [#example--confirmed-user-with-metadata] ```ruby response = supabase.auth.admin.create_user( email: "ada@example.com", password: "correct horse battery staple", email_confirm: true, user_metadata: { display_name: "Ada Lovelace" }, app_metadata: { plan: "pro", role: "owner" } ) response.user.id # => "8d7f5c4b-..." response.user.email # => "ada@example.com" response.user.app_metadata # => { "plan" => "pro", "role" => "owner", "provider" => "email", ... } ``` ## Example — passwordless user (magic-link only) [#example--passwordless-user-magic-link-only] ```ruby response = supabase.auth.admin.create_user( email: "guest@example.com", email_confirm: true ) # Now generate a one-time magic link to hand to the user: link = supabase.auth.admin.generate_link( type: "magiclink", email: "guest@example.com" ) ``` ## Example — phone-first user [#example--phone-first-user] ```ruby supabase.auth.admin.create_user( phone: "+15555550123", phone_confirm: true, user_metadata: { source: "imported_from_legacy_system" } ) ``` # Delete a user (admin) (/reference/ruby/auth/admin-deleteuser) Delete a user from the project. By default this is a hard delete — the row in `auth.users` is removed and any cascading deletes you've set up in your schema will fire. Pass `should_soft_delete: true` to instead anonymize the user and keep the row. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] `uid` is positional; `should_soft_delete` is a real Ruby keyword argument. Raises `ArgumentError` synchronously if `uid` isn't a syntactically valid UUID. ## Parameters [#parameters] ## Returns [#returns] Returns `nil` on success. Raises `Supabase::Auth::Errors::AuthApiError` (status 404) if no user with that UUID exists; raises `ArgumentError` synchronously if `uid` isn't a valid UUID. ## Example — hard delete [#example--hard-delete] ```ruby supabase.auth.admin.delete_user("8d7f5c4b-1234-4abc-9def-1234567890ab") # The row is gone. Any ON DELETE CASCADE foreign keys to auth.users(id) # will fire — make sure your schema is ready for that before calling. ``` ## Example — soft delete (preserve the row) [#example--soft-delete-preserve-the-row] ```ruby supabase.auth.admin.delete_user( "8d7f5c4b-1234-4abc-9def-1234567890ab", should_soft_delete: true ) # The row is still present in auth.users — email/phone/identities are wiped, # but foreign keys pointing at this user.id remain valid. Useful when you have # audit logs or content rows you don't want to delete. ``` ## Example — handle missing user [#example--handle-missing-user] ```ruby begin supabase.auth.admin.delete_user(user_id) rescue Supabase::Auth::Errors::AuthApiError => e warn "delete failed: #{e.status} #{e.message}" end ``` Validates the UUID client-side via `Helpers.is_valid_uuid` and raises `ArgumentError` before any HTTP round-trip. # Generate an email link (admin) (/reference/ruby/auth/admin-generatelink) Generate a one-time auth link (and its underlying OTP) without GoTrue sending the email itself. Use this when you want to ship the link through your own transactional email provider, embed it in a Slack message, or surface it in a back-office tool. The `type` parameter switches between the different flows GoTrue supports. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] `params` is a hash. Pass it as a literal (`{ type: "magiclink", email: "..." }`) or use Ruby's hash-literal shorthand. ## Parameters [#parameters] ### options keys [#options-keys] ## Returns [#returns] A `Struct` with `:properties` (a `GenerateLinkProperties` Struct exposing `:action_link`, `:email_otp`, `:hashed_token`, `:redirect_to`, `:verification_type`) and `:user` (the affected `Types::User`). The `:action_link` is the URL you'll typically paste into your own email template; `:email_otp` is the bare 6-digit code if you want to display it directly; `:hashed_token` is what's used internally by `verify_otp` when `token_hash:` is passed. ## Example — generate a magic link to send via your own email provider [#example--generate-a-magic-link-to-send-via-your-own-email-provider] ```ruby response = supabase.auth.admin.generate_link( type: "magiclink", email: "ada@example.com", options: { redirect_to: "https://app.example.com/auth/callback" } ) action_link = response.properties.action_link email_otp = response.properties.email_otp # "123456" # Hand action_link to your own SendGrid/Postmark/Resend pipeline: Mailer.deliver(to: "ada@example.com", subject: "Sign in", body: <<~HTML) Click here to sign in, or enter this code: #{email_otp} HTML ``` ## Example — generate a signup confirmation link (with password) [#example--generate-a-signup-confirmation-link-with-password] ```ruby response = supabase.auth.admin.generate_link( type: "signup", email: "new@example.com", password: "correct horse battery staple", options: { data: { display_name: "Newcomer" }, redirect_to: "https://app.example.com/welcome" } ) # The user is created (unconfirmed) and you receive an action_link # they must click to confirm. Useful for "self-serve signup via Slack". puts response.properties.action_link puts response.user.email # => "new@example.com" puts response.user.email_confirmed_at # => nil (still needs confirmation) ``` ## Example — generate a password recovery link [#example--generate-a-password-recovery-link] ```ruby response = supabase.auth.admin.generate_link( type: "recovery", email: "ada@example.com", options: { redirect_to: "https://app.example.com/reset" } ) # The link sends the user to /reset, where you'd then call # update_user(password: "...") on their behalf. puts response.properties.action_link ``` ## Example — generate an email-change confirmation link [#example--generate-an-email-change-confirmation-link] ```ruby # Send the user (on their *current* address) a confirmation link. supabase.auth.admin.generate_link( type: "email_change_current", email: "old@example.com", new_email: "new@example.com" ) # Then ALSO send a confirmation to the *new* address (GoTrue's "double opt-in"): supabase.auth.admin.generate_link( type: "email_change_new", email: "old@example.com", new_email: "new@example.com" ) ``` `options[:redirect_to]` is merged into the request URL's query string (not the JSON body). The returned `GenerateLinkResponse` Struct exposes `properties.action_link`, `properties.email_otp`, `properties.hashed_token`, `properties.redirect_to`, `properties.verification_type`, plus the `user` Struct. # Retrieve a user (admin) (/reference/ruby/auth/admin-getuserbyid) Look up a single user by their UUID. Unlike `get_user` (which decodes the *current* bearer's JWT to find the user), this method fetches any user in the project — no session required. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] `uid` is a positional `String` — the user's UUID. Raises `ArgumentError` synchronously (before any HTTP call) if the string isn't a syntactically valid UUID. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with a single `:user` field carrying the full `Types::User` (id, email, phone, app\_metadata, user\_metadata, identities, factors, timestamps). Raises `Supabase::Auth::Errors::AuthApiError` with status 404 if no user with that UUID exists; raises `ArgumentError` synchronously if `uid` isn't a valid UUID. ## Example — basic fetch [#example--basic-fetch] ```ruby response = supabase.auth.admin.get_user_by_id("8d7f5c4b-1234-4abc-9def-1234567890ab") response.user.email # => "ada@example.com" response.user.app_metadata # => { "plan" => "pro", ... } response.user.identities # => Array ``` ## Example — handling not-found vs malformed UUID [#example--handling-not-found-vs-malformed-uuid] ```ruby begin supabase.auth.admin.get_user_by_id("not-a-uuid") rescue ArgumentError => e # Validation is client-side, no HTTP round-trip happened. warn "bad uuid: #{e.message}" end begin supabase.auth.admin.get_user_by_id("00000000-0000-0000-0000-000000000000") rescue Supabase::Auth::Errors::AuthApiError => e warn "no such user: #{e.status} #{e.message}" # => 404 end ``` Performs a synchronous client-side UUID format check via `Helpers.is_valid_uuid` and raises `ArgumentError` before any HTTP call. # Send an email invite link (admin) (/reference/ruby/auth/admin-inviteuserbyemail) Send an invitation email to a new user. The email links to the configured `redirect_to` URL with an embedded invite token; clicking it creates the user and signs them in. Use this for B2B onboarding flows where you're seeding accounts from a known list of addresses. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] `email` is a positional `String`; `options` is an optional positional hash (defaults to `{}`). ## Parameters [#parameters] ### options keys [#options-keys] ## Returns [#returns] A `Struct` with a single `:user` field carrying the newly created (still-unconfirmed) `Types::User`. Raises `Supabase::Auth::Errors::AuthApiError` (status 422) if a user with the email already exists. ## Example — minimal invite [#example--minimal-invite] ```ruby response = supabase.auth.admin.invite_user_by_email("ada@example.com") response.user.id # => "8d7f5c4b-..." response.user.email # => "ada@example.com" response.user.invited_at # => 2026-06-12 12:34:56 UTC ``` ## Example — invite with seeded metadata and a redirect [#example--invite-with-seeded-metadata-and-a-redirect] ```ruby supabase.auth.admin.invite_user_by_email( "ada@example.com", data: { display_name: "Ada Lovelace", role: "engineer", invited_by_team: "team_abc" }, redirect_to: "https://app.example.com/onboarding" ) ``` ## Example — handle "already invited" [#example--handle-already-invited] ```ruby begin supabase.auth.admin.invite_user_by_email("existing@example.com") rescue Supabase::Auth::Errors::AuthApiError => e # 422 — a user with this email already exists. Re-issue the invite via # admin.generate_link(type: "invite") if you want to send another email. warn "invite failed: #{e.status} #{e.message}" end ``` `options` is a single positional hash, so callers write `invite_user_by_email("...", data: { ... }, redirect_to: "...")` via Ruby's hash-literal shorthand. # List all users (admin) (/reference/ruby/auth/admin-listusers) List every user in the project, one page at a time. Use this for back-office tooling, exports, or audit dashboards. There is no built-in filter — paginate through the full set and filter client-side, or query the underlying `auth.users` table from Postgres if you need to slice by metadata. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. ## Signature [#signature] Both pagination args are real Ruby keyword arguments — call as `list_users(page: 2, per_page: 100)`. ## Parameters [#parameters] ## Returns [#returns] An array of `Types::User` Structs (id, email, phone, app\_metadata, user\_metadata, identities, factors, timestamps). The array is empty if the page is past the end of the user list. Total counts and `next`/`last` page hints are NOT surfaced on the Ruby return value — paginate by checking whether the returned array is empty or shorter than `per_page`. Raises `Supabase::Auth::Errors::AuthApiError` on failure. ## Example — fetch the first page [#example--fetch-the-first-page] ```ruby users = supabase.auth.admin.list_users(page: 1, per_page: 50) users.length # => 50 users.first.email # => "ada@example.com" users.first.id # => "8d7f5c4b-..." ``` ## Example — iterate every user [#example--iterate-every-user] ```ruby all_users = [] page = 1 loop do batch = supabase.auth.admin.list_users(page: page, per_page: 100) break if batch.empty? all_users.concat(batch) break if batch.length < 100 page += 1 end puts "Total users: #{all_users.length}" ``` ## Example — defaults (page 1, 50 per page) [#example--defaults-page-1-50-per-page] ```ruby # Omit both args to get the GoTrue defaults — equivalent to (page: 1, per_page: 50). supabase.auth.admin.list_users ``` The method takes real keyword args — `list_users(page: 2)` is a true kwarg call, not hash shorthand. Returns a bare `Array`; total counts and link-header pagination hints are not currently surfaced. # Update a user (admin) (/reference/ruby/auth/admin-updateuserbyid) Update a user's email, phone, password, metadata, ban status, or role from the server. Unlike `update_user` (which mutates the *current* signed-in user), this method can target any user in the project. This endpoint requires the project's `service_role` key. Never call it from a browser, mobile app, or any client you don't fully control. In particular, `app_metadata` and `role` MUST never be writeable by end-users — that's the whole point of routing those changes through this admin endpoint. ## Signature [#signature] `uid` is a positional UUID `String`; `attributes` is a positional hash. Raises `ArgumentError` synchronously if `uid` isn't a syntactically valid UUID. ## Parameters [#parameters] ### attributes keys [#attributes-keys] ## Returns [#returns] A `Struct` with a single `:user` field carrying the updated `Types::User`. Raises `Supabase::Auth::Errors::AuthApiError` (status 404) if the user does not exist, or status 422 on validation failures (e.g. duplicate email). Raises `ArgumentError` synchronously if `uid` isn't a valid UUID. ## Example — promote a user to a paid plan [#example--promote-a-user-to-a-paid-plan] ```ruby response = supabase.auth.admin.update_user_by_id( "8d7f5c4b-1234-4abc-9def-1234567890ab", app_metadata: { plan: "pro", plan_started_at: Time.now.iso8601 } ) response.user.app_metadata["plan"] # => "pro" ``` ## Example — force-reset a password [#example--force-reset-a-password] ```ruby supabase.auth.admin.update_user_by_id( user_id, password: SecureRandom.base64(32) ) # Existing sessions remain valid! Pair with sign_out if you need to force reauth. supabase.auth.admin.sign_out(user_access_token, "global") ``` ## Example — ban a user for 24 hours [#example--ban-a-user-for-24-hours] ```ruby supabase.auth.admin.update_user_by_id(user_id, ban_duration: "24h") # Later, lift the ban early: supabase.auth.admin.update_user_by_id(user_id, ban_duration: "none") ``` ## Example — change email without confirmation [#example--change-email-without-confirmation] ```ruby # Set the new email AND mark it confirmed in one call to skip the email-link round-trip. supabase.auth.admin.update_user_by_id( user_id, email: "new-address@example.com", email_confirm: true ) ``` Validates the `uid` UUID format client-side with `Helpers.is_valid_uuid` and raises `ArgumentError` before any HTTP call. # Auth Admin (/reference/ruby/auth/admin) The `auth.admin` namespace wraps GoTrue's privileged admin endpoints. Construct a dedicated service-role client and call methods on `supabase.auth.admin`: ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_SERVICE_ROLE_KEY") ) supabase.auth.admin.list_users(page: 1, per_page: 50) ``` Every method on `auth.admin` calls a privileged endpoint that bypasses Row Level Security. The bearer token MUST be a `service_role` key, not the `anon` key. Never ship a service-role key to a browser, mobile app, or any client you don't fully control — use it only on a trusted server. ## Methods [#methods] | Method | Description | | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [`admin.create_user`](/reference/ruby/auth/admin-createuser) | Create a user directly, bypassing email/phone confirmation. | | [`admin.list_users`](/reference/ruby/auth/admin-listusers) | List users with pagination. | | [`admin.get_user_by_id`](/reference/ruby/auth/admin-getuserbyid) | Fetch a single user by UUID. | | [`admin.update_user_by_id`](/reference/ruby/auth/admin-updateuserbyid) | Update any field on any user. | | [`admin.delete_user`](/reference/ruby/auth/admin-deleteuser) | Hard- or soft-delete a user. | | [`admin.invite_user_by_email`](/reference/ruby/auth/admin-inviteuserbyemail) | Send an invite email with a signup link. | | [`admin.generate_link`](/reference/ruby/auth/admin-generatelink) | Generate magic-link, recovery, invite, or signup links without sending the email. | # Retrieve a session (/reference/ruby/auth/getsession) Return the current `Session` for the signed-in user. If the session is within ten seconds of `expires_at` (the `EXPIRY_MARGIN` constant), `get_session` transparently calls the refresh-token endpoint and returns the freshly-rotated session. Returns `nil` when no session is stored. When `persist_session: true` (the default), the session is read from the configured storage backend; otherwise it is read from the in-memory `@current_session`. ## Signature [#signature] No arguments. ## Parameters [#parameters] This method takes no parameters. ## Returns [#returns] A `Struct` with `:access_token`, `:refresh_token`, `:token_type`, `:expires_in`, `:expires_at`, `:provider_token`, `:provider_refresh_token`, and `:user`. Returns `nil` when no session is stored, or when stored session data is unparseable (e.g. corrupted storage). If the session has already expired and a refresh attempt fails, `Supabase::Auth::Errors::AuthSessionMissing` is raised. ## Example — read the current session [#example--read-the-current-session] ```ruby session = supabase.auth.get_session if session puts "Signed in as #{session.user.email}" puts "Token expires at #{Time.at(session.expires_at)}" else puts "Not signed in" end ``` ## Example — use the access token in a downstream request [#example--use-the-access-token-in-a-downstream-request] ```ruby session = supabase.auth.get_session raise "Not signed in" unless session Faraday.get( "https://api.example.com/me", nil, { "Authorization" => "Bearer #{session.access_token}" } ) ``` ## Example — gate work on an explicit refresh [#example--gate-work-on-an-explicit-refresh] ```ruby session = supabase.auth.get_session # get_session has already refreshed if the token was within 10s of expiry, # but you can force-refresh by passing the refresh_token to refresh_session: fresh = supabase.auth.refresh_session(session.refresh_token) if session ``` `EXPIRY_MARGIN` is 10 seconds — refresh fires within that window. Persist-session/in-memory branching is driven by `ClientOptions`. Fields are accessed with method-style readers (`session.access_token`). # Retrieve a user (/reference/ruby/auth/getuser) Fetch the `User` record from GoTrue's `GET /user` endpoint. Without an argument, `get_user` uses the access token from the current session (calling [`get_session`](/reference/ruby/auth/getsession) internally, which may refresh the token if it's near expiry). Pass an explicit JWT to look up a user by an arbitrary token instead. Returns `nil` when there is no session and no JWT is provided. ## Signature [#signature] `jwt` is an optional positional argument — an access token to verify on the server. When omitted, the current session's `access_token` is used. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with a single `:user` field — a `Types::User` containing `:id`, `:email`, `:phone`, `:user_metadata`, `:app_metadata`, `:identities`, `:factors`, `:created_at`, `:last_sign_in_at`, etc. Returns `nil` when no session is stored and no JWT was supplied. Raises `Supabase::Auth::Errors::AuthApiError` if GoTrue rejects the token (expired/invalid). ## Example — use the current session [#example--use-the-current-session] ```ruby response = supabase.auth.get_user if response response.user.id # => "8b3c..." response.user.email # => "ada@example.com" end ``` ## Example — verify a user-supplied token [#example--verify-a-user-supplied-token] ```ruby # E.g. extracted from an Authorization: Bearer header on an incoming request: response = supabase.auth.get_user(bearer_from_request) raise "Invalid token" unless response ``` ## Example — read app and user metadata [#example--read-app-and-user-metadata] ```ruby response = supabase.auth.get_user response.user.user_metadata["display_name"] # set by sign_up :data response.user.app_metadata["provider"] # e.g. "email" or "google" ``` # Retrieve identities linked to a user (/reference/ruby/auth/getuseridentities) Return the identities (email, phone, OAuth providers like Google or GitHub) currently linked to the signed-in user. Useful before calling [`unlink_identity`](/reference/ruby/auth/unlinkidentity) — you need an `identity_id` to unlink, and that's a property of each `UserIdentity` in the list. A session is required — `get_user_identities` raises `Supabase::Auth::Errors::AuthSessionMissing` if no user is signed in. ## Signature [#signature] No arguments — the call always operates on the current session's user. ## Parameters [#parameters] None. ## Returns [#returns] A `Struct` with a single `:identities` field — an Array of `Supabase::Auth::Types::UserIdentity` Structs. Each identity exposes `:id`, `:identity_id`, `:user_id`, `:identity_data`, `:provider`, `:created_at`, `:last_sign_in_at`, `:updated_at`. Raises `Supabase::Auth::Errors::AuthSessionMissing` if there is no active session. ## Example — list every identity [#example--list-every-identity] ```ruby response = supabase.auth.get_user_identities response.identities.each do |identity| puts "#{identity.provider}: #{identity.identity_data["email"] || identity.identity_data["phone"]}" end ``` ## Example — find a specific provider [#example--find-a-specific-provider] ```ruby identities = supabase.auth.get_user_identities.identities google = identities.find { |i| i.provider == "google" } google&.identity_id # => "..." ``` ## Example — combine with unlink [#example--combine-with-unlink] ```ruby identities = supabase.auth.get_user_identities.identities if identities.length > 1 github = identities.find { |i| i.provider == "github" } supabase.auth.unlink_identity(github) if github end ``` Calls `auth.get_user` under the hood and plucks `user.identities` into an `IdentitiesResponse`. Each identity matches the GoTrue wire format. # Overview (/reference/ruby/auth) The `auth` namespace wraps the GoTrue (Supabase Auth) API. Access it via `supabase.auth` after constructing a client. Methods use `snake_case` names, single-hash credentials, and keyword arguments for top-level options. ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) supabase.auth.sign_in_with_password(email: "ada@example.com", password: "secret") ``` ## Sign-in / sign-up [#sign-in--sign-up] | Method | Description | | ------------------------------------------------------------------ | ------------------------------------------------------------- | | [`sign_up`](/reference/ruby/auth/signup) | Create a new user with email/phone + password. | | [`sign_in_with_password`](/reference/ruby/auth/signinwithpassword) | Sign in with email/phone + password. | | [`sign_in_with_otp`](/reference/ruby/auth/signinwithotp) | Send a magic-link or SMS one-time password. | | [`sign_in_with_oauth`](/reference/ruby/auth/signinwithoauth) | Build a redirect URL for a third-party OAuth provider. | | [`sign_in_with_id_token`](/reference/ruby/auth/signinwithidtoken) | Sign in with a provider-issued ID token (e.g. Google, Apple). | | [`sign_in_with_sso`](/reference/ruby/auth/signinwithsso) | Sign in via SAML SSO using domain or provider ID. | | [`sign_in_anonymously`](/reference/ruby/auth/signinanonymously) | Create and sign in an anonymous user. | | [`sign_out`](/reference/ruby/auth/signout) | Revoke the current session and clear local storage. | | [`verify_otp`](/reference/ruby/auth/verifyotp) | Verify a one-time password and produce a session. | | [`resend`](/reference/ruby/auth/resend) | Resend a magic link, SMS OTP, or signup confirmation. | ## Sessions & user [#sessions--user] | Method | Description | | -------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [`get_session`](/reference/ruby/auth/getsession) | Return the current session, refreshing it if needed. | | [`refresh_session`](/reference/ruby/auth/refreshsession) | Force-refresh the session using a refresh token. | | [`set_session`](/reference/ruby/auth/setsession) | Restore a session from existing access + refresh tokens. | | [`get_user`](/reference/ruby/auth/getuser) | Fetch the user for the current (or a supplied) access token. | | [`get_user_identities`](/reference/ruby/auth/getuseridentities) | List the OAuth identities linked to the current user. | | [`update_user`](/reference/ruby/auth/updateuser) | Update the current user's email, password, or metadata. | | [`get_claims`](/reference/ruby/auth/getclaims) | Decode and verify JWT claims for the current (or supplied) token. | | [`reauthenticate`](/reference/ruby/auth/reauthenticate) | Trigger a fresh nonce challenge for the current user. | | [`reset_password_for_email`](/reference/ruby/auth/resetpasswordforemail) | Send a password-reset email. | | [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) | Subscribe to `SIGNED_IN` / `SIGNED_OUT` / `TOKEN_REFRESHED` events. | | [`exchange_code_for_session`](/reference/ruby/auth/exchangecodeforsession) | Finish a PKCE flow by exchanging the auth code for a session. | | [`link_identity`](/reference/ruby/auth/linkidentity) | Link an OAuth identity to the current user. | | [`unlink_identity`](/reference/ruby/auth/unlinkidentity) | Unlink an OAuth identity from the current user. | ## MFA (`supabase.auth.mfa`) [#mfa-supabaseauthmfa] | Method | Description | | -------------------------------------------------------------------------------------------------- | --------------------------------------------- | | [`mfa.enroll`](/reference/ruby/auth/mfa-enroll) | Enroll a new TOTP or phone factor. | | [`mfa.challenge`](/reference/ruby/auth/mfa-challenge) | Start a verification challenge for a factor. | | [`mfa.verify`](/reference/ruby/auth/mfa-verify) | Submit a code to satisfy a challenge. | | [`mfa.challenge_and_verify`](/reference/ruby/auth/mfa-challengeandverify) | Challenge + verify in one call. | | [`mfa.unenroll`](/reference/ruby/auth/mfa-unenroll) | Remove an MFA factor. | | [`mfa.list_factors`](/reference/ruby/auth/mfa-listfactors) | List the current user's verified factors. | | [`mfa.get_authenticator_assurance_level`](/reference/ruby/auth/mfa-getauthenticatorassurancelevel) | Inspect AAL1/AAL2 state from the current JWT. | ## Admin (`supabase.auth.admin`) [#admin-supabaseauthadmin] The admin API requires a `service_role` key — see the [admin overview](/reference/ruby/auth/admin) for the warning and setup pattern. | Method | Description | | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | [`admin.create_user`](/reference/ruby/auth/admin-createuser) | Create a user directly, bypassing email/phone confirmation. | | [`admin.list_users`](/reference/ruby/auth/admin-listusers) | List users with pagination. | | [`admin.get_user_by_id`](/reference/ruby/auth/admin-getuserbyid) | Fetch a single user by UUID. | | [`admin.update_user_by_id`](/reference/ruby/auth/admin-updateuserbyid) | Update any field on any user. | | [`admin.delete_user`](/reference/ruby/auth/admin-deleteuser) | Hard- or soft-delete a user. | | [`admin.invite_user_by_email`](/reference/ruby/auth/admin-inviteuserbyemail) | Send a signup invitation email. | | [`admin.generate_link`](/reference/ruby/auth/admin-generatelink) | Generate magic-link / recovery / invite / signup URLs without sending the email. | Ruby's hash-literal shorthand lets you write `sign_in_with_password(email: "...", password: "...")` directly. Sub-options stay nested under `options: { ... }` to match the wire payload. # Link an identity to a user (/reference/ruby/auth/linkidentity) Attach an additional OAuth identity (Google, GitHub, ...) to the currently signed-in user. Returns the URL the browser should be redirected to in order to authorize with the provider; on callback, the new identity is appended to the user's `identities` list. A session is required — `link_identity` raises `Supabase::Auth::Errors::AuthSessionMissing` if no user is signed in. ## Signature [#signature] `credentials` is a hash. Same shape as [`sign_in_with_oauth`](/reference/ruby/auth/signinwithoauth) — `provider:` plus an optional `options:` hash. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:provider` and `:url`. Redirect the browser to `response.url`; on successful provider callback the identity is linked to the current user. Raises `Supabase::Auth::Errors::AuthSessionMissing` if there is no active session. ## Example — link a GitHub identity [#example--link-a-github-identity] ```ruby response = supabase.auth.link_identity( provider: "github", options: { redirect_to: "https://app.example.com/auth/callback" } ) response.provider # => "github" response.url # => "https://.supabase.co/auth/v1/user/identities/authorize?..." # In a Rails controller: # redirect_to response.url, allow_other_host: true ``` ## Example — request extra scopes [#example--request-extra-scopes] ```ruby response = supabase.auth.link_identity( provider: "google", options: { scopes: "openid profile email https://www.googleapis.com/auth/calendar.readonly", redirect_to: "https://app.example.com/auth/callback" } ) ``` Under `flow_type: "pkce"`, the generated code verifier is stored for later consumption by [`exchange_code_for_session`](/reference/ruby/auth/exchangecodeforsession). # Create a challenge (/reference/ruby/auth/mfa-challenge) Create a short-lived challenge token for an enrolled factor. The challenge is the second step of the MFA flow: enroll → **challenge** → verify. For phone factors, this is the call that actually sends the SMS / WhatsApp message containing the one-time code. A live session is required — `challenge` calls `get_session` internally and raises `Supabase::Auth::Errors::AuthSessionMissing` if the user is signed out. ## Signature [#signature] `params` is a hash. Pass it as a literal (`{ factor_id: "..." }`) or use Ruby's hash-literal shorthand (`factor_id: "..."`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:id` (the challenge ID, required when calling [`mfa.verify`](/reference/ruby/auth/mfa-verify)), `:factor_type` (echoed back from the factor — `"totp"` or `"phone"`), and `:expires_at` (Unix timestamp when this challenge becomes invalid). ## Example — challenge a TOTP factor [#example--challenge-a-totp-factor] ```ruby challenge = supabase.auth.mfa.challenge(factor_id: factor_id) challenge.id # => pass this to mfa.verify challenge.factor_type # => "totp" challenge.expires_at # => 1735689600 ``` ## Example — phone factor with explicit channel [#example--phone-factor-with-explicit-channel] ```ruby # Sends an SMS to the phone number registered with the factor. challenge = supabase.auth.mfa.challenge( factor_id: phone_factor_id, channel: "sms" ) # Or use WhatsApp if your project is configured for it: challenge = supabase.auth.mfa.challenge( factor_id: phone_factor_id, channel: "whatsapp" ) ``` ## Example — handling an expired/no-session caller [#example--handling-an-expiredno-session-caller] ```ruby begin challenge = supabase.auth.mfa.challenge(factor_id: factor_id) rescue Supabase::Auth::Errors::AuthSessionMissing # No live session — send the user back through sign-in first. redirect_to_sign_in end ``` # Create and verify a challenge (/reference/ruby/auth/mfa-challengeandverify) Shortcut that combines [`mfa.challenge`](/reference/ruby/auth/mfa-challenge) and [`mfa.verify`](/reference/ruby/auth/mfa-verify) into a single call. Useful for TOTP, where you already have the user's code in hand and don't need a separate "send the SMS" step. The challenge ID is generated internally and threaded into verify automatically. A live session is required — the inner `challenge` call raises `Supabase::Auth::Errors::AuthSessionMissing` if the user is signed out. ## Signature [#signature] `params` is a hash. Pass it as a literal or use Ruby's hash-literal shorthand. ## Parameters [#parameters] ## Returns [#returns] Same shape as [`mfa.verify`](/reference/ruby/auth/mfa-verify): `:access_token`, `:token_type`, `:expires_in`, `:refresh_token`, `:user`. The local session is updated and `MFA_CHALLENGE_VERIFIED` is dispatched. ## Example — TOTP verify in one call [#example--totp-verify-in-one-call] ```ruby # Most common use: user already has a 6-digit code from their authenticator app. verify = supabase.auth.mfa.challenge_and_verify( factor_id: factor_id, code: "123456" ) verify.access_token # => upgraded JWT (aal2) ``` ## Example — equivalent two-step form [#example--equivalent-two-step-form] ```ruby # These two snippets are equivalent: # Combined: supabase.auth.mfa.challenge_and_verify(factor_id: factor_id, code: code) # Explicit: challenge = supabase.auth.mfa.challenge(factor_id: factor_id) supabase.auth.mfa.verify( factor_id: factor_id, challenge_id: challenge.id, code: code ) ``` ## Example — phone factor with channel [#example--phone-factor-with-channel] ```ruby # Note: this still makes TWO HTTP calls (challenge then verify), so the user # must already have received the SMS/WhatsApp code from a prior challenge — # or the inner challenge will send a new one and you'll need to wait for it. verify = supabase.auth.mfa.challenge_and_verify( factor_id: phone_factor_id, channel: "sms", code: user_supplied_code ) ``` `:channel` is forwarded to the inner `challenge` call. If you want the channel selection, pass it; otherwise leave it off and the default is `"sms"`. # Enroll a factor (/reference/ruby/auth/mfa-enroll) Register a new multi-factor authentication factor for the currently signed-in user. After enrolling, the factor stays in `unverified` status until a challenge is solved with [`mfa.verify`](/reference/ruby/auth/mfa-verify) (or [`mfa.challenge_and_verify`](/reference/ruby/auth/mfa-challengeandverify)). A live session is required — `enroll` calls `get_session` internally and raises `Supabase::Auth::Errors::AuthSessionMissing` if the user is signed out. ## Signature [#signature] `params` is a hash. Pass it as a literal (`{ factor_type: "totp" }`) or use Ruby's hash-literal shorthand (`factor_type: "totp"`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:id`, `:type`, `:friendly_name`, `:totp`, and `:phone`. For TOTP factors `:totp` is itself a Struct with `:qr_code` (a `data:image/svg+xml;utf-8,...` URL ready to embed in an `` tag), `:secret` (the raw base32 seed), and `:uri` (the full `otpauth://` URI). For phone factors `:phone` carries the enrolled number; `:totp` is `nil`. ## Example — TOTP full enroll → challenge → verify cycle [#example--totp-full-enroll--challenge--verify-cycle] ```ruby # Step 1 — enroll a TOTP factor enroll = supabase.auth.mfa.enroll( factor_type: "totp", friendly_name: "Ada's iPhone", issuer: "Example App" ) factor_id = enroll.id qr_code = enroll.totp.qr_code # data:image/svg+xml;utf-8,... secret = enroll.totp.secret # base32 seed for manual entry # Show qr_code in your UI; ask the user to scan it with their authenticator app. # Step 2 — start a challenge once the user is ready to enter a code challenge = supabase.auth.mfa.challenge(factor_id: factor_id) challenge_id = challenge.id # Step 3 — collect the 6-digit code from the user and verify verify = supabase.auth.mfa.verify( factor_id: factor_id, challenge_id: challenge_id, code: "123456" ) verify.access_token # => upgraded JWT (aal2) verify.user.factors # => includes the now-verified factor ``` ## Example — phone factor [#example--phone-factor] ```ruby response = supabase.auth.mfa.enroll( factor_type: "phone", friendly_name: "Personal SMS", phone: "+15555550123" ) response.id # => factor id, status is "unverified" until challenge + verify response.phone # => "+15555550123" response.totp # => nil ``` ## Example — minimal TOTP enrollment [#example--minimal-totp-enrollment] ```ruby # Most fields are optional; only factor_type is required. response = supabase.auth.mfa.enroll(factor_type: "totp") response.totp.uri # otpauth://totp/...?secret=...&issuer=... ``` The `:qr_code` returned by GoTrue is a raw SVG string; `enroll` rewrites it as a `data:image/svg+xml;utf-8,...` URL so it can be dropped straight into an `` without a base64 step. Passing `factor_type: "phone"` without `phone:` surfaces as a GoTrue 4xx, not a client-side error. # Get Authenticator Assurance Level (/reference/ruby/auth/mfa-getauthenticatorassurancelevel) Decode the current session's access token and report the user's authenticator assurance level (AAL). Use it to gate sensitive UI behind AAL2 ("user has stepped up with MFA") or to detect when a user has enrolled a factor but hasn't yet challenged it (`current_level == "aal1"`, `next_level == "aal2"`). This method does NOT raise when there's no session — it returns a Struct with `nil` levels and an empty methods array. That lets you call it on every page load without rescuing. ## Signature [#signature] Takes no arguments. ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] A `Struct` with three fields: * `:current_level` — `"aal1"`, `"aal2"`, or `nil` (no session). Pulled from the `aal` claim in the active JWT. * `:next_level` — `"aal2"` if the user has any verified factor enrolled (i.e. they *could* step up), otherwise the same as `current_level`. A `next_level` higher than `current_level` is your cue to prompt the user to challenge their factor. * `:current_authentication_methods` — Array of `Supabase::Auth::Types::AMREntry`, each with `:method` (e.g. `"password"`, `"otp"`, `"totp"`) and `:timestamp`. Sourced from the `amr` claim in the JWT. ## Example — gate a sensitive action behind AAL2 [#example--gate-a-sensitive-action-behind-aal2] ```ruby aal = supabase.auth.mfa.get_authenticator_assurance_level if aal.current_level != "aal2" if aal.next_level == "aal2" # User has a factor enrolled but hasn't challenged it in this session. redirect_to "/mfa/challenge" else # User hasn't enrolled MFA at all. redirect_to "/mfa/setup" end end ``` ## Example — show the user how they signed in [#example--show-the-user-how-they-signed-in] ```ruby aal = supabase.auth.mfa.get_authenticator_assurance_level aal.current_authentication_methods.each do |entry| case entry.method when "password" then puts "Signed in with password at #{Time.at(entry.timestamp)}" when "otp" then puts "Verified email OTP at #{Time.at(entry.timestamp)}" when "totp" then puts "Stepped up with TOTP at #{Time.at(entry.timestamp)}" end end ``` ## Example — safe to call without a session [#example--safe-to-call-without-a-session] ```ruby # Even if the user is signed out, this returns a Struct rather than raising. aal = supabase.auth.mfa.get_authenticator_assurance_level aal.current_level # => nil aal.next_level # => nil aal.current_authentication_methods # => [] ``` Invalid `amr` entries (e.g. missing `method`) are silently dropped via `.compact`. In practice GoTrue always emits well-formed `amr` entries, so the difference only matters with hand-forged tokens. # List all factors (/reference/ruby/auth/mfa-listfactors) Return the user's enrolled MFA factors grouped by type. Useful for building "Security" settings pages where the user can see which authenticators are active and remove ones they no longer use. Unlike most other MFA methods, this call does not raise on a missing session — it routes through [`get_user`](/reference/ruby/auth/getuser), which returns `nil` for `:user` when there's no session. The returned arrays are simply empty in that case. Only **verified** factors are included in the `:totp` and `:phone` arrays. Unverified factors still appear in `:all`, so you can use this method to spot stuck enrollments too. ## Signature [#signature] Takes no arguments. ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] A `Struct` with three arrays: * `:all` — every factor on the user, in any status (`unverified`, `verified`, etc.). * `:totp` — only factors with `factor_type == "totp"` AND `status == "verified"`. * `:phone` — only factors with `factor_type == "phone"` AND `status == "verified"`. Each element is a `Supabase::Auth::Types::Factor` with `:id`, `:friendly_name`, `:factor_type`, `:status`, `:created_at`, `:updated_at`. ## Example — render a settings UI [#example--render-a-settings-ui] ```ruby factors = supabase.auth.mfa.list_factors puts "Authenticator apps:" factors.totp.each do |f| puts " - #{f.friendly_name || "(unnamed)"} (added #{f.created_at})" end puts "Phone numbers:" factors.phone.each do |f| puts " - #{f.friendly_name || "(unnamed)"}" end ``` ## Example — guard against unverified leftovers [#example--guard-against-unverified-leftovers] ```ruby factors = supabase.auth.mfa.list_factors stuck = factors.all.select { |f| f.status == "unverified" } if stuck.any? # Optionally clean these up — they don't count toward AAL. stuck.each { |f| supabase.auth.mfa.unenroll(factor_id: f.id) } end ``` ## Example — check if the user has any MFA configured [#example--check-if-the-user-has-any-mfa-configured] ```ruby factors = supabase.auth.mfa.list_factors mfa_enabled = factors.totp.any? || factors.phone.any? unless mfa_enabled redirect_to "/security/setup-mfa" end ``` Reads the user's factors from `get_user` and partitions them client-side; there is no dedicated `GET /factors` endpoint. Don't confuse this with `auth.admin.mfa.list_factors(user_id:)`, which is a different surface — see the [admin MFA reference](/reference/ruby/auth/admin) for that one. # Unenroll a factor (/reference/ruby/auth/mfa-unenroll) Permanently delete a previously enrolled MFA factor. After unenrolling, the factor no longer counts toward the user's AAL and cannot be challenged. To enforce MFA again, the user must enroll a new factor. A live session is required — `unenroll` calls `get_session` internally and raises `Supabase::Auth::Errors::AuthSessionMissing` if the user is signed out. ## Signature [#signature] `params` is a hash. Pass it as a literal (`{ factor_id: "..." }`) or use Ruby's hash-literal shorthand (`factor_id: "..."`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:id` — the ID of the factor that was removed (echoed back from GoTrue for confirmation). ## Example — remove a verified TOTP factor [#example--remove-a-verified-totp-factor] ```ruby response = supabase.auth.mfa.unenroll(factor_id: factor_id) response.id # => "the-removed-factor-id" ``` ## Example — clean up a stuck unverified factor [#example--clean-up-a-stuck-unverified-factor] ```ruby # Find any unverified factor leftover from an abandoned enrollment and remove it. factors = supabase.auth.mfa.list_factors abandoned = factors.all.find { |f| f.status == "unverified" } if abandoned supabase.auth.mfa.unenroll(factor_id: abandoned.id) end ``` ## Example — handling errors [#example--handling-errors] ```ruby begin supabase.auth.mfa.unenroll(factor_id: factor_id) rescue Supabase::Auth::Errors::AuthSessionMissing flash[:error] = "Please sign in again to manage your MFA factors." rescue Supabase::Auth::Errors::AuthApiError => e # 404 if the factor doesn't exist, 403 if it belongs to another user, etc. flash[:error] = "Couldn't remove factor: #{e.message}" end ``` Hits `DELETE /factors/{factor_id}` and returns only the deleted factor's ID. Removing the last verified factor downgrades the user's AAL on their next session refresh. # Verify a challenge (/reference/ruby/auth/mfa-verify) Complete the MFA flow by submitting the code the user supplied for an outstanding [`mfa.challenge`](/reference/ruby/auth/mfa-challenge). On success GoTrue returns a fresh access token with the elevated assurance level (`aal2`), the new tokens are saved to the local session, and a `MFA_CHALLENGE_VERIFIED` event is dispatched to any auth-state-change subscribers. A live session is required — `verify` calls `get_session` internally and raises `Supabase::Auth::Errors::AuthSessionMissing` if the user is signed out. ## Signature [#signature] `params` is a hash. Pass it as a literal (`{ factor_id: "...", challenge_id: "...", code: "..." }`) or use Ruby's hash-literal shorthand. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:access_token`, `:token_type`, `:expires_in`, `:refresh_token`, and `:user`. The new access token has its `aal` claim upgraded to `aal2`. The same tokens are also persisted in the client's session store and the bearer is rotated, so subsequent calls automatically use the upgraded JWT. ## Example — verify a TOTP code [#example--verify-a-totp-code] ```ruby verify = supabase.auth.mfa.verify( factor_id: factor_id, challenge_id: challenge.id, code: "123456" ) verify.access_token # => new JWT with aal: "aal2" verify.user.email ``` ## Example — listen for `MFA_CHALLENGE_VERIFIED` [#example--listen-for-mfa_challenge_verified] ```ruby supabase.auth.on_auth_state_change do |event, session| if event == "MFA_CHALLENGE_VERIFIED" puts "User upgraded to AAL2 at #{Time.now}" end end # Later, in the verify step: supabase.auth.mfa.verify( factor_id: factor_id, challenge_id: challenge.id, code: code ) # => block above fires with event = "MFA_CHALLENGE_VERIFIED" ``` ## Example — handling a bad code [#example--handling-a-bad-code] ```ruby begin supabase.auth.mfa.verify( factor_id: factor_id, challenge_id: challenge.id, code: user_supplied_code ) rescue Supabase::Auth::Errors::AuthApiError => e # GoTrue returns 4xx for invalid / expired codes. flash[:error] = "That code didn't work. Try again or request a new challenge." end ``` If GoTrue ever returns a response without an `access_token` field, the local-session save is silently skipped and `MFA_CHALLENGE_VERIFIED` is NOT fired. In practice GoTrue always returns the full session on success, so this only matters if you're proxying responses through a custom layer. # Listen to auth events (/reference/ruby/auth/onauthstatechange) Register a block that is invoked every time the auth state changes. Returns a `Types::Subscription` whose `:unsubscribe` lambda removes the callback from the emitter list. Each subscription gets a `SecureRandom.uuid` id, so a single client can hold many independent listeners. The block receives two positional arguments: an event name (`String`) and the current session (`Types::Session` or `nil`). The block is **always** invoked with the latest session at the moment the event fires — including `nil` for `SIGNED_OUT`. ## Signature [#signature] The block form is the only public API — there is no `on_auth_state_change(callback)` positional variant. Block arity is `|event, session|`. ## Parameters [#parameters] This method takes no positional arguments. It takes a single required block. ## Returns [#returns] A `Struct` with `:id` (the subscription's `SecureRandom.uuid`), `:callback` (the block itself), and `:unsubscribe` (a `lambda` that, when called with no arguments, removes this subscription from the client's emitter map). Hold onto the returned value and call `subscription.unsubscribe.call` when you no longer want to receive events — letting the value go out of scope does **not** unsubscribe. ## Events [#events] The dispatched event vocabulary, as emitted by `auth/client.rb`: | Event | Fired by | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SIGNED_IN` | `sign_in_with_password`, `sign_in_with_otp` (after verify), `sign_in_with_id_token`, `sign_in_anonymously`, `set_session`, `exchange_code_for_session`, `initialize_from_storage` (when a stored session is recovered), `initialize_from_url` (implicit OAuth redirect). | | `SIGNED_OUT` | `sign_out` (any scope other than `"others"`). | | `TOKEN_REFRESHED` | Auto-refresh timer firing, or an explicit `refresh_session` call. | | `USER_UPDATED` | A successful `update_user` PUT. | | `MFA_CHALLENGE_VERIFIED` | `mfa.verify` / `mfa.challenge_and_verify` succeeded and rotated the bearer to aal2. | | `PASSWORD_RECOVERY` | `initialize_from_url` detected a `redirect_type=recovery` fragment. | ## Example — react to sign-in, sign-out, and token refresh [#example--react-to-sign-in-sign-out-and-token-refresh] ```ruby require "supabase" supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) subscription = supabase.auth.on_auth_state_change do |event, session| case event when "SIGNED_IN" puts "Signed in as #{session.user.email}" when "SIGNED_OUT" puts "Signed out" when "TOKEN_REFRESHED" puts "Token rotated; new access_token expires at #{Time.at(session.expires_at)}" end end supabase.auth.sign_in_with_password(email: "ada@example.com", password: "secret") # => SIGNED_IN supabase.auth.sign_out # => SIGNED_OUT subscription.unsubscribe.call ``` ## Example — unsubscribe cleanup with `ensure` [#example--unsubscribe-cleanup-with-ensure] ```ruby subscription = supabase.auth.on_auth_state_change { |event, _| audit(event) } begin run_authenticated_workflow(supabase) ensure subscription.unsubscribe.call end ``` ## Example — fan out into a `Queue` for thread-safe consumption [#example--fan-out-into-a-queue-for-thread-safe-consumption] ```ruby events = Queue.new supabase.auth.on_auth_state_change do |event, session| events.push([event, session]) end # Consume from the main thread (or any other thread of your choice). Thread.new do loop do event, session = events.pop handle_event(event, session) end end ``` This pattern decouples the dispatcher thread (which may be the auto-refresh `Timer` thread — see the callout below) from your business logic. `_notify_all_subscribers` is called inline from the method that triggered the event, so `SIGNED_IN` / `SIGNED_OUT` / `USER_UPDATED` / `MFA_CHALLENGE_VERIFIED` arrive on the thread that called `sign_in_with_password` / `sign_out` / etc., while `TOKEN_REFRESHED` from the auto-refresh path arrives on the background `Supabase::Auth::Timer` thread (`auth/timer.rb`, a plain `Thread.new`). Practical consequences: * **Treat the block as multi-threaded.** Any mutable state you touch from it (instance vars, arrays, hashes) needs a `Mutex` — or push events to a `Queue` and consume them from a single owner thread, as in the example above. * **Don't do long-running work in the block.** A blocking callback delays every subsequent subscriber and, if it runs on the Timer thread, delays the next auto-refresh schedule. * **Exceptions are not caught.** `_notify_all_subscribers` does not `rescue` — a raise inside your block aborts iteration over remaining subscribers and propagates up. On the Timer thread it is swallowed by the timer's outer `rescue StandardError`, but on caller threads it bubbles into the method that triggered the event (e.g. `sign_in_with_password` would re-raise your error). Wrap risky work in `begin/rescue` yourself. * **The async variant** (`Supabase::Auth::Async::Client`) dispatches on the same Ruby threads described above — there is no separate fiber-based dispatch. # Retrieve a new session (/reference/ruby/auth/refreshsession) Rotate the current session's tokens by sending its refresh token to GoTrue's `POST /token?grant_type=refresh_token`. Unlike [`get_session`](/reference/ruby/auth/getsession), which only refreshes when within ten seconds of expiry, `refresh_session` always hits the endpoint. If no `refresh_token` argument is provided, the current session's refresh token is used. A `SIGNED_IN` → `TOKEN_REFRESHED` event is dispatched to [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) subscribers, and the new session replaces the persisted one. ## Signature [#signature] `refresh_token` is an optional positional argument — pass it to refresh a session held outside the client. When omitted, the current session's refresh token is used. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` and `:session` (`access_token`, `refresh_token`, `expires_at`, `user`). The returned `session` is also persisted to storage and stored as the current session. Raises `Supabase::Auth::Errors::AuthSessionMissing` if no refresh token is supplied and no current session exists, or if the refresh attempt comes back without a session. ## Example — refresh the current session [#example--refresh-the-current-session] ```ruby response = supabase.auth.refresh_session response.session.access_token # => "eyJhbGciOi..." (newly rotated) response.session.expires_at # => 1717968000 (newer expiry) ``` ## Example — refresh a session stored elsewhere [#example--refresh-a-session-stored-elsewhere] ```ruby # Pull a refresh token out of your own session storage: stored_refresh_token = MySessionStore.fetch(user_id) response = supabase.auth.refresh_session(stored_refresh_token) MySessionStore.save(user_id, response.session) ``` ## Example — handle a missing session [#example--handle-a-missing-session] ```ruby begin supabase.auth.refresh_session rescue Supabase::Auth::Errors::AuthSessionMissing # No active session — redirect to /sign-in end ``` Dispatches `TOKEN_REFRESHED` via `_notify_all_subscribers` so anyone subscribed through `on_auth_state_change` sees the new session in real time. # Resend an OTP (/reference/ruby/auth/resend) Re-trigger a one-time-password or confirmation email/SMS via GoTrue's `POST /resend` endpoint. Use this when the user reports they never received the original message (mailbox delays, mistyped phone number) or when the previous code expired. The `type:` parameter selects which flow is being resent — it must match the flow that originally generated the message (you cannot turn a `signup` into a `recovery` by resending). Does **not** require an active session. Like the original send, resends are rate-limited server-side. ## Signature [#signature] `credentials` is a hash — pass a literal or use Ruby's hash-literal shorthand. ## Parameters [#parameters] At least one of `email:` or `phone:` is required — calling `resend` with neither raises `Supabase::Auth::Errors::AuthInvalidCredentialsError`. ## Returns [#returns] A `Struct` with `:message_id`, `:user`, and `:session`. For resends `user` and `session` are `nil` (the flow is not yet complete); `message_id` is the GoTrue-side identifier for the message that was queued. Treat a successful return as "the resend was accepted" — no session is established until [`verify_otp`](/reference/ruby/auth/verifyotp) is called with the code. ## Example — resend a signup confirmation [#example--resend-a-signup-confirmation] ```ruby response = supabase.auth.resend( email: "ada@example.com", type: "signup", options: { email_redirect_to: "https://app.example.com/auth/confirmed" } ) response.message_id ``` ## Example — resend an SMS OTP [#example--resend-an-sms-otp] ```ruby supabase.auth.resend( phone: "+15555550123", type: "sms" ) ``` ## Example — resend an email-change confirmation [#example--resend-an-email-change-confirmation] ```ruby # The user already called update_user(email: "ada.new@example.com") but the # confirmation email got lost. Resend it to the *new* address: supabase.auth.resend( email: "ada.new@example.com", type: "email_change", options: { email_redirect_to: "https://app.example.com/auth/email-changed" } ) ``` When both `email:` and `phone:` are supplied, `phone:` is dropped and only `email:` is sent on the wire. The accepted `type:` vocabulary and rate-limiting are GoTrue-side. # Send a password reset request (/reference/ruby/auth/resetpasswordforemail) Trigger GoTrue's `POST /recover` endpoint to email the user a one-time recovery link. The link drops them back at your application with a session that has `PASSWORD_RECOVERY` semantics — your app should then call [`update_user`](/reference/ruby/auth/updateuser) with the new password. Does **not** require an active session: this is the entry point for the "forgot password" flow. ## Signature [#signature] Two positional arguments: an `email` String, and an optional `options` Hash. A keyword-style alias `reset_password_email(email:, **options)` is also provided — same behaviour, different calling style. See the callout below. ## Parameters [#parameters] ## Returns [#returns] GoTrue currently returns an empty JSON object on success, so the parsed body is a `Hash`. Treat this as a fire-and-forget call — branch on whether an exception was raised, not on the return value. On failure (rate limit, invalid email, captcha required) `Supabase::Auth::Errors::AuthApiError` is raised. ## Example — minimal [#example--minimal] ```ruby supabase.auth.reset_password_for_email("ada@example.com") ``` ## Example — with redirect and captcha [#example--with-redirect-and-captcha] ```ruby supabase.auth.reset_password_for_email( "ada@example.com", redirect_to: "https://app.example.com/auth/reset-callback", captcha_token: "10000000-aaaa-bbbb-cccc-000000000001" ) ``` ## Example — completing the flow [#example--completing-the-flow] ```ruby # Step 1: send the email supabase.auth.reset_password_for_email( "ada@example.com", redirect_to: "https://app.example.com/auth/reset-callback" ) # Step 2: after the user clicks the link and your callback page restores the session, # update the password on the recovered session. supabase.auth.update_user(password: "new-strong-password-2026") ``` Two calling styles exist: * `reset_password_for_email(email, options)` — positional form. * `reset_password_email(email:, **options)` — keyword form. Useful when you want to splat an existing hash without naming the `options:` key. Both forward to the same GoTrue endpoint. # Set the session data (/reference/ruby/auth/setsession) Restore an existing session by handing the client a previously-issued `access_token` and `refresh_token` — for example, tokens read from your own session storage when bootstrapping a new client instance. If the access token is still valid, `set_session` calls [`get_user`](/reference/ruby/auth/getuser) to verify it and builds a fresh `Session` struct; if it's already expired, it falls back to a refresh-token exchange. On success the new session is persisted to storage and a `TOKEN_REFRESHED` event is dispatched to [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) subscribers. ## Signature [#signature] Both arguments are positional and required. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` and `:session`. When the access token was still valid, `session` carries the supplied tokens plus the freshly-fetched user. When the access token was expired, `session` holds the refreshed pair returned by GoTrue. Raises `Supabase::Auth::Errors::AuthSessionMissing` if the access token is expired and no usable refresh token was supplied, and `Supabase::Auth::Errors::UserDoesntExist` if the access token can't be matched to a user. ## Example — restore a session from your own storage [#example--restore-a-session-from-your-own-storage] ```ruby tokens = MySessionStore.fetch(user_id) response = supabase.auth.set_session(tokens[:access_token], tokens[:refresh_token]) response.session.user.email # => "ada@example.com" ``` ## Example — fall back to refresh when the access token is expired [#example--fall-back-to-refresh-when-the-access-token-is-expired] ```ruby # access_token is past its exp; set_session refreshes transparently: response = supabase.auth.set_session(expired_access_token, valid_refresh_token) response.session.access_token # => newly-rotated token response.session.refresh_token # => newly-rotated refresh token ``` ## Example — handle missing refresh token [#example--handle-missing-refresh-token] ```ruby begin supabase.auth.set_session(expired_access_token, "") rescue Supabase::Auth::Errors::AuthSessionMissing # Token expired and no refresh token to recover with — sign the user in again. end ``` This page covers `auth.set_session`. The top-level `Supabase::Client#set_auth(token)` is a separate method — use it when you already have a JWT and want to swap the `Authorization` header on every sub-client without touching session state. Use `set_session` when you also have the matching refresh token and want full session management (storage, auto-refresh, state-change events). # Create an anonymous user (/reference/ruby/auth/signinanonymously) Create a new anonymous user and start a session for them. Anonymous users have a real `user.id` (and can be upgraded later by [`update_user`](/reference/ruby/auth/updateuser) — adding an email/password — or by [`link_identity`](/reference/ruby/auth/linkidentity) — adding an OAuth identity), so subsequent calls to `auth.get_user` work as for any other user. Anonymous sign-in must be enabled in the Supabase dashboard under **Authentication → Providers → Anonymous Sign-Ins** for the request to succeed. ## Signature [#signature] `credentials` is optional. Pass it as a hash if you want to attach metadata or a captcha token. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` and `:session`. On success both are populated — the anonymous user is signed in immediately. ## Example — minimal [#example--minimal] ```ruby response = supabase.auth.sign_in_anonymously response.user.id # => "..." response.user.email # => nil (anonymous) response.session.access_token # => "..." ``` ## Example — with metadata [#example--with-metadata] ```ruby response = supabase.auth.sign_in_anonymously( options: { data: { display_name: "Guest 42", referrer: "landing" } } ) response.user.user_metadata["display_name"] # => "Guest 42" ``` ## Example — upgrade to a real account later [#example--upgrade-to-a-real-account-later] ```ruby guest = supabase.auth.sign_in_anonymously # Later, attach an email + password to the same user.id: supabase.auth.update_user(email: "ada@example.com", password: "Lovelace-1815!") ``` # Sign in a user through OAuth (/reference/ruby/auth/signinwithoauth) Build the URL the browser should be redirected to in order to start an OAuth flow with a third-party provider (Google, GitHub, GitLab, Bitbucket, Azure, Facebook, Apple, Twitter, Discord, etc.). This method does not perform the redirect — it returns the URL and the provider name so that the caller (e.g. a Rails controller) can issue the HTTP redirect itself. When the PKCE flow is enabled (`flow_type: "pkce"` on the client), a code verifier is generated and stored in the configured storage; it is consumed later by [`exchange_code_for_session`](/reference/ruby/auth/exchangecodeforsession). ## Signature [#signature] `credentials` is a hash. Pass it as a literal (`{ provider: "google", options: { ... } }`) or use Ruby's hash-literal shorthand (`provider: "google", options: { ... }`). ## Parameters [#parameters] ### `options` keys [#options-keys] ## Returns [#returns] A `Struct` with `:provider` and `:url`. Issue an HTTP redirect to `response.url` to start the flow. ## Example — Google sign-in [#example--google-sign-in] ```ruby response = supabase.auth.sign_in_with_oauth( provider: "google", options: { redirect_to: "https://app.example.com/auth/callback" } ) response.provider # => "google" response.url # => "https://.supabase.co/auth/v1/authorize?provider=google&redirect_to=..." # In a Rails controller: # redirect_to response.url, allow_other_host: true ``` ## Example — custom scopes [#example--custom-scopes] ```ruby response = supabase.auth.sign_in_with_oauth( provider: "github", options: { scopes: "read:user user:email" } ) ``` ## Example — extra query params (Google refresh token) [#example--extra-query-params-google-refresh-token] ```ruby response = supabase.auth.sign_in_with_oauth( provider: "google", options: { redirect_to: "https://app.example.com/auth/callback", scopes: "openid profile email", query_params: { access_type: "offline", prompt: "consent" } } ) ``` This method does not perform the redirect — it returns the URL so the calling framework can issue the HTTP redirect. With `flow_type: "pkce"`, the generated `code_verifier` is persisted to the configured storage. # Sign in a user through OTP (/reference/ruby/auth/signinwithotp) Trigger a passwordless sign-in by sending the user a one-time code (or magic link, for email). The user completes sign-in by calling [`verify_otp`](/reference/ruby/auth/verifyotp) with the code, or by clicking the magic link in the email. By default, GoTrue will create the user if no account exists. Pass `options: { should_create_user: false }` to require an existing account. ## Signature [#signature] `credentials` is a hash — call with a literal (`{ email: "..." }`) or Ruby's hash-literal shorthand (`email: "..."`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:message_id`, `:user`, and `:session`. For magic-link / OTP flows the `user` and `session` fields are `nil` until the code is verified — only `message_id` is populated. ## Example — email magic link [#example--email-magic-link] ```ruby response = supabase.auth.sign_in_with_otp( email: "ada@example.com", options: { email_redirect_to: "https://app.example.com/auth/callback" } ) response.message_id # => "..." ``` ## Example — SMS OTP [#example--sms-otp] ```ruby response = supabase.auth.sign_in_with_otp( phone: "+15555550123", options: { channel: "sms" } ) # Later, after the user reads the SMS: supabase.auth.verify_otp( phone: "+15555550123", token: "123456", type: "sms" ) ``` ## Example — require an existing account [#example--require-an-existing-account] ```ruby response = supabase.auth.sign_in_with_otp( email: "ada@example.com", options: { should_create_user: false } ) ``` Missing both `email` and `phone` raises `Supabase::Auth::Errors::AuthInvalidCredentialsError`. `verify_otp` is the second step. # Sign in a user (/reference/ruby/auth/signinwithpassword) Authenticate an existing user with email/phone + password. On success the new session is persisted (if `persist_session` is enabled in `ClientOptions`) and a `SIGNED_IN` event is dispatched to any [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) subscribers. ## Signature [#signature] `credentials` is a hash — call with a literal (`{ email: "...", password: "..." }`) or Ruby's hash-literal shorthand (`email: "...", password: "..."`). ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` (the authenticated user) and `:session` (`access_token`, `refresh_token`, `expires_at`, `user`). On invalid credentials, GoTrue raises a `Supabase::Auth::Errors::AuthApiError` instead of returning a payload. ## Example — email + password [#example--email--password] ```ruby response = supabase.auth.sign_in_with_password( email: "ada@example.com", password: "Lovelace-1815!" ) response.session.access_token # => "eyJhbGciOi..." response.user.email # => "ada@example.com" ``` ## Example — phone + password [#example--phone--password] ```ruby response = supabase.auth.sign_in_with_password( phone: "+15555550123", password: "Lovelace-1815!" ) ``` ## Example — with captcha [#example--with-captcha] ```ruby response = supabase.auth.sign_in_with_password( email: "ada@example.com", password: "Lovelace-1815!", options: { captcha_token: turnstile_token } ) ``` Missing email/phone or missing password raises `Supabase::Auth::Errors::AuthInvalidCredentialsError`. On success the new session is persisted via the configured storage backend. # Sign in a user through SSO (/reference/ruby/auth/signinwithsso) Start a SAML SSO flow. Identify the IdP either by `domain:` (GoTrue looks up the matching SSO provider for that email domain) or by `provider_id:` (the UUID returned by the admin SSO API). By default the call returns a URL — your application then redirects the user to it to complete the SAML handshake. If you want GoTrue to issue an HTTP 303 redirect on the wire instead, pass `options: { skip_http_redirect: false }`. ## Signature [#signature] `credentials` is a hash. You must supply exactly one of `domain:` or `provider_id:`. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with a single `:url` field — the URL to redirect the user to in order to complete the SAML handshake. Raises `Supabase::Auth::Errors::AuthInvalidCredentialsError` if neither `domain:` nor `provider_id:` is provided. ## Example — by domain [#example--by-domain] ```ruby response = supabase.auth.sign_in_with_sso( domain: "acme-corp.com", options: { redirect_to: "https://app.example.com/auth/callback" } ) response.url # => "https://.supabase.co/auth/v1/sso/redirect?..." ``` ## Example — by provider ID [#example--by-provider-id] ```ruby response = supabase.auth.sign_in_with_sso( provider_id: "d0a8b3a4-9e7f-4c2b-8b1c-3e7f6c2b1a9e", options: { redirect_to: "https://app.example.com/auth/callback" } ) ``` ## Example — let GoTrue issue the redirect [#example--let-gotrue-issue-the-redirect] ```ruby response = supabase.auth.sign_in_with_sso( domain: "acme-corp.com", options: { skip_http_redirect: false } ) ``` # Sign out a user (/reference/ruby/auth/signout) Sign the current user out. The default `scope: "global"` revokes every refresh token issued to the user (every device); `scope: "local"` only revokes the current session; `scope: "others"` revokes every other session but keeps the current one alive. For any `scope` other than `"others"`, the local session is also removed from storage and a `SIGNED_OUT` event is dispatched to [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) subscribers. API errors from the underlying call are suppressed so logout always clears local state. ## Signature [#signature] `options` is an optional hash carrying a single `scope:` key. ## Parameters [#parameters] ## Returns [#returns] Returns `nil`. There is no return value to inspect — assume success if no exception escapes. (API errors from the admin sign-out call are caught and suppressed so local state is always cleared.) ## Example — global sign-out (default) [#example--global-sign-out-default] ```ruby supabase.auth.sign_out # Every refresh token for this user is now revoked across all devices. # Local session removed, SIGNED_OUT event dispatched. ``` ## Example — local-only sign-out [#example--local-only-sign-out] ```ruby supabase.auth.sign_out(scope: "local") # Only this device's session is revoked; other devices stay signed in. # Local session still removed, SIGNED_OUT event still dispatched. ``` ## Example — sign other devices out, stay on this one [#example--sign-other-devices-out-stay-on-this-one] ```ruby supabase.auth.sign_out(scope: "others") # Every OTHER session is revoked; this device's session keeps working. # Local session is NOT removed and no SIGNED_OUT event is dispatched. ``` `AuthApiError` from the underlying admin call is rescued so logout always succeeds locally even if the network revoke fails. # Create a new user (/reference/ruby/auth/signup) Create a new user and (depending on your project's confirmation settings) sign them in. The credentials hash must include either `email:` or `phone:`, plus a `password:` — passwordless sign-in is handled by [`sign_in_with_otp`](/reference/ruby/auth/signinwithotp). On success, GoTrue returns the new `user` and — if email confirmation is disabled or the phone channel returns a session immediately — a `session`. On a project that requires email confirmation, `session` will be `nil` until the user clicks the confirmation link. ## Signature [#signature] `credentials` is a hash. You can pass it as a literal (`{ email: "...", password: "..." }`) or use Ruby's hash-literal shorthand (`email: "...", password: "..."`) — both are equivalent. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` and `:session`. When email confirmation is required, `session` is `nil` and the caller must wait for the user to click the confirmation link. ## Example — email + password [#example--email--password] ```ruby response = supabase.auth.sign_up( email: "ada@example.com", password: "Lovelace-1815!" ) response.user.email # => "ada@example.com" response.session&.access_token ``` ## Example — phone + password [#example--phone--password] ```ruby response = supabase.auth.sign_up( phone: "+15555550123", password: "Lovelace-1815!", options: { channel: "sms" } ) ``` ## Example — with metadata, captcha, and redirect [#example--with-metadata-captcha-and-redirect] ```ruby response = supabase.auth.sign_up( email: "ada@example.com", password: "Lovelace-1815!", options: { data: { full_name: "Ada Lovelace", referrer: "blog" }, captcha_token: "10000000-aaaa-bbbb-cccc-000000000001", email_redirect_to: "https://app.example.com/welcome" } ) ``` The one-of-`email`/`phone` rule and the "password required when email/phone is provided" rule both raise `Supabase::Auth::Errors::AuthInvalidCredentialsError`. # Unlink an identity from a user (/reference/ruby/auth/unlinkidentity) Detach an OAuth identity from the currently signed-in user. Pass either a `Supabase::Auth::Types::UserIdentity` (typically read from [`get_user_identities`](/reference/ruby/auth/getuseridentities)) or a hash with an `:identity_id` key. A session is required — `unlink_identity` raises `Supabase::Auth::Errors::AuthSessionMissing` if no user is signed in. The user must keep at least one identity; GoTrue rejects unlinking the last one. ## Signature [#signature] `identity` is either a `UserIdentity` Struct (responds to `#identity_id`) or a Hash with `:identity_id`. ## Parameters [#parameters] ## Returns [#returns] Returns the parsed GoTrue response body for `DELETE /user/identities/:identity_id`. On success the body is an empty hash; on failure the call raises `Supabase::Auth::Errors::AuthApiError`. Raises `Supabase::Auth::Errors::AuthSessionMissing` if there is no active session. ## Example — unlink the GitHub identity [#example--unlink-the-github-identity] ```ruby identities = supabase.auth.get_user_identities.identities github = identities.find { |i| i.provider == "github" } supabase.auth.unlink_identity(github) ``` ## Example — unlink by identity\_id directly [#example--unlink-by-identity_id-directly] ```ruby supabase.auth.unlink_identity(identity_id: "d0a8b3a4-9e7f-4c2b-8b1c-3e7f6c2b1a9e") ``` Also accepts a plain Hash with `:identity_id` (handy when you've persisted the ID outside a `UserIdentity` Struct). # Update a user (/reference/ruby/auth/updateuser) Mutate the currently signed-in user. The same method handles **email change**, **password change**, and arbitrary **user-metadata updates** — pick the attribute keys that match what you want to change. `update_user` PUTs to `/user` with the access token of the current session, persists the refreshed user object back into the session, and emits a `USER_UPDATED` event to every [`on_auth_state_change`](/reference/ruby/auth/onauthstatechange) subscriber. An active session is required — without one the method raises `Supabase::Auth::Errors::AuthSessionMissing`. ## Signature [#signature] Two positional arguments: an `attributes` Hash with the user fields to change, and an optional `options` Hash. Because Ruby's hash-literal shorthand lets you drop the braces on the last positional Hash, calls *look* keyword-y (`update_user(email: "...")`) — but the method takes a single attributes Hash, not keyword arguments. ## Parameters [#parameters] ### attributes [#attributes] ### options [#options] ## Returns [#returns] A `Struct` with a single `:user` field of type `Supabase::Auth::Types::User` — the updated user record returned by GoTrue. The wrapping client also rebuilds and persists the current session (`access_token` / `refresh_token` are preserved, `user` is replaced) and fires `USER_UPDATED`. Raises `AuthSessionMissing` if no session is active. ## Example — change password [#example--change-password] ```ruby response = supabase.auth.update_user(password: "new-strong-password-2026") response.user.id ``` ## Example — change email [#example--change-email] ```ruby response = supabase.auth.update_user( { email: "ada.new@example.com" }, email_redirect_to: "https://app.example.com/auth/email-changed" ) # response.user.email is still the OLD email until the user clicks the # confirmation link sent to the new address. ``` ## Example — update user\_metadata [#example--update-user_metadata] ```ruby supabase.auth.update_user(data: { full_name: "Ada Lovelace", preferences: { theme: "dark", locale: "en-GB" } }) ``` ## Example — multiple changes at once [#example--multiple-changes-at-once] ```ruby supabase.auth.update_user( email: "ada.new@example.com", password: "new-strong-password-2026", data: { full_name: "Ada Lovelace" } ) ``` An email change kicks off a confirmation flow and does not flip the user's `email` field until the new address confirms; a password change applies immediately. Raises `AuthSessionMissing` when no session is present rather than silently sending an anonymous request. # Verify and log in through OTP (/reference/ruby/auth/verifyotp) Submit the code or token a user received from [`sign_in_with_otp`](/reference/ruby/auth/signinwithotp), an email confirmation link, an SMS, or a password-recovery email to GoTrue's `POST /verify` endpoint. On success the user is signed in, the session is persisted, and a `SIGNED_IN` event is emitted. Two related input modes: * `token:` + (`email:` or `phone:`) — the 6-digit code path (used by SMS and email OTPs). * `token_hash:` (no email/phone needed) — the link path, where the hashed token comes from the URL query string of a magic link / confirmation / recovery email. ## Signature [#signature] `params` is a hash — pass a literal or use Ruby's hash-literal shorthand. ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:user` and `:session`. On successful verification both are populated; the session is also saved to the configured storage and `SIGNED_IN` is dispatched to every `on_auth_state_change` subscriber. ## Example — verify an email magic-link code [#example--verify-an-email-magic-link-code] ```ruby response = supabase.auth.verify_otp( email: "ada@example.com", token: "123456", type: "email" ) response.user.email # => "ada@example.com" response.session.access_token # => "eyJ..." ``` ## Example — verify an SMS code [#example--verify-an-sms-code] ```ruby response = supabase.auth.verify_otp( phone: "+15555550123", token: "123456", type: "sms" ) ``` ## Example — verify a token\_hash from a magic link URL [#example--verify-a-token_hash-from-a-magic-link-url] ```ruby # Your callback page receives ?token_hash=...&type=email in the URL. response = supabase.auth.verify_otp( token_hash: params[:token_hash], type: params[:type] # e.g. "email" or "recovery" ) response.session.access_token ``` ## Example — verify a password-recovery code [#example--verify-a-password-recovery-code] ```ruby # After reset_password_for_email lands the user with a code: response = supabase.auth.verify_otp( email: "ada@example.com", token: "654321", type: "recovery" ) # Now update_user can be called against the recovered session. supabase.auth.update_user(password: "new-strong-password-2026") ``` On success the returned session is saved and `SIGNED_IN` is dispatched. Invalid or expired codes surface as `Supabase::Auth::Errors::AuthApiError`. # Contained by value (/reference/ruby/database/containedby) Filter rows where `column <@ value` (PostgreSQL "contained by"). The inverse of [`contains`](/reference/ruby/database/contains). Auto-serializes the same way. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — array column [#example--array-column] ```ruby # Match rows whose tags are entirely a subset of the supplied list. supabase .from("posts") .select("id, title, tags") .contained_by("tags", ["ruby", "supabase", "postgrest", "rails"]) .execute ``` ## Example — range column [#example--range-column] ```ruby # Match rows whose `during` range fits inside June 2026. supabase .from("bookings") .select("*") .contained_by("during", "[2026-06-01,2026-07-01)") .execute ``` ## Example — jsonb column [#example--jsonb-column] ```ruby supabase .from("preferences") .select("*") .contained_by("settings", { theme: "dark", density: "compact" }) .execute ``` The operand is auto-serialized the same way as [`contains`](/reference/ruby/database/contains). PostgREST receives `column=cd.`. # Column contains every element in a value (/reference/ruby/database/contains) Filter rows where `column @> value` (PostgreSQL "contains"). Works on `array`, `range`, and `jsonb` columns — the method shape-detects the argument and serializes it appropriately. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — array column [#example--array-column] ```ruby # tags is text[] — match rows whose tags include all of these. supabase .from("posts") .select("id, title, tags") .contains("tags", ["ruby", "supabase"]) .execute ``` ## Example — jsonb column [#example--jsonb-column] ```ruby # metadata is jsonb — match rows whose metadata includes { plan: "pro" }. supabase .from("organizations") .select("*") .contains("metadata", { plan: "pro" }) .execute ``` ## Example — range column [#example--range-column] ```ruby # during is a tstzrange — match rows whose range fully contains [2026-06-01, 2026-06-02). supabase .from("bookings") .select("*") .contains("during", "[2026-06-01,2026-06-02)") .execute ``` `Array` is auto-serialized to `{a,b,c}` and `Hash` to JSON. PostgREST receives `column=cs.`. # Retrieve as a CSV (/reference/ruby/database/csv) Switch the response shape to CSV. Lives on the `select` builder. Sets `Accept: text/csv` so PostgREST serializes the result set as comma-separated text. `data` on the returned response is the raw CSV string — feed it to the standard `CSV` library or write it to disk verbatim. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] A builder that, when `.execute`'d, returns a `SingleAPIResponse` whose `data` is the raw CSV string (PostgREST's `text/csv` body). The first line is the header row. ## Example — export a table to CSV [#example--export-a-table-to-csv] ```ruby response = supabase .from("countries") .select("id, name, continent") .order("id") .csv .execute response.data # => "id,name,continent\n1,Algeria,Africa\n2,Angola,Africa\n..." ``` ## Example — parse with the standard library [#example--parse-with-the-standard-library] `response.data` is a plain `String`, so the standard `csv` library reads it without any extra plumbing. ```ruby require "csv" csv_text = supabase .from("countries") .select("id, name, continent") .csv .execute .data rows = CSV.parse(csv_text, headers: true) rows.each { |row| puts "#{row['id']}: #{row['name']}" } ``` ## Example — write the export straight to a file [#example--write-the-export-straight-to-a-file] ```ruby File.write( "countries.csv", supabase.from("countries").select("*").csv.execute.data ) ``` Sets the `text/csv` Accept header and returns the raw body — no client-side parsing. # Delete data (/reference/ruby/database/delete) Delete rows via `DELETE`. Chain at least one [filter](/reference/ruby/database#filters) to scope which rows are removed, then call `.execute`. PostgREST refuses an unfiltered `DELETE` unless you opt in explicitly via [`max_affected`](/reference/ruby/database/maxaffected). ## Signature [#signature] `delete` takes no positional argument — the rows-to-delete are determined entirely by the filters you chain after it. ## Parameters [#parameters] ## Returns [#returns] A chainable builder that mixes in `FilterMixin` — chain filters to scope the delete, then call `.execute` to fire the request and receive an `APIResponse`. ## Example — delete one row by primary key [#example--delete-one-row-by-primary-key] ```ruby response = supabase .from("countries") .delete .eq("id", 250) .execute response.data # => [{ "id" => 250, "name" => "Wakanda", ... }] ``` ## Example — bulk delete with a compound filter [#example--bulk-delete-with-a-compound-filter] ```ruby response = supabase .from("sessions") .delete .lte("expires_at", Time.now.utc.iso8601) .eq("revoked", false) .execute response.data.length # => however many rows were deleted ``` ## Example — fire-and-forget delete [#example--fire-and-forget-delete] `returning: "minimal"` returns no body, so `data` is `[]`. Use this when you don't need the deleted rows back. ```ruby supabase .from("audit_log") .delete(returning: "minimal") .lt("created_at", "2025-01-01") .execute ``` ## Example — count what was deleted [#example--count-what-was-deleted] ```ruby response = supabase .from("sessions") .delete(count: "exact") .lte("expires_at", Time.now.utc.iso8601) .execute response.count # => N rows deleted ``` ## Example — guard a wide delete with `max_affected` [#example--guard-a-wide-delete-with-max_affected] `max_affected(N)` adds `Prefer: handling=strict,max-affected=N`. PostgREST refuses the delete (returning `400`) if the row count would exceed `N`. Use this any time the filter could match more rows than you intend to remove. ```ruby supabase .from("login_attempts") .delete .eq("ip", "203.0.113.7") .max_affected(1_000) .execute ``` ## Example — delete with `in_` (multiple primary keys) [#example--delete-with-in_-multiple-primary-keys] ```ruby supabase .from("countries") .delete .in_("id", [250, 251, 252]) .execute ``` PostgREST refuses unfiltered deletes (no `eq`/`in_`/etc. on the chain) unless `max_affected` is set. # Column equals a value (/reference/ruby/database/eq) Filter rows where `column = value`. Chains onto any verb builder ([`select`](/reference/ruby/database/select), [`update`](/reference/ruby/database/update), [`delete`](/reference/ruby/database/delete), [`upsert`](/reference/ruby/database/upsert)). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder, so you can keep chaining filters or modifiers before `.execute`. ## Example — match a single row by id [#example--match-a-single-row-by-id] ```ruby response = supabase .from("countries") .select("id, name") .eq("id", 1) .execute response.data # => [{ "id" => 1, "name" => "Algeria" }] ``` ## Example — combine with other filters [#example--combine-with-other-filters] ```ruby supabase .from("orders") .select("id, total") .eq("status", "paid") .gte("total", 100) .execute ``` # Use a custom filter (/reference/ruby/database/filter) The low-level primitive every other filter is built on. Pass the PostgREST operator (`eq`, `like`, `cs`, `fts`, etc.) and the **criteria** (the right-hand side of the `operator.criteria` pair) verbatim. Use this when PostgREST grows a new operator before this library exposes a named helper, or when you need to construct the operator string dynamically. Every named filter on the builder ([`eq`](/reference/ruby/database/eq), [`gt`](/reference/ruby/database/gt), [`like`](/reference/ruby/database/like), …) is a thin wrapper that calls `filter` with the matching operator constant. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. If `not_` was called immediately before, the operator is prefixed with `not.` and the negation flag is cleared. ## Example — equivalent of `eq` [#example--equivalent-of-eq] ```ruby # These two calls produce identical wire requests. supabase.from("countries").select("*").eq("id", 1).execute supabase.from("countries").select("*").filter("id", "eq", 1).execute # Wire: ?id=eq.1 ``` ## Example — `in` with a parenthesized criteria string [#example--in-with-a-parenthesized-criteria-string] ```ruby supabase .from("countries") .select("*") .filter("id", "in", "(1,2,3)") .execute # Wire: ?id=in.(1,2,3) ``` ## Example — `contains` (`cs`) with a braced criteria string [#example--contains-cs-with-a-braced-criteria-string] ```ruby # Equivalent to .contains("tags", ["ruby", "supabase"]) supabase .from("posts") .select("*") .filter("tags", "cs", "{ruby,supabase}") .execute # Wire: ?tags=cs.{ruby,supabase} ``` ## Example — full-text search with config [#example--full-text-search-with-config] ```ruby supabase .from("articles") .select("*") .filter("body", "fts(english)", "ruby & postgrest") .execute # Wire: ?body=fts(english).ruby%20%26%20postgrest ``` ## Example — negate with `not_` [#example--negate-with-not_] ```ruby supabase .from("orders") .select("*") .not_.filter("status", "eq", "cancelled") .execute # Wire: ?status=not.eq.cancelled ``` The operator and criteria follow PostgREST's wire format — `criteria` is the literal string PostgREST receives after the operator's dot separator. Refer to the [PostgREST operators docs](https://docs.postgrest.org/en/v12/references/api/tables_views.html#operators) when picking the criteria shape. # Open a builder for a table (/reference/ruby/database/from) Open a `RequestBuilder` for a table. Every read or write against `public.` (or the schema set by [`schema`](/reference/ruby/database/schema)) starts here. The call doesn't hit the wire — it just stores the table name and headers; the request fires when you chain a verb and call `.execute`. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] A chainable builder with `select`, `insert`, `update`, `upsert`, and `delete` methods. Nothing is sent until `.execute` is called on a sub-builder. ## Example — fetch rows from a table [#example--fetch-rows-from-a-table] ```ruby response = supabase .from("countries") .select("id, name") .execute response.data # => [{ "id" => 1, "name" => "Algeria" }, ...] ``` ## Example — `from_` and `table` aliases [#example--from_-and-table-aliases] `from_` and `table` are aliases for `from` — they call the same method. Use whichever reads best at the call site. ```ruby # All three calls are identical. supabase.from("orders").select("*").execute supabase.from_("orders").select("*").execute supabase.table("orders").select("*").execute ``` ## Example — non-public schema [#example--non-public-schema] `from` resolves the table inside the schema set on the client. Use [`schema`](/reference/ruby/database/schema) to switch. ```ruby private_db = supabase.schema("billing") private_db.from("invoices").select("*").execute ``` # Column is greater than a value (/reference/ruby/database/gt) Filter rows where `column > value`. Chains onto any verb builder. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — numeric comparison [#example--numeric-comparison] ```ruby supabase .from("countries") .select("name, population") .gt("population", 50_000_000) .execute ``` ## Example — timestamp comparison [#example--timestamp-comparison] ```ruby supabase .from("orders") .select("*") .gt("created_at", "2026-01-01T00:00:00Z") .execute ``` # Column is greater than or equal to a value (/reference/ruby/database/gte) Filter rows where `column >= value`. Chains onto any verb builder. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — inclusive lower bound [#example--inclusive-lower-bound] ```ruby supabase .from("orders") .select("id, total") .gte("total", 100) .execute ``` ## Example — bounded range with `lt` [#example--bounded-range-with-lt] ```ruby supabase .from("orders") .select("*") .gte("created_at", "2026-01-01") .lt("created_at", "2026-02-01") .execute ``` # Column matches a case-insensitive pattern (/reference/ruby/database/ilike) Filter rows where `column ILIKE pattern`. Same wildcard syntax as [`like`](/reference/ruby/database/like), but case-insensitive. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — case-insensitive prefix [#example--case-insensitive-prefix] ```ruby supabase .from("users") .select("id, email") .ilike("email", "alice@%") .execute # Matches "alice@…", "ALICE@…", "Alice@…" ``` ## Example — fuzzy substring search [#example--fuzzy-substring-search] ```ruby supabase .from("countries") .select("name") .ilike("name", "%land%") .execute # => [{ "name" => "England" }, { "name" => "Iceland" }, { "name" => "Poland" }, ...] ``` Use [`like`](/reference/ruby/database/like) when case matters. # Column is in an array (/reference/ruby/database/in) Filter rows where `column IN (...)`. The method is named `in_` because `in` is a reserved keyword in Ruby's parser. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — match a small set of ids [#example--match-a-small-set-of-ids] ```ruby supabase .from("countries") .select("id, name") .in_("id", [1, 2, 3]) .execute ``` ## Example — string values [#example--string-values] ```ruby supabase .from("orders") .select("*") .in_("status", ["pending", "paid", "shipped"]) .execute ``` ## Example — delete many rows by primary key [#example--delete-many-rows-by-primary-key] ```ruby supabase .from("notifications") .delete .in_("id", stale_ids) .execute ``` `in` is a reserved word in Ruby's parser, so the method is `in_`. PostgREST receives `column=in.(v1,v2,...)`. # Overview (/reference/ruby/database) The `database` surface is the PostgREST query builder reachable through the top-level `Supabase::Client`. Every chain starts with `supabase.from(table)`, then adds a verb (`select` / `insert` / `update` / `upsert` / `delete`), optional [filters](#filters) and [modifiers](#modifiers), and ends with `.execute` to fire the request. ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) response = supabase .from("countries") .select("id, name") .eq("continent", "Africa") .order("name") .limit(20) .execute response.data # => [{ "id" => 1, "name" => "Algeria" }, ...] response.count # => nil unless you asked for count: "exact" ``` `supabase.from` returns a `RequestBuilder`; calling a verb returns a sub-builder that mixes in [`FilterMixin`](https://github.com/supabase/supabase-rb/blob/main/lib/supabase/postgrest/request_builder.rb) (every filter you'd expect from PostgREST) and, for `select`, [`SelectMixin`](https://github.com/supabase/supabase-rb/blob/main/lib/supabase/postgrest/request_builder.rb) (order / limit / offset / range). Nothing hits the wire until `.execute`. ## Builders [#builders] | Method | Description | | ------------------------------------------- | ------------------------------------------------------------------- | | [`from`](/reference/ruby/database/from) | Open a builder for a table. `from_` and `table` are aliases. | | [`select`](/reference/ruby/database/select) | Read rows. Accepts a column list, `count:`, and `head:`. | | [`insert`](/reference/ruby/database/insert) | Insert one row (`Hash`) or many (`Array`). | | [`update`](/reference/ruby/database/update) | Patch rows that match the chained filters. | | [`upsert`](/reference/ruby/database/upsert) | Insert-or-update via `Prefer: resolution=merge-duplicates`. | | [`delete`](/reference/ruby/database/delete) | Delete rows that match the chained filters. | | [`rpc`](/reference/ruby/database/rpc) | Call a stored procedure (`POST` by default, `GET` / `HEAD` opt-in). | | [`schema`](/reference/ruby/database/schema) | Switch the active Postgres schema. Returns a new client. | ## Filters [#filters] Filter methods are mixed into every verb builder. They mutate the request and return `self` so they chain. Every PostgREST operator is exposed. | Method | PostgREST operator | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | [`eq`](/reference/ruby/database/eq) | `eq` | | [`neq`](/reference/ruby/database/neq) | `neq` | | [`gt`](/reference/ruby/database/gt) | `gt` | | [`gte`](/reference/ruby/database/gte) | `gte` | | [`lt`](/reference/ruby/database/lt) | `lt` | | [`lte`](/reference/ruby/database/lte) | `lte` | | [`like`](/reference/ruby/database/like) | `like` | | [`ilike`](/reference/ruby/database/ilike) | `ilike` | | [`like_all_of`](/reference/ruby/database/likeallof) | `like(all)` | | [`like_any_of`](/reference/ruby/database/likeanyof) | `like(any)` | | [`ilike_all_of`](/reference/ruby/database/ilikeallof) | `ilike(all)` | | [`ilike_any_of`](/reference/ruby/database/ilikeanyof) | `ilike(any)` | | [`is_`](/reference/ruby/database/is) | `is` (`null` / `true` / `false`) | | [`in_`](/reference/ruby/database/in) | `in` | | [`contains`](/reference/ruby/database/contains) | `cs` | | [`contained_by`](/reference/ruby/database/containedby) | `cd` | | [`overlaps`](/reference/ruby/database/overlaps) | `ov` | | [`range_lt`](/reference/ruby/database/rangelt) / [`range_gt`](/reference/ruby/database/rangegt) / [`range_gte`](/reference/ruby/database/rangegte) / [`range_lte`](/reference/ruby/database/rangelte) / [`range_adjacent`](/reference/ruby/database/rangeadjacent) | `sl` / `sr` / `nxl` / `nxr` / `adj` | | [`fts`](/reference/ruby/database/fts) / [`plfts`](/reference/ruby/database/plfts) / [`phfts`](/reference/ruby/database/phfts) / [`wfts`](/reference/ruby/database/wfts) | full-text search variants | | [`text_search`](/reference/ruby/database/textsearch) | high-level FTS helper (sets `type:` / `config:`) | | [`match`](/reference/ruby/database/match) | `eq` over every key in a Hash | | [`or_`](/reference/ruby/database/or) | grouped OR clause (`reference_table:` for joins) | | [`not_`](/reference/ruby/database/not) | negate the next filter | | [`filter`](/reference/ruby/database/filter) | escape hatch — pass operator / criteria verbatim | | [`max_affected`](/reference/ruby/database/maxaffected) | cap the number of rows a write may touch | ## Modifiers [#modifiers] Modifiers are added by `SelectMixin`. They apply to `select` (and `rpc`) builders. | Method | Description | | ------------------------------------------- | ------------------------------------------------------------------------- | | [`order`](/reference/ruby/database/order) | `ORDER BY column ASC/DESC`. `foreign_table:` orders an embedded relation. | | [`limit`](/reference/ruby/database/limit) | `LIMIT n`. `foreign_table:` limits an embedded relation. | | [`offset`](/reference/ruby/database/offset) | `OFFSET n`. | | [`range`](/reference/ruby/database/range) | Inclusive `start..finish` (sets `offset` + `limit`). | ## Result-shape switchers [#result-shape-switchers] These live on the `select` builder and change how the response is parsed. | Method | Description | | ------------------------------------------------------ | -------------------------------------------------------- | | [`single`](/reference/ruby/database/single) | Expect exactly one row; raise if the row count is not 1. | | [`maybe_single`](/reference/ruby/database/maybesingle) | Expect 0 or 1 row; nil on 0, raise on more than 1. | | [`csv`](/reference/ruby/database/csv) | Return the body as CSV text instead of JSON. | | [`explain`](/reference/ruby/database/explain) | Return the PostgREST query plan instead of executing. | ## Executing a chain [#executing-a-chain] Every builder returns from each step so you can keep chaining. The wire request only fires when you call `.execute`, which returns a `Supabase::Postgrest::APIResponse` with two readers: * `data` — the parsed JSON body (typically an `Array`; `Hash` for `single`). * `count` — populated only when you opted in with `count: "exact" | "planned" | "estimated"`. A non-2xx response raises `Supabase::Postgrest::Errors::APIError`. The error carries `:message`, `:code`, `:details`, and `:hint` and accepts both string and symbol keys. ```ruby begin supabase.from("countries").select("*").eq("id", -1).single.execute rescue Supabase::Postgrest::Errors::APIError => e e.code # => "PGRST116" (no rows) e.message # => "Cannot coerce the result to a single JSON object" end ``` # Insert data (/reference/ruby/database/insert) Insert one or many rows into a table. The first argument can be a single `Hash` (single-row insert) or an `Array` (bulk insert — PostgREST infers the column list from the first row's keys). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] A chainable builder. Call `.execute` to fire the request and receive an `APIResponse` (`data:` lists the inserted rows when `returning: "representation"`). ## Example — insert a single row [#example--insert-a-single-row] ```ruby response = supabase .from("countries") .insert(name: "Wakanda", continent: "Africa") .execute response.data # => [{ "id" => 250, "name" => "Wakanda", "continent" => "Africa" }] ``` ## Example — bulk insert [#example--bulk-insert] Pass an `Array` to insert many rows in one request. PostgREST collects the column list from the union of every row's keys; rows missing a key receive `NULL` (or the column default — see `default_to_null:`). ```ruby response = supabase .from("countries") .insert([ { name: "Wakanda", continent: "Africa" }, { name: "Genosha", continent: "Africa" }, { name: "Latveria", continent: "Europe" } ]) .execute response.data.length # => 3 ``` ## Example — fire-and-forget (no body back) [#example--fire-and-forget-no-body-back] `returning: "minimal"` returns no body, so `data` is `[]`. Use this when you don't need the inserted row(s) and want to skip the serialization cost. ```ruby supabase .from("audit_log") .insert({ event: "login", user_id: 42 }, returning: "minimal") .execute # response.data => [] ``` ## Example — count without representation [#example--count-without-representation] ```ruby response = supabase .from("countries") .insert([{ name: "Wakanda" }, { name: "Genosha" }], count: "exact") .execute response.count # => 2 ``` ## Example — insert + chained select [#example--insert--chained-select] `select` after `insert` flips the `Prefer` header to `return=representation` (overriding `returning:`) and adds a `select=` query parameter so you can shape the response. ```ruby response = supabase .from("countries") .insert(name: "Wakanda") .select("id, name") .execute response.data # => [{ "id" => 250, "name" => "Wakanda" }] ``` `Types::ReturnMethod::REPRESENTATION` / `::MINIMAL` are the constants behind the string values for `returning:`. # Column is a value (/reference/ruby/database/is) Filter rows using SQL's `IS` operator — the only correct way to test for `NULL`. The method is named `is_` (trailing underscore) because `is` is a reserved word in Ruby's parser. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — find rows where a column is NULL [#example--find-rows-where-a-column-is-null] ```ruby supabase .from("users") .select("id, email, deleted_at") .is_("deleted_at", nil) .execute # => active (non-soft-deleted) users ``` ## Example — boolean column [#example--boolean-column] ```ruby supabase .from("subscriptions") .select("user_id, plan") .is_("active", true) .execute ``` `is` is a reserved word in Ruby's parser, so the method is `is_`. Internally, passing `nil` is rewritten to the literal string `"null"` so PostgREST receives `column=is.null`. # Column matches a pattern (/reference/ruby/database/like) Filter rows where `column LIKE pattern`. Case-sensitive. Use `%` for any-string and `_` for any-single-char. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — prefix match [#example--prefix-match] ```ruby supabase .from("countries") .select("name") .like("name", "United%") .execute # => [{ "name" => "United Kingdom" }, { "name" => "United States" }] ``` ## Example — suffix match [#example--suffix-match] ```ruby supabase .from("files") .select("path") .like("path", "%.pdf") .execute ``` ## Example — substring match [#example--substring-match] ```ruby supabase .from("posts") .select("title") .like("title", "%Ruby%") .execute ``` Case-sensitive — use [`ilike`](/reference/ruby/database/ilike) for the case-insensitive variant. # Limit the number of rows (/reference/ruby/database/limit) Add a `LIMIT` to the query. Lives on the `select` (and `rpc`) builder via `SelectMixin`. Calling `limit` more than once just overwrites the previous value — only the last call sticks. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder, so you can keep chaining other modifiers / filters before `.execute`. ## Example — first 10 rows [#example--first-10-rows] ```ruby response = supabase .from("countries") .select("id, name") .order("name") .limit(10) .execute ``` ## Example — paginate with offset [#example--paginate-with-offset] `limit` pairs with [`offset`](/reference/ruby/database#modifiers) for paging, or use [`range`](/reference/ruby/database/range) for a one-call inclusive slice. ```ruby supabase .from("countries") .select("id, name") .order("id") .offset(20) .limit(10) .execute ``` ## Example — limit an embedded resource [#example--limit-an-embedded-resource] `foreign_table:` caps the joined relation independently of the parent. ```ruby supabase .from("authors") .select("name, books(title)") .limit(5, foreign_table: "books") .execute ``` # Column is less than a value (/reference/ruby/database/lt) Filter rows where `column < value`. Chains onto any verb builder. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — strict upper bound [#example--strict-upper-bound] ```ruby supabase .from("countries") .select("name, population") .lt("population", 1_000_000) .execute ``` ## Example — bounded range with `gte` [#example--bounded-range-with-gte] ```ruby supabase .from("invoices") .select("*") .gte("issued_at", "2026-01-01") .lt("issued_at", "2026-02-01") .execute ``` # Column is less than or equal to a value (/reference/ruby/database/lte) Filter rows where `column <= value`. Chains onto any verb builder. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — inclusive upper bound [#example--inclusive-upper-bound] ```ruby supabase .from("products") .select("id, name, price") .lte("price", 9.99) .execute ``` ## Example — bounded range with `gte` [#example--bounded-range-with-gte] ```ruby supabase .from("readings") .select("*") .gte("recorded_at", "2026-06-01") .lte("recorded_at", "2026-06-30") .execute ``` # Match a Hash of column values (/reference/ruby/database/match) Shorthand that calls [`eq`](/reference/ruby/database/eq) for each key in the supplied Hash. Saves typing when you want to match several columns with `=` simultaneously. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining (after applying every `eq`). ## Example — match a composite of equalities [#example--match-a-composite-of-equalities] ```ruby supabase .from("orders") .select("*") .match(status: "paid", currency: "USD", customer_id: 42) .execute # Equivalent to: # .eq("status", "paid").eq("currency", "USD").eq("customer_id", 42) ``` ## Example — single-key shorthand [#example--single-key-shorthand] ```ruby supabase .from("users") .select("id, email") .match(role: "admin") .execute ``` ## Example — combine with non-`eq` filters [#example--combine-with-non-eq-filters] ```ruby supabase .from("invoices") .select("*") .match(currency: "USD", paid: true) .gte("issued_at", "2026-01-01") .execute ``` Raises `ArgumentError` on an empty or `nil` argument. Each key produces one `column=eq.value` query-string entry. # Retrieve zero or one rows (/reference/ruby/database/maybesingle) Soft variant of [`single`](/reference/ruby/database/single). Lives on the `select` builder. Unlike `single`, the SELECT-chain `maybe_single` does **not** set the `vnd.pgrst.object+json` Accept header — the request goes out expecting an array, and the client inspects the result: * 0 rows → `.execute` returns `nil` (not a response object). * 1 row → `.execute` returns a `SingleAPIResponse` with the row as a `Hash`. * more than 1 row → raises `Supabase::Postgrest::Errors::APIError` with the message `"Cannot coerce the result to a single JSON object"`. Pick `maybe_single` whenever "no row" is a valid case in your domain — it's the friendlier shape for optional lookups. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] A builder that, when `.execute`'d, returns `nil` (no row), a `SingleAPIResponse` (one row, `data` is a `Hash`), or raises an `APIError` (more than one row). ## Example — optional lookup [#example--optional-lookup] ```ruby response = supabase .from("users") .select("id, email") .eq("email", "alice@example.com") .maybe_single .execute if response.nil? puts "no such user" else response.data # => { "id" => 7, "email" => "alice@example.com" } end ``` ## Example — guard a `>1` accident [#example--guard-a-1-accident] If multiple rows match (the filter is not unique), `.execute` raises: ```ruby begin supabase .from("countries") .select("*") .eq("continent", "Africa") # many rows .maybe_single .execute rescue Supabase::Postgrest::Errors::APIError => e e.message # => "Cannot coerce the result to a single JSON object" end ``` If you don't want any `>1` guard, drop `maybe_single` and call `.execute` directly — you'll get the array of rows. ## `maybe_single` vs `single` [#maybe_single-vs-single] | | `maybe_single` | `single` | | ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------- | | Sets `Accept: application/vnd.pgrst.object+json` | no (SELECT chain) | yes | | 0 rows | `.execute` returns `nil` (no exception) | raises `APIError` (`PGRST116`) | | 1 row | `data` is the row as a `Hash` | `data` is the row as a `Hash` | | more than 1 row | raises `APIError` ("Cannot coerce…") — client-side check | raises `APIError` (`PGRST116`) — server-side | | Use when | the row may or may not exist (optional lookup) | the row must exist (PK lookup) | The single most user-visible difference: with `single`, a missing row is an exception you must rescue; with `maybe_single`, it's a `nil` you can branch on. The SELECT-chain `maybe_single` deliberately omits the `vnd.pgrst.object+json` Accept header (the RPC variant *does* set it). This asymmetry is intentional — not a bug. # Column not equal to a value (/reference/ruby/database/neq) Filter rows where `column <> value`. Chains onto any verb builder. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — exclude a status [#example--exclude-a-status] ```ruby supabase .from("orders") .select("*") .neq("status", "cancelled") .execute ``` ## Example — combine with `eq` [#example--combine-with-eq] ```ruby supabase .from("users") .select("id, email") .eq("active", true) .neq("role", "admin") .execute ``` # Negate a filter (/reference/ruby/database/not) Flips the negation flag on the builder so the next filter call is prefixed with `not.` on the wire. Apply this to any filter — `eq`, `like`, `in_`, `is_`, etc. — by chaining `not_` immediately before it. The method is named `not_` because `not` is a reserved word in Ruby's parser. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. It mutates a one-shot flag on the builder and returns `self`; the very next filter call consumes the flag. ## Returns [#returns] The same builder, with the negation flag set. The flag is cleared as soon as the next filter call runs. ## Example — `NOT eq` [#example--not-eq] ```ruby supabase .from("orders") .select("*") .not_.eq("status", "cancelled") .execute # PostgREST receives status=not.eq.cancelled ``` ## Example — `NOT in` [#example--not-in] ```ruby supabase .from("countries") .select("name") .not_.in_("continent", ["Antarctica", "Oceania"]) .execute ``` ## Example — `NOT is null` (find rows where a column is set) [#example--not-is-null-find-rows-where-a-column-is-set] ```ruby supabase .from("users") .select("id, email, last_login_at") .not_.is_("last_login_at", nil) .execute ``` ## Example — `NOT like` [#example--not-like] ```ruby supabase .from("files") .select("path") .not_.like("path", "tmp/%") .execute ``` `not_` sets a one-shot `@negate_next` flag that the next [`filter`](/reference/ruby/database/filter) call consumes; if you call `not_` and then never call a filter, nothing happens. # Match at least one filter (/reference/ruby/database/or) Filter rows where any of the supplied sub-filters match. The `filters` argument is a **PostgREST filter string** — exactly the body of a `or=( … )` query-string parameter — passed through verbatim. `reference_table:` scopes the OR to an embedded resource for foreign-table filtering. The method is named `or_` because `or` is a reserved word in Ruby's parser. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — match rows by id OR by name [#example--match-rows-by-id-or-by-name] ```ruby supabase .from("countries") .select("id, name") .or_("id.eq.1,name.eq.Algeria") .execute # Wire: ?or=(id.eq.1,name.eq.Algeria) ``` ## Example — combine multiple comparisons [#example--combine-multiple-comparisons] ```ruby supabase .from("orders") .select("*") .or_("status.eq.refunded,total.gt.10000") .execute ``` ## Example — nested `and` inside an `or` [#example--nested-and-inside-an-or] PostgREST string syntax allows arbitrarily nested logical groups. Each clause is a filter string. ```ruby supabase .from("subscriptions") .select("*") .or_("plan.eq.enterprise,and(plan.eq.pro,seats.gte.10)") .execute # Wire: ?or=(plan.eq.enterprise,and(plan.eq.pro,seats.gte.10)) ``` ## Example — `not` inside an `or` [#example--not-inside-an-or] ```ruby supabase .from("users") .select("*") .or_("role.eq.admin,not.is.deleted_at.null") .execute ``` ## Example — OR applied to an embedded resource [#example--or-applied-to-an-embedded-resource] ```ruby # Posts with at least one comment that is featured OR has more than 100 likes. supabase .from("posts") .select("id, title, comments(*)") .or_("featured.eq.true,likes.gt.100", reference_table: "comments") .execute # Wire: ?comments.or=(featured.eq.true,likes.gt.100) ``` There is no per-operator DSL — write the raw PostgREST filter string (`column.operator.value` separated by commas). See the [PostgREST logical-operators docs](https://docs.postgrest.org/en/v12/references/api/tables_views.html#logical-operators) for the full grammar. # Order the results (/reference/ruby/database/order) Add an `ORDER BY` clause. Lives on the `select` (and `rpc`) builder via `SelectMixin`. Each call appends to the existing order — call it once per column for multi-key sorts. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder, so you can keep chaining other modifiers / filters before `.execute`. ## Example — ascending by name [#example--ascending-by-name] ```ruby response = supabase .from("countries") .select("id, name") .order("name") .execute ``` ## Example — descending with nulls last [#example--descending-with-nulls-last] ```ruby response = supabase .from("users") .select("id, last_login") .order("last_login", desc: true, nullsfirst: false) .execute ``` ## Example — multi-column ordering [#example--multi-column-ordering] Call `order` once per column. The builder concatenates them with a comma, exactly like PostgREST expects. ```ruby supabase .from("orders") .select("id, customer_id, total") .order("customer_id") .order("total", desc: true) .execute ``` ## Example — order an embedded resource [#example--order-an-embedded-resource] `foreign_table:` targets the joined relation. Use it with a `select` that embeds the table. ```ruby supabase .from("authors") .select("name, books(title, published_at)") .order("published_at", desc: true, foreign_table: "books") .execute ``` # Limit the query to a range (/reference/ruby/database/range) Select an inclusive row range. Lives on the `select` (and `rpc`) builder via `SelectMixin`. Implemented as a one-call shorthand that sets `offset = start` and `limit = finish - start + 1` on the underlying request. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder, so you can keep chaining other modifiers / filters before `.execute`. ## Example — first 10 rows [#example--first-10-rows] `range(0, 9)` is inclusive, so it returns 10 rows (indexes 0..9). ```ruby response = supabase .from("countries") .select("id, name") .order("id") .range(0, 9) .execute response.data.length # => 10 ``` ## Example — next page [#example--next-page] For a "page 2 of 10-per-page" slice, use `range(10, 19)`. ```ruby supabase .from("countries") .select("id, name") .order("id") .range(10, 19) .execute ``` ## Example — range on an embedded resource [#example--range-on-an-embedded-resource] ```ruby supabase .from("authors") .select("name, books(title)") .range(0, 4, foreign_table: "books") .execute ``` # Mutually exclusive to a range (/reference/ruby/database/rangeadjacent) Filter rows where `column -|- [start, finish)` — the column's range shares an endpoint with the supplied range but doesn't overlap. PostgREST operator: `adj`. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — bookings adjacent to a window [#example--bookings-adjacent-to-a-window] ```ruby supabase .from("bookings") .select("*") .range_adjacent("during", ["2026-06-01", "2026-07-01"]) .execute ``` # Greater than a range (/reference/ruby/database/rangegt) Filter rows where `column >> [start, finish)` — the column's range begins strictly after the supplied range ends. PostgREST operator: `sr` (strictly right). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — bookings starting after Q2 [#example--bookings-starting-after-q2] ```ruby supabase .from("bookings") .select("*") .range_gt("during", ["2026-04-01", "2026-07-01"]) .execute ``` # Greater than or equal to a range (/reference/ruby/database/rangegte) Filter rows where `column &> [start, finish)` — the column's range does not start before the supplied range's lower bound. PostgREST operator: `nxl` (not extends left). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — bookings that start on or after the window's start [#example--bookings-that-start-on-or-after-the-windows-start] ```ruby supabase .from("bookings") .select("*") .range_gte("during", ["2026-06-01", "2026-07-01"]) .execute ``` # Less than a range (/reference/ruby/database/rangelt) Filter rows where `column << [start, finish)` — the column's range ends strictly before the supplied range begins. PostgREST operator: `sl` (strictly left). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — bookings ending before July [#example--bookings-ending-before-july] ```ruby supabase .from("bookings") .select("*") .range_lt("during", ["2026-07-01", "2026-08-01"]) .execute ``` # Less than or equal to a range (/reference/ruby/database/rangelte) Filter rows where `column &< [start, finish)` — the column's range does not end after the supplied range's upper bound. PostgREST operator: `nxr` (not extends right). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The same builder for chaining. ## Example — bookings that finish on or before the window's end [#example--bookings-that-finish-on-or-before-the-windows-end] ```ruby supabase .from("bookings") .select("*") .range_lte("during", ["2026-06-01", "2026-07-01"]) .execute ``` # Call a Postgres function (/reference/ruby/database/rpc) Call a Postgres function over PostgREST. By default the function is invoked with `POST` and the body carries the arguments; `get: true` switches to a read-only `GET` (cacheable) and `head: true` issues a `HEAD` (count only, no body). The returned builder supports the same [filters](/reference/ruby/database#filters) and [modifiers](/reference/ruby/database#modifiers) as `select` — so set-returning functions can be filtered, ordered, and paged without a wrapping view. ## Signature [#signature] `params` defaults to `{}`. For `POST` calls it is serialized as the JSON request body; for `GET` / `HEAD` it is stringified and sent as query parameters (PostgREST then reads the function arguments from the URL). ## Parameters [#parameters] ## Returns [#returns] A chainable builder that mixes in `FilterMixin` and `SelectMixin`, so set-returning functions support `.eq`, `.gt`, `.order`, `.limit`, `.range`, `.single`, `.maybe_single`, `.csv`, etc. before `.execute`. Calling `.execute` returns a `SingleAPIResponse` (PostgREST treats the call as scalar — `data` is whatever the function returned, parsed from JSON). ## Example — scalar return [#example--scalar-return] A function returning a single value (`RETURNS integer`, `RETURNS text`, `RETURNS jsonb`, …) comes back as `response.data` directly. No filters, no chaining — just `.execute`. ```ruby # CREATE FUNCTION add(a int, b int) RETURNS int AS $$ # SELECT a + b; # $$ LANGUAGE sql IMMUTABLE; response = supabase.rpc("add", { a: 2, b: 3 }).execute response.data # => 5 ``` ```ruby # CREATE FUNCTION current_tenant() RETURNS text AS $$ # SELECT current_setting('app.tenant_id', true); # $$ LANGUAGE sql STABLE; response = supabase.rpc("current_tenant").execute response.data # => "acme" ``` ## Example — set-returning function [#example--set-returning-function] A function declared `RETURNS SETOF
` or `RETURNS TABLE(...)` comes back as `response.data` of `Array`. The shape mirrors what `select` returns for a table. ```ruby # CREATE FUNCTION countries_in_continent(target text) # RETURNS SETOF countries AS $$ # SELECT * FROM countries WHERE continent = target; # $$ LANGUAGE sql STABLE; response = supabase.rpc("countries_in_continent", { target: "Africa" }).execute response.data # => [ # { "id" => 1, "name" => "Algeria", "continent" => "Africa" }, # { "id" => 54, "name" => "Zimbabwe", "continent" => "Africa" } # ] ``` ## Example — chained filters on a set-returning result [#example--chained-filters-on-a-set-returning-result] PostgREST applies filters, ordering, and pagination on top of the function's output. Chain them just like you would on a `select` — this is the killer feature of `rpc` for set-returning functions: you don't have to push the predicates into the function body. ```ruby response = supabase .rpc("countries_in_continent", { target: "Africa" }) .gt("population", 50_000_000) .order("population", desc: true) .limit(5) .execute response.data # => [ # { "id" => 23, "name" => "Nigeria", "population" => 220_000_000, ... }, # { "id" => 14, "name" => "Ethiopia", "population" => 120_000_000, ... }, # ... # ] ``` `.single` and `.maybe_single` also work, so a set-returning function with a unique filter can be coerced to a single object: ```ruby response = supabase .rpc("search_users", { q: "alice" }) .eq("email", "alice@example.com") .single .execute response.data # => { "id" => 7, "email" => "alice@example.com", ... } ``` ## Example — count only with `head: true` [#example--count-only-with-head-true] `head: true` issues a `HEAD` request — no body comes back, but PostgREST still computes the row count and returns it in the `Content-Range` header. Pair it with `count:` to read a total without paying for the rows. ```ruby response = supabase.rpc( "countries_in_continent", { target: "Africa" }, count: "exact", head: true ).execute response.data # => nil (HEAD has no body) response.count # => 54 ``` ## Example — `get: true` for cacheable read-only functions [#example--get-true-for-cacheable-read-only-functions] For functions marked `STABLE` or `IMMUTABLE`, `get: true` switches the call to `GET` so the arguments travel in the URL and an HTTP cache (CDN, browser) can store the response. The function arguments must be JSON-stringifiable as query parameters. ```ruby response = supabase.rpc( "popular_tags", { limit: 10 }, get: true ).execute response.data # => [{ "tag" => "ruby", "uses" => 1234 }, ...] ``` The builder is `RPCFilterRequestBuilder` — a `SingleRequestBuilder` subclass that mixes in `FilterMixin` and `SelectMixin`. Wire format: `POST /rpc/` by default, `GET` / `HEAD` opt-in. # Fetch data (/reference/ruby/database/select) Build a `SELECT`. Pass any number of column expressions (default `*`) and chain [filters](/reference/ruby/database#filters) / [modifiers](/reference/ruby/database#modifiers) before calling `.execute`. The builder also exposes result-shape switchers ([`single`](/reference/ruby/database/single), [`maybe_single`](/reference/ruby/database/maybesingle), [`csv`](/reference/ruby/database/csv), [`explain`](/reference/ruby/database/explain)). ## Signature [#signature] `columns` is a splat — pass each column expression as a separate string, or pass none to default to `"*"`. Embedded resources (`"posts(*)"`, `"author:users!author_id(name)"`) are supported by PostgREST and pass through unchanged. ## Parameters [#parameters] ## Returns [#returns] A chainable builder. Call `.execute` to fire the request and receive an `APIResponse` (`data:` and `count:`). Use `.single`, `.maybe_single`, `.csv`, or `.explain` to change the response shape before executing. ## Example — fetch every column [#example--fetch-every-column] ```ruby response = supabase.from("countries").select("*").execute response.data # => [{ "id" => 1, "name" => "Algeria", "continent" => "Africa" }, ...] ``` ## Example — pick specific columns + filters + ordering [#example--pick-specific-columns--filters--ordering] ```ruby response = supabase .from("countries") .select("id, name") .eq("continent", "Africa") .order("name") .limit(20) .execute response.data # => [{ "id" => 1, "name" => "Algeria" }, ...] ``` ## Example — count rows alongside the data [#example--count-rows-alongside-the-data] ```ruby response = supabase .from("countries") .select("*", count: "exact") .eq("continent", "Africa") .execute response.data # => [...rows...] response.count # => 54 ``` ## Example — count only, no rows (HEAD) [#example--count-only-no-rows-head] ```ruby response = supabase .from("countries") .select("*", count: "exact", head: true) .eq("continent", "Africa") .execute response.data # => [] response.count # => 54 ``` ## Example — embedded resources [#example--embedded-resources] PostgREST resource embedding works as a column expression. The shape mirrors what PostgREST returns, so authors picking columns inside the embedded resource use the standard `relation(col1, col2)` syntax. ```ruby response = supabase .from("posts") .select("id, title, author:users!author_id(id, full_name)") .order("id", desc: true) .limit(10) .execute ``` ## Example — fetch exactly one row [#example--fetch-exactly-one-row] `.single` flips the `Accept` header so PostgREST returns the row as an object (not an array of length 1) and raises `Supabase::Postgrest::Errors::APIError` if the result is 0 or more than 1 row. ```ruby response = supabase .from("countries") .select("*") .eq("id", 1) .single .execute response.data # => { "id" => 1, "name" => "Algeria", ... } ``` # Retrieve one row of data (/reference/ruby/database/single) Flip the response shape to a single row. Lives on the `select` builder. Sets `Accept: application/vnd.pgrst.object+json` so PostgREST itself enforces the contract — the wire request returns the row as an object (not an array of length 1), and the server returns `406 Not Acceptable` when the result set is empty or has more than one row. See [`maybe_single`](/reference/ruby/database/maybesingle) for the "0-or-1" variant that returns `nil` on no rows. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] A builder that, when `.execute`'d, returns a `SingleAPIResponse` whose `data` is the single row as a `Hash` (not wrapped in an array). Raises `Supabase::Postgrest::Errors::APIError` (code `PGRST116`) when the result set is not exactly one row. ## Example — fetch one row by id [#example--fetch-one-row-by-id] ```ruby response = supabase .from("countries") .select("*") .eq("id", 1) .single .execute response.data # => { "id" => 1, "name" => "Algeria", ... } ``` Compare without `.single` — the same query would return a one-element array: ```ruby response = supabase.from("countries").select("*").eq("id", 1).execute response.data # => [{ "id" => 1, "name" => "Algeria", ... }] ``` ## Example — raises when the row doesn't exist [#example--raises-when-the-row-doesnt-exist] PostgREST refuses to coerce 0 rows to an object, so `.single` over a non-matching filter raises: ```ruby begin supabase.from("countries").select("*").eq("id", -1).single.execute rescue Supabase::Postgrest::Errors::APIError => e e.code # => "PGRST116" e.message # => "Cannot coerce the result to a single JSON object" end ``` Use [`maybe_single`](/reference/ruby/database/maybesingle) when "no row" is a valid case and you want `nil` instead of an exception. ## `single` vs `maybe_single` [#single-vs-maybe_single] | | `single` | `maybe_single` | | ------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------- | | Sets `Accept: application/vnd.pgrst.object+json` | yes | no (SELECT chain) | | 0 rows | raises `APIError` (`PGRST116`) — server-side | returns `nil` — client-side check | | 1 row | `data` is the row as a `Hash` | `data` is the row as a `Hash` | | more than 1 row | raises `APIError` (`PGRST116`) — server-side | raises `APIError` ("Cannot coerce…") — client-side check | | Use when | the row must exist (PK lookup) | the row may or may not exist (optional lookup) | Both raise on `>1`, but `single` enforces the contract on the wire (PostgREST returns 406 with no body), while `maybe_single` issues a normal array request and inspects the result client-side. Pick `maybe_single` whenever 0 is a valid outcome. # Match by text search (/reference/ruby/database/textsearch) Run a Postgres full-text search against a tsvector column. `text_search` is a convenience over the raw [`fts`](/reference/ruby/database/fts) / [`plfts`](/reference/ruby/database/plfts) / [`phfts`](/reference/ruby/database/phfts) / [`wfts`](/reference/ruby/database/wfts) operators — pass `type:` to pick the variant and `config:` to pick a non-default text-search config (e.g. `"english"`). `text_search` lives on the `select` builder ([`SelectRequestBuilder`](https://github.com/supabase/supabase-rb/blob/main/lib/supabase/postgrest/request_builder.rb)), not on the universal [`FilterMixin`](https://github.com/supabase/supabase-rb/blob/main/lib/supabase/postgrest/request_builder.rb). Call it after [`select`](/reference/ruby/database/select), before `.execute`. ## Signature [#signature] ## Parameters [#parameters] ### options keys [#options-keys] ## Returns [#returns] A builder ready to `.execute`. Note: the return type is the base `QueryRequestBuilder`, not the filtered/modifier-mixed `SelectRequestBuilder` — chain `text_search` *after* you've added your other filters and modifiers, then `.execute`. ## Example — default tsquery syntax [#example--default-tsquery-syntax] ```ruby supabase .from("posts") .select("id, title") .text_search("body", "ruby & postgrest") .execute ``` ## Example — plain phrase (`plfts`) [#example--plain-phrase-plfts] ```ruby supabase .from("posts") .select("id, title") .text_search("body", "ruby on rails", type: "plain") .execute ``` ## Example — exact phrase (`phfts`) [#example--exact-phrase-phfts] ```ruby supabase .from("articles") .select("*") .text_search("title", "supabase ruby docs", type: "phrase") .execute ``` ## Example — Google-style query (`wfts`) [#example--google-style-query-wfts] ```ruby supabase .from("articles") .select("*") .text_search("body", "supabase ruby -python", type: "web_search") .execute ``` ## Example — non-default config [#example--non-default-config] ```ruby supabase .from("articles") .select("*") .text_search("body", "voyageurs", type: "phrase", config: "french") .execute ``` The lower-level [`fts`](/reference/ruby/database/fts), [`plfts`](/reference/ruby/database/plfts), [`phfts`](/reference/ruby/database/phfts), [`wfts`](/reference/ruby/database/wfts) filter mixins are also available on every verb builder if you want to call the raw operator directly. # Update data (/reference/ruby/database/update) Patch existing rows via `PATCH`. Pass a `Hash` of column-value updates, chain at least one [filter](/reference/ruby/database#filters) to scope which rows are touched, then call `.execute`. Updates with no filter are rejected by PostgREST unless you've used [`max_affected`](/reference/ruby/database/maxaffected) to opt in explicitly. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] A chainable builder that mixes in `FilterMixin` — chain filters to scope the update, then call `.execute` to fire the request and receive an `APIResponse`. ## Example — update a single row by primary key [#example--update-a-single-row-by-primary-key] ```ruby response = supabase .from("countries") .update(name: "Türkiye") .eq("id", 99) .execute response.data # => [{ "id" => 99, "name" => "Türkiye", ... }] ``` ## Example — bulk update with a compound filter [#example--bulk-update-with-a-compound-filter] ```ruby response = supabase .from("orders") .update(status: "shipped", shipped_at: Time.now.utc.iso8601) .eq("status", "ready_to_ship") .lte("created_at", "2026-06-01") .execute response.data.length # => however many rows matched ``` ## Example — fire-and-forget update [#example--fire-and-forget-update] `returning: "minimal"` returns no body, so `data` is `[]`. Use this when you don't need the updated rows back. ```ruby supabase .from("audit_log") .update(seen: true, returning: "minimal") .eq("user_id", 42) .execute ``` ## Example — count the affected rows [#example--count-the-affected-rows] ```ruby response = supabase .from("orders") .update({ archived: true }, count: "exact") .eq("status", "completed") .execute response.count # => N rows updated ``` ## Example — guard a bulk update with `max_affected` [#example--guard-a-bulk-update-with-max_affected] `max_affected(N)` adds `Prefer: handling=strict,max-affected=N`. PostgREST refuses the update (returning `400`) if the row count would exceed `N`. This is the safe way to issue an unfiltered-looking update. ```ruby supabase .from("invoices") .update(due_at: nil) .eq("status", "voided") .max_affected(50) .execute ``` Updates with no filters do NOT silently update every row — PostgREST refuses the request unless `max_affected` has explicitly opted in. # Upsert data (/reference/ruby/database/upsert) Insert rows that may already exist. Behind the scenes, `upsert` issues `POST` with `Prefer: resolution=merge-duplicates` (or `resolution=ignore-duplicates` if you opt in). On a primary-key or unique conflict, PostgREST overwrites the existing row instead of failing. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] A chainable builder. Call `.execute` to fire the request and receive an `APIResponse` (`data:` lists the affected rows when `returning: "representation"`). ## Example — upsert by primary key [#example--upsert-by-primary-key] When the conflict target is the table's primary key, no `on_conflict:` is needed. Matching rows are overwritten; new rows are inserted. ```ruby response = supabase .from("countries") .upsert([ { id: 1, name: "Algeria", population: 45_400_000 }, { id: 250, name: "Wakanda", population: 6_500_000 } ]) .execute response.data.length # => 2 (one update + one insert) ``` ## Example — upsert by a unique column [#example--upsert-by-a-unique-column] Set `on_conflict:` to the column with the unique constraint to dedupe by something other than the primary key. ```ruby response = supabase .from("users") .upsert( { email: "ada@example.com", full_name: "Ada Lovelace" }, on_conflict: "email" ) .execute ``` ## Example — ignore conflicts (insert-or-skip) [#example--ignore-conflicts-insert-or-skip] Set `ignore_duplicates: true` to flip the resolution to `ignore`. Conflicting rows are silently skipped; the response contains only the newly-inserted rows. ```ruby response = supabase .from("login_attempts") .upsert( [{ user_id: 1, login_date: "2026-06-12" }, { user_id: 2, login_date: "2026-06-12" }], on_conflict: "user_id,login_date", ignore_duplicates: true ) .execute ``` ## Example — composite-key conflict target [#example--composite-key-conflict-target] `on_conflict:` accepts a comma-separated list of columns for composite unique constraints. ```ruby supabase .from("user_roles") .upsert( { user_id: 42, role: "admin", granted_at: Time.now.utc.iso8601 }, on_conflict: "user_id,role" ) .execute ``` ## Example — count the affected rows [#example--count-the-affected-rows] ```ruby response = supabase .from("countries") .upsert( [{ id: 1, name: "Algeria" }, { id: 2, name: "Angola" }], count: "exact" ) .execute response.count # => 2 ``` `Prefer` is set to `resolution=merge-duplicates` by default, or `resolution=ignore-duplicates` when `ignore_duplicates: true`. The `on_conflict:` query parameter is required for non-PK conflict targets. # Overview (/reference/ruby/functions) The `supabase.functions` client invokes deployed [Edge Functions](https://supabase.com/docs/guides/functions). A single `invoke` method that always `POST`s, with kwargs for body, headers, region, and how to decode the response. The full surface fits on one page. ## First call [#first-call] ```ruby require "supabase" supabase = Supabase.create_client( ENV.fetch("SUPABASE_URL"), ENV.fetch("SUPABASE_ANON_KEY") ) result = supabase.functions.invoke( "hello-world", body: { name: "Ada" }, response_type: :json ) puts result # => { "message" => "Hello, Ada!" } ``` ## API [#api] | Method | What it does | | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | [`functions.invoke(name, body:, headers:, region:, response_type:, return_response:)`](/reference/ruby/functions/invoke) | Invoke a deployed Edge Function. POSTs `/`; returns the decoded response body. | ## Authentication [#authentication] `supabase.functions` reuses the bearer token of the umbrella client — sign in via `supabase.auth` and `invoke` automatically sends the right `Authorization` header. To override the bearer for a single call, pass it via `headers:`; to swap the bearer for every subsequent call on this functions client, use `supabase.functions.set_auth(token)`. ```ruby # Per-call override supabase.functions.invoke( "hello-world", headers: { "Authorization" => "Bearer #{custom_jwt}" }, body: { name: "Ada" } ) # Persistent swap supabase.functions.set_auth(service_role_jwt) ``` ## Errors [#errors] | Class | Raised when | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `Supabase::Functions::Errors::FunctionsHttpError` | The function returned a non-2xx response. Carries `:status` and the parsed message from the response body. | | `Supabase::Functions::Errors::FunctionsRelayError` | The Supabase relay (in front of the function) signaled failure via the `x-relay-error: true` response header. | | `Supabase::Functions::Errors::FunctionsError` | Common base class — rescue this to catch any Functions-API failure. | ## Async mode [#async-mode] If the umbrella client was constructed with `async: true`, `supabase.functions.invoke` runs on `async-http-faraday` — call sites should wrap calls in `Async do ... end` to actually get concurrency. The public method signature is unchanged. ```ruby require "supabase" require "async" supabase = Supabase.create_client( ENV.fetch("SUPABASE_URL"), ENV.fetch("SUPABASE_ANON_KEY"), async: true ) Async do |task| calls = %w[hello-world goodbye-world].map do |name| task.async { supabase.functions.invoke(name, body: { id: 1 }, response_type: :json) } end results = calls.map(&:wait) results.each { |r| puts r.inspect } end ``` See the [`invoke` page](/reference/ruby/functions/invoke) for the full signature, every parameter, the response-decoding rules, and a note on streaming. # Invoke a Supabase Edge Function (/reference/ruby/functions/invoke) Call a deployed Edge Function. `invoke` is always an HTTP `POST` against `/` — there is no `method:` kwarg (see the [design note](#design)). The request body, custom headers, region routing, and response decoding are all per-call kwargs. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] By default (`return_response: false`), returns the decoded body: a `Hash`/`Array`/scalar when `response_type: :json`, a UTF-8 `String` when `response_type: :text`, or an `ASCII-8BIT` `String` when `response_type: :binary`. A `nil` response body comes through as `nil` for every `response_type`. When `return_response: true`, returns a `Types::Response` Struct with `:data`, `:status`, and `:headers` — this wrapper is deprecated and slated for removal. Errors: * `Supabase::Functions::Errors::FunctionsHttpError` — raised on a non-2xx response from the function. Carries `status` and the message from the response body's `"error"` field (or a default message). * `Supabase::Functions::Errors::FunctionsRelayError` — raised when the Supabase relay layer signals failure via the `x-relay-error: true` response header. Distinguishable from `FunctionsHttpError` and rescued separately. ## Example — JSON body, JSON response [#example--json-body-json-response] ```ruby require "supabase" supabase = Supabase.create_client( ENV.fetch("SUPABASE_URL"), ENV.fetch("SUPABASE_ANON_KEY") ) result = supabase.functions.invoke( "hello-world", body: { name: "Ada" }, response_type: :json ) result # => { "message" => "Hello, Ada!" } ``` `body: { ... }` is JSON-encoded and sent with `Content-Type: application/json`. `response_type: :json` parses the response body — a parse failure raises `JSON::ParserError` (the caller asked for JSON, so a non-JSON body is a contract violation). ## Example — default text response [#example--default-text-response] ```ruby raw = supabase.functions.invoke("hello-world", body: { name: "Ada" }) raw # => "{\"message\":\"Hello, Ada!\"}" raw.encoding # => # ``` Without `response_type: :json`, the body comes back as a raw UTF-8 `String`. The Content-Type response header is ignored — parsing is always opt-in by the caller. ## Example — region routing [#example--region-routing] ```ruby region = Supabase::Functions::Types::FunctionRegion::US_EAST_1 supabase.functions.invoke( "ingest-event", body: { user_id: 42, event: "signup" }, region: region ) ``` Passing `region:` sets the `x-region` request header AND appends `forceFunctionRegion=` to the URL query string. Region strings are validated before any HTTP call — a typo like `"us-east-99"` raises `ArgumentError`. Pass `FunctionRegion::ANY` (or `"any"`) to explicitly request no region pinning. PascalCase aliases (`FunctionRegion::UsEast1`) also work. ## Example — custom headers (trace ID, idempotency key) [#example--custom-headers-trace-id-idempotency-key] ```ruby supabase.functions.invoke( "charge-customer", body: { customer_id: 42, amount_cents: 1999 }, headers: { "X-Trace-Id" => SecureRandom.uuid, "Idempotency-Key" => "charge-2026-06-12-customer-42-001" }, response_type: :json ) ``` Per-invocation headers are merged over the client-level defaults (including `Authorization`). If you set `Content-Type` here it is respected — `invoke` only fills in `Content-Type` when the caller hasn't set one (`||=`). ## Example — String body (already-serialized payload) [#example--string-body-already-serialized-payload] ```ruby serialized = JSON.generate({ user_id: 42 }) supabase.functions.invoke( "ingest-prebaked", body: serialized, headers: { "Content-Type" => "application/json" }, response_type: :json ) ``` A `String` body is sent as-is (no JSON-encoding). The default `Content-Type` is `text/plain` — override it via `headers:` if the wire format is something else. ## Example — binary response (PDF, image, etc.) [#example--binary-response-pdf-image-etc] ```ruby bytes = supabase.functions.invoke( "render-invoice", body: { invoice_id: 4242 }, response_type: :binary ) bytes.encoding # => # File.binwrite("invoice-4242.pdf", bytes) ``` `response_type: :binary` returns the body byte-for-byte with `ASCII-8BIT` (`BINARY`) encoding — no UTF-8 munging. The encoding choice is explicit at the call site. ## Example — rescuing errors [#example--rescuing-errors] ```ruby begin supabase.functions.invoke("hello-world", body: { name: "Ada" }, response_type: :json) rescue Supabase::Functions::Errors::FunctionsRelayError => e # The Supabase relay (in front of the function) errored. The function # itself may never have executed. warn "relay error: #{e.message} (status #{e.status})" rescue Supabase::Functions::Errors::FunctionsHttpError => e # Non-2xx from the function. e.message is parsed from the response # body's "error" field when present. warn "function returned #{e.status}: #{e.message}" end ``` Both error classes inherit from `Supabase::Functions::Errors::FunctionsError` — rescue that to catch any Functions-API failure. ## Streaming responses [#streaming-responses] **Streaming responses are not supported.** `invoke` reads the full response body into memory before returning — there is no callback, block, or `IO`-yielding form analogous to supabase-js's `Response.body` `ReadableStream`. The underlying Faraday session does not expose a streaming reader, and `:text` / `:binary` / `:json` all consume `response.body` whole. If your function returns a large payload, prefer: 1. **`response_type: :binary`** so the bytes are not re-encoded as UTF-8 (cheap copy, no transcoding cost). 2. **A higher per-request timeout** via the client-level `timeout:` kwarg — the default is 60 seconds. 3. **A direct Faraday connection** injected via `http_client:` if you really need streaming semantics — you can configure the adapter to handle large responses outside `invoke`. See the [Edge Functions README](https://github.com/supabase-rb/client/blob/main/lib/supabase/functions/README.md) for the injection pattern. If full streaming support lands, this section will be updated. `invoke` is always a `POST`, and the only query-string consumer is region routing (handled internally via `forceFunctionRegion`). Other design choices worth knowing: `region` is validated before the HTTP call (raises `ArgumentError` on typos rather than warning-and-coercing); a caller-provided `Content-Type` is respected via `||=` instead of being overwritten; `x-relay-error` is read case-insensitively per RFC 7230; and `response_type: :json` raises on invalid JSON (a contract violation) rather than silently falling back to the raw String. # Initializing (/reference/ruby/initializing) `supabase-rb` is a single gem that bundles Auth, PostgREST (Database), Storage, Edge Functions, and Realtime behind one umbrella client. `Supabase.create_client` is the entry point — pass your project URL and an API key, get back a `Supabase::Client`. For server-rendered Rails apps, install [`supabase-rails`](/reference/rails) instead. It bundles `supabase-rb` plus a Rack middleware that builds a per-request `Supabase::Client` for you, an encrypted-cookie session model, and generators that scaffold sign-in / sign-up / OAuth / OTP / password-reset controllers and views. Start with the [Rails getting-started guide](/reference/rails/getting-started); the per-request client is exposed in controllers as `supabase_context.supabase`. ## Install [#install] Add the gem to your `Gemfile`: ```ruby # Gemfile gem "supabase-rb" ``` Then run: ```bash bundle install ``` Or install it directly without bundler: ```bash gem install supabase-rb ``` Requires Ruby ≥ 3.1. ## First query [#first-query] A complete end-to-end example — install the gem, construct a client with your project URL + anon key, and read a row: ```ruby require "supabase" supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) response = supabase.from("countries").select("*").limit(1).execute puts response.data # => [{"id" => 1, "name" => "United Kingdom"}] ``` `SUPABASE_URL` is your project URL (e.g. `https://abcd1234.supabase.co`) and `SUPABASE_ANON_KEY` is the anon (`anon`) key from the project's API settings. ## Construct a client [#construct-a-client] Equivalent to `Supabase::Client.new(...)` but routes through `Client.create`, which additionally restores a persisted session (if `options.persist_session` is enabled and the auth storage holds one) and applies the session's access token as the bearer. An umbrella client exposing `auth`, `postgrest` (via `from`/`table`/`rpc`/`schema`), `storage`, `functions`, and `realtime` (via `channel`). ```ruby supabase = Supabase.create_client( supabase_url: "https://abcd1234.supabase.co", supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) ``` You can also call `Supabase::Client.new(...)` directly if you want to skip the persisted-session restore: ```ruby supabase = Supabase::Client.new( supabase_url: "https://abcd1234.supabase.co", supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) ``` ## ClientOptions [#clientoptions] `Supabase::ClientOptions` is the typed configuration struct passed via `options:`. A single class covers both sync and async modes because the sync/async split is decided at runtime via the umbrella `async:` flag (see [Async mode](#async-mode)). ```ruby options = Supabase::ClientOptions.new( schema: "public", headers: { "X-Tenant" => "acme" }, auto_refresh_token: true, persist_session: true, postgrest_client_timeout: 30, storage_client_timeout: 20, function_client_timeout: 10, flow_type: "pkce" ) supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), options: options ) ``` A flat hash of the same keys works equivalently — it is canonicalized into a `ClientOptions` instance internally: ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), options: { schema: "public", headers: { "X-Tenant" => "acme" } } ) ``` ### Global headers [#global-headers] Headers passed via `options.headers` are merged into every HTTP sub-client. The default `X-Client-Info: supabase-rb/` header is always included so your project logs can attribute requests to the Ruby gem. ```ruby options = Supabase::ClientOptions.new( headers: { "X-Tenant" => "acme", "X-Request-Id" => SecureRandom.uuid } ) ``` ### Database schema [#database-schema] `schema:` sets the default Postgres schema for all PostgREST requests. To query a non-default schema for a single chain without changing this default, use `client.schema("other")`: ```ruby supabase.schema("private").from("audit_log").select("*").execute ``` ## Async mode [#async-mode] Pass `async: true` to swap every HTTP sub-client (auth, postgrest, storage, functions) to its `async-http-faraday` variant. Calls return values that integrate with the `async` gem's reactor. ```ruby require "async" require "supabase" Async do supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), async: true ) response = supabase.from("countries").select("*").execute puts response.data end ``` Async mode is opt-in at construction time via `async: true` — there is no separate `AsyncClient` class. Request builders are transport-agnostic, so the same call sites work in both modes. Two things to know: * Under `async: true`, `client.remove_channel` / `client.remove_all_channels` (and the `set_auth` fan-out to a connected realtime socket) return an `Async::Task` instead of blocking the calling fiber — call `.wait` on it to await the result. * The realtime client itself stays thread-based regardless of `async:` — only the HTTP sub-clients switch transport. The umbrella's `dispatch_realtime` helper bridges the two worlds for you. ## Legacy nested-hash options [#legacy-nested-hash-options] The original Ruby options shape — a nested hash keyed by sub-client name — is still accepted alongside `ClientOptions`. Existing callers don't need to migrate: ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), options: { auth: { auto_refresh_token: true, persist_session: true, flow_type: "pkce" }, postgrest: { schema: "public", timeout: 30 }, storage: { timeout: 20 }, functions: { timeout: 10 }, realtime: { logger: Logger.new($stdout) }, global: { headers: { "X-Tenant" => "acme" } } } ) ``` The shape is detected automatically — presence of any of `:auth`, `:postgrest`, `:functions`, `:global`, or a `Hash`-valued `:storage` switches the client into legacy-routing mode. The legacy nested-hash form is preserved for backwards compatibility with `supabase-rb ≤ 3.x`, when there was no equivalent of `ClientOptions`. New code should prefer `ClientOptions` (or a flat hash of its fields) — the legacy shape silently drops any `ClientOptions`-style keys mixed in, and the client emits a warning when it detects such stray keys. # Create a channel (/reference/ruby/realtime/channel) Open a channel on a topic. The call is cheap — it creates a `Supabase::Realtime::Channel` instance, registers it in `client.channels`, and returns it. No socket I/O happens until you attach listeners and call [`subscribe`](/reference/ruby/realtime/subscribe). ## Signature [#signature] The same method lives on the realtime sub-client directly (`supabase.realtime.channel(topic, params: nil)`) — the top-level shortcut just forwards. ## Parameters [#parameters] ## Returns [#returns] A channel scoped to `topic`. Chain `on_postgres_changes` / `on_broadcast` / `on_presence_*`, then call `subscribe` to start the join handshake. The channel is added to `client.get_channels` immediately — the registry tracks intent, not connection state. ## Example — open a channel and attach a Postgres-changes listener [#example--open-a-channel-and-attach-a-postgres-changes-listener] ```ruby channel = supabase .channel("public:countries") .on_postgres_changes("*", schema: "public", table: "countries") do |payload| puts payload["data"]["type"], payload["data"]["record"] end .subscribe ``` ## Example — auto-prefixing [#example--auto-prefixing] Topic names get a `realtime:` prefix automatically. The two calls below open the same topic — pre-prefixed names are passed through unchanged. ```ruby supabase.channel("public:users") # → "realtime:public:users" supabase.channel("realtime:public:users") # → "realtime:public:users" ``` ## Example — broadcast acks and presence key [#example--broadcast-acks-and-presence-key] Override `params` to ask the server to ack broadcasts and to enable Presence with a stable key (the key groups all metas from this client under the same map entry). ```ruby channel = supabase.channel("room:42", params: { "config" => { "broadcast" => { "ack" => true, "self" => false }, "presence" => { "key" => "user-#{current_user.id}", "enabled" => true }, "private" => false } }) ``` ## Example — private channels (RLS-gated) [#example--private-channels-rls-gated] Pass `config.private: true` so the server enforces the topic's `realtime.messages` policies against the JWT carried in the join payload. The current `access_token` (from `set_auth`, or the anon key on initial create) is automatically placed at the root of the join payload — you don't need to add it to `params`. ```ruby private_channel = supabase.channel("room:tenant-42", params: { "config" => { "broadcast" => { "ack" => false, "self" => false }, "presence" => { "key" => "", "enabled" => false }, "private" => true } }) ``` ## Multiple channels on the same topic [#multiple-channels-on-the-same-topic] The registry is a flat list — `client.channel(topic)` always returns a **new** instance, even when a channel for the same topic already exists. Each gets its own join\_ref / subscription lifecycle. To look up an existing channel, walk `supabase.get_channels.find { |c| c.topic == "realtime:public:users" }`. The entire channel surface is synchronous — `channel` returns a fully usable object, and every method you call on it blocks the calling thread for the duration of the socket write. Heartbeat / read-loop / reconnect run on background `Thread`s; if you want non-blocking realtime calls from the top-level client, see [`remove_channel`](/reference/ruby/realtime/removechannel) for the `async: true` `Async::Task` shape. # Overview (/reference/ruby/realtime) The Realtime surface lets a Ruby process listen for Postgres changes, send broadcasts between connected clients, and track presence on a topic. Channels live on a single shared WebSocket; you open them with `supabase.channel`, attach listeners with `channel.on_*`, then call `channel.subscribe` to start the join handshake. Teardown is `channel.unsubscribe` (per-channel) or `supabase.remove_channel` / `supabase.remove_all_channels` (registry-level). ```ruby require "supabase" supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY") ) channel = supabase .channel("public:countries") .on_postgres_changes("INSERT", schema: "public", table: "countries") do |payload| puts "new country: #{payload['data']['record']}" end .subscribe do |state, error| puts "subscribe state: #{state}" end # ...later... supabase.remove_channel(channel) # or: channel.unsubscribe ``` ## Methods on the top-level client [#methods-on-the-top-level-client] | Method | Description | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`channel`](/reference/ruby/realtime/channel) | Open (or re-open) a topic. Returns a `Supabase::Realtime::Channel`. | | [`remove_channel`](/reference/ruby/realtime/removechannel) | Unsubscribe one channel and drop it from the registry. Closes the socket when the registry empties. | | [`remove_all_channels`](/reference/ruby/realtime/removeallchannels) | Unsubscribe every tracked channel and close the socket. | | `get_channels` | Snapshot (`Array`) of every channel currently tracked. Returns a `dup` so iteration is safe. | ## Methods on a channel [#methods-on-a-channel] | Method | Description | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | [`on_postgres_changes`](/reference/ruby/realtime/on) | Listen to `INSERT`/`UPDATE`/`DELETE` (or `"*"`) for a schema/table/filter. | | [`on_broadcast`](/reference/ruby/realtime/on) | Listen to a named broadcast event sent by another client on this topic. | | [`on_presence_sync`](/reference/ruby/realtime/on) / `on_presence_join` / `on_presence_leave` | Mirror Phoenix Presence state changes into your code. | | [`subscribe`](/reference/ruby/realtime/subscribe) | Start the join handshake. Optional block fires with `SUBSCRIBED` / `CHANNEL_ERROR` / `TIMED_OUT`. | | [`unsubscribe`](/reference/ruby/realtime/unsubscribe) | Send a `phx_leave`. State stays in `LEAVING` until the server acks. | | `send_broadcast(event, payload)` | Emit a broadcast frame to every other subscriber of the topic. | | `track(payload)` / `untrack` | Mutate this client's presence entry. See the [`on`](/reference/ruby/realtime/on) page for end-to-end examples. | | `push_event(event, payload, timeout:)` | Public low-level Phoenix push for custom events. Returns the `Push` so you can attach `receive(...)` blocks. | | `presence_state` | Snapshot of the current `{ key => [presences] }` map. | | `on_close(&block)` / `on_error(&block)` | Channel-level lifecycle hooks (Ruby-only). | Heartbeat, read-loop, and reconnect run on background `Thread`s, and thread-safety is provided by explicit mutexes (Presence, Push, Timer, send-buffer). Listener blocks (`on_postgres_changes`, `on_broadcast`, presence callbacks, `subscribe`'s status block) run **on the read-thread** — a slow callback delays delivery of the next frame. Exceptions raised inside a callback are caught by `Supabase::Realtime::CallbackSafety` and logged so the read-loop survives. ## Sockets, reconnect, and `on_reconnect_failed` [#sockets-reconnect-and-on_reconnect_failed] `Supabase::Realtime::Client` owns one `Socket`. The production transport is `Sockets::WebsocketClientSimple` (constructed lazily when no `transport:` is injected). When the socket drops, a background thread reconnects with exponential backoff — `initial_backoff * 2^(attempts - 1)` seconds, capped at 60s, up to `max_retries` attempts. When all attempts are exhausted, the registered `on_reconnect_failed` callback fires with the last underlying error. ```ruby supabase.realtime.on_reconnect_failed do |error| Rails.logger.error("realtime gave up after retries: #{error&.message}") end ``` This callback fires once the client has given up after exhausting all retries. ## Subscribe states [#subscribe-states] The block passed to [`subscribe`](/reference/ruby/realtime/subscribe) is invoked with one of: | State | Meaning | | ----------------- | ------------------------------------------------------------------------------------------------------------------- | | `"SUBSCRIBED"` | The server acked the join. Listeners are now live. | | `"CHANNEL_ERROR"` | The server replied with an error (e.g. RLS rejected the join, or `on_postgres_changes` bindings didn't round-trip). | | `"TIMED_OUT"` | No reply within `Types::DEFAULT_TIMEOUT_SECONDS` (10s). | | `"CLOSED"` | Server-side phx\_close after a successful join. | These come from `Supabase::Realtime::Types::SubscribeStates`. # Listen to events on a channel (/reference/ruby/realtime/on) Listener registration on a `Supabase::Realtime::Channel` is split into three families, one per event class: * `on_postgres_changes(event, schema:, table:, filter:)` — database row events from the Realtime replication slot. * `on_broadcast(event)` — custom messages sent from another client via `channel.send_broadcast(...)`. * `on_presence_sync` / `on_presence_join` / `on_presence_leave` — Phoenix Presence state changes. Listeners must be attached **before** [`subscribe`](/reference/ruby/realtime/subscribe) — they're serialized into the join payload so the server knows which postgres\_changes bindings to honor and whether to enable Presence. Listeners attached after a channel is already JOINED trigger a resubscribe so the server config catches up. ## postgres\_changes [#postgres_changes] ### Signature [#signature] ### Parameters [#parameters] ### Example — listen for all changes on a table [#example--listen-for-all-changes-on-a-table] ```ruby supabase .channel("public:countries") .on_postgres_changes("*", schema: "public", table: "countries") do |payload| case payload["data"]["type"] when "INSERT" then puts "added: #{payload['data']['record']}" when "UPDATE" then puts "updated: #{payload['data']['record']}" when "DELETE" then puts "deleted: #{payload['data']['old_record']}" end end .subscribe ``` ### Example — server-side row filter [#example--server-side-row-filter] ```ruby supabase .channel("orders:pending") .on_postgres_changes( "UPDATE", schema: "public", table: "orders", filter: "status=eq.pending" ) do |payload| notify_dispatcher(payload["data"]["record"]) end .subscribe ``` ## broadcast [#broadcast] ### Signature [#signature-1] ### Parameters [#parameters-1] ### Example — broadcast send + receive [#example--broadcast-send--receive] The `send_broadcast(event, payload)` call on the same channel object emits a broadcast frame to every other subscriber of the topic. By default the server does **not** echo a broadcast back to its sender (`config.broadcast.self = false`); flip it via `params` (see [`channel`](/reference/ruby/realtime/channel)) if you want a confirmation loop. ```ruby chat = supabase.channel("room:42") chat.on_broadcast("chat:message") do |payload| msg = payload["payload"] puts "<#{msg['user']}> #{msg['text']}" end chat.subscribe do |state, _| next unless state == "SUBSCRIBED" chat.send_broadcast("chat:message", { user: "ada", text: "shipping in five" }) end ``` ## presence [#presence] Presence listeners get attached via `on_presence_sync` / `on_presence_join` / `on_presence_leave`. The first attachment automatically flips `config.presence.enabled = true` in the join payload so the server starts streaming presence frames. ### Signatures [#signatures] Presence payload keys are normalized — Phoenix's wire-level `metas` array and `phx_ref` field are rewritten to a flat `{ key => [{ "presence_ref" => ..., ... }] }` shape before reaching your block. ### Example — track / untrack the local user [#example--track--untrack-the-local-user] `channel.track(payload)` stores `payload` under this client's presence key (set via `params.config.presence.key` — see [`channel`](/reference/ruby/realtime/channel)). `channel.untrack` removes it. Track from inside the `subscribe` block — calling it before the join handshake completes is buffered, but reading `presence_state` won't return anything until the first server `presence_state` frame arrives. ```ruby room = supabase.channel("room:42", params: { "config" => { "broadcast" => { "ack" => false, "self" => false }, "presence" => { "key" => "user-#{current_user.id}", "enabled" => true }, "private" => false } }) room.on_presence_sync do puts "snapshot: #{room.presence_state}" end room.on_presence_join do |key, _current, new_presences| puts "join: #{key} = #{new_presences.first}" end room.on_presence_leave do |key, _remaining, _left| puts "leave: #{key}" end room.subscribe do |state, _| next unless state == "SUBSCRIBED" room.track({ online_at: Time.now.utc.iso8601, typing: false }) end # Later, when the user closes the page: room.untrack ``` `presence_state` returns a snapshot Hash — safe to iterate while the read-thread continues applying inbound diffs (US-007 thread safety). ## Listener ordering and the join payload [#listener-ordering-and-the-join-payload] When the channel joins, `inject_postgres_changes_bindings` serializes every registered `on_postgres_changes` listener into `config.postgres_changes` on the join payload — the server filters before forwarding, instead of shipping every row event for the topic. The server echoes back its registered bindings in the phx\_reply; if the echo doesn't match the order/shape you registered, the channel raises `CHANNEL_ERROR` and unsubscribes (otherwise the listener would silently miss events). Adding a postgres\_changes listener after `subscribe` is **not** supported — re-create the channel. By contrast, broadcast and presence listeners are demuxed client-side, so they can be added at any time. Adding a presence listener after subscribe triggers an automatic resubscribe so the server starts emitting presence\_state / diff frames. All three listener families dispatch on the realtime read-thread — the same thread that parses inbound WebSocket frames. A slow callback blocks delivery of every subsequent frame on the channel. Push events to a `Queue` (or `Concurrent::Async`-style worker) if your handler does anything I/O-bound. Exceptions raised inside a callback are caught by `Supabase::Realtime::CallbackSafety` and forwarded to the client's `logger:` (falling back to `Kernel#warn`), so the read-loop survives — but your handler still saw a failed delivery. # Remove all channels (/reference/ruby/realtime/removeallchannels) `supabase.remove_all_channels` walks the registry, calls `channel.unsubscribe` on each, clears the registry, and disconnects the socket. Idempotent — a follow-up call on an empty registry is a no-op. Use this at process shutdown, in test teardown, or any time you want the realtime client to release its socket and background threads (heartbeat, reconnect, read-loop). ## Signature [#signature] The same method lives on the realtime sub-client (`supabase.realtime.remove_all_channels`) — the top-level shortcut forwards through `dispatch_realtime`. ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] In sync mode the call blocks until every channel's `phx_leave` is queued and the socket is closed, then returns the realtime client itself. Under `async: true` the realtime teardown runs in a child `Async` task and the call returns the `Async::Task` so the calling fiber doesn't stall on N blocking `Socket#send` writes. Call `.wait` on the task to await completion. ## Example — shut everything down [#example--shut-everything-down] ```ruby supabase.channel("public:countries").on_postgres_changes("*", schema: "public", table: "countries") { |p| handle(p) }.subscribe supabase.channel("public:orders").on_postgres_changes("INSERT", schema: "public", table: "orders") { |p| dispatch(p) }.subscribe supabase.channel("room:42").on_broadcast("chat:message") { |p| log(p) }.subscribe # ...later... supabase.remove_all_channels # All three go through LEAVING → CLOSED, socket disconnects, background threads stop. ``` ## Example — clean teardown in a Rails initializer / on\_exit [#example--clean-teardown-in-a-rails-initializer--on_exit] ```ruby at_exit do supabase.remove_all_channels end ``` ## Example — `async: true` returns an `Async::Task` [#example--async-true-returns-an-asynctask] Under `async: true` the call returns an `Async::Task`. `.wait` on it inside a reactor. ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), async: true ) # ...subscribe to a few channels... Async do task = supabase.remove_all_channels task.wait end ``` Outside a reactor, `Async { }` degrades to running inline — the call matches the sync path. ## Iteration is over a snapshot [#iteration-is-over-a-snapshot] The implementation iterates `@channels.dup` so a channel that removes itself from the registry during its own `unsubscribe` (via `handle_channel_close → _remove_channel`) doesn't shift the live array mid-loop. After the loop, the registry is cleared and `disconnect` is called — an intentional close, not a bare `@socket.close`, so the background reconnect thread is stopped instead of immediately bringing the socket back up. The realtime client is thread-based and every channel's `phx_leave` is a blocking `Socket#send`; without dispatch, the calling fiber under `async: true` would hang while N writes drain serially. The umbrella's `dispatch_realtime` wraps the fan-out in an `Async` block so the call returns an `Async::Task` the caller `.wait`s on. In sync mode (`async: false`, the default) the block runs straight through and the call returns `self` (the realtime client). Verified by `spec/async/remove_channel_non_blocking_spec.rb`. # Remove a channel (/reference/ruby/realtime/removechannel) `supabase.remove_channel(channel)` calls `channel.unsubscribe`, removes the channel from `client.channels`, and — when the registry is now empty — closes the underlying socket via `disconnect`. Use this when you're done with a single channel and want the client to potentially go idle (no socket, no heartbeat thread, no background reconnect). If you just want to stop receiving frames on a channel but keep the socket up for other channels, call `channel.unsubscribe` directly — see [`unsubscribe`](/reference/ruby/realtime/unsubscribe). ## Signature [#signature] The same method lives on the realtime sub-client directly (`supabase.realtime.remove_channel(channel)`) — the top-level shortcut forwards through `dispatch_realtime` so it can opt into `Async::Task` semantics under `async: true`. ## Parameters [#parameters] ## Returns [#returns] In sync mode the call blocks until the `phx_leave` frame is queued and returns the result of the underlying delete (typically `nil`). Under `async: true` the realtime teardown runs in a child `Async` task and the call returns the `Async::Task` so the calling fiber doesn't stall on a blocking `Socket#send`. Call `.wait` on the task to await completion. ## Example — remove a single channel [#example--remove-a-single-channel] ```ruby channel = supabase .channel("public:countries") .on_postgres_changes("*", schema: "public", table: "countries") { |p| handle(p) } .subscribe # ...done... supabase.remove_channel(channel) # Channel goes through LEAVING → CLOSED. # If this was the last channel, the socket also disconnects. ``` ## Example — remove one of several channels [#example--remove-one-of-several-channels] The socket stays up because the registry still holds `room`. ```ruby orders = supabase.channel("public:orders").subscribe room = supabase.channel("room:42").subscribe supabase.remove_channel(orders) # orders is removed; the socket stays open for room. ``` ## Example — non-blocking teardown under `async: true` [#example--non-blocking-teardown-under-async-true] Under `async: true` the call returns an `Async::Task`. `.wait` on it inside a reactor to await completion. ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_ANON_KEY"), async: true ) channel = supabase.channel("public:orders").subscribe Async do # Returns immediately with an Async::Task; phx_leave is sent in the child task. task = supabase.remove_channel(channel) task.wait end ``` Outside a reactor, `Async { }` degrades to running inline, so the call matches the sync path. ## Socket auto-close [#socket-auto-close] `remove_channel` routes the eventual socket teardown through the **intentional-close path** (`Client#disconnect`), not a bare `@socket.close`. A bare close would fire `on_close → schedule_reconnect` and bring the socket right back up. With `disconnect`, the reconnect thread is stopped, the heartbeat thread is killed, every remaining channel state is flipped to `CLOSED`, and the socket stays down until the next `subscribe` opens it again. The realtime client is thread-based, and `Socket#send` is blocking; without dispatch, the calling fiber under `async: true` would hang for the duration of the `phx_leave` write. The umbrella's `dispatch_realtime` wraps the call in an `Async` block so the call returns an `Async::Task` the caller may `.wait` on. In sync mode (`async: false`, the default) the block runs straight through and the call returns whatever `realtime.remove_channel` returned (typically `nil`). Verified by `spec/async/remove_channel_non_blocking_spec.rb`. # Subscribe to a channel (/reference/ruby/realtime/subscribe) Kick off the `phx_join` handshake for a channel. `subscribe` switches the channel state to `JOINING`, serializes every registered `on_postgres_changes` listener into the join payload, and either sends the join (when the socket is already open) or opens the socket (the client's `rejoin_channels` then sends the join exactly once on `on_open`). The optional block fires when the join resolves — with `SUBSCRIBED` on success, `CHANNEL_ERROR` on a server error, or `TIMED_OUT` after `Types::DEFAULT_TIMEOUT_SECONDS` (10s) with no reply. ## Signature [#signature] `subscribe` can only be called **once** per channel — a second call raises `Supabase::Realtime::Errors::AlreadyJoinedError`. To rejoin after `unsubscribe`, create a new channel via [`channel`](/reference/ruby/realtime/channel). ## Parameters [#parameters] This method takes no positional arguments. The block is optional. ## Returns [#returns] Returns the channel so the call can chain with `.on_*` listeners. The join is in flight when this method returns — use the block parameter (or `on_close` / `on_error`) to observe completion. State changes synchronously to `JOINING`; it only flips to `JOINED` after the server's phx\_reply arrives on the read-thread. ## Example — basic subscribe with status callback [#example--basic-subscribe-with-status-callback] ```ruby channel = supabase .channel("public:countries") .on_postgres_changes("*", schema: "public", table: "countries") do |payload| handle(payload) end .subscribe do |state, error| case state when "SUBSCRIBED" puts "live" when "CHANNEL_ERROR" warn "join failed: #{error.inspect}" when "TIMED_OUT" warn "no phx_reply in 10s — server unreachable?" end end ``` ## Example — subscribe without a callback [#example--subscribe-without-a-callback] When you don't care about the join result, drop the block. State is observable later via `channel.joined?`, `channel.errored?`, etc. ```ruby channel = supabase.channel("room:42").subscribe sleep 0.5 until channel.joined? channel.send_broadcast("ping", { at: Time.now.utc.iso8601 }) ``` ## Example — wait synchronously for SUBSCRIBED [#example--wait-synchronously-for-subscribed] A blocking wait for SUBSCRIBED with a `Queue`-based handshake. Useful in test setups or one-shot scripts. ```ruby ready = Queue.new channel = supabase .channel("public:orders") .on_postgres_changes("INSERT", schema: "public", table: "orders") { |p| handle(p) } .subscribe { |state, error| ready.push([state, error]) } state, error = ready.pop raise "subscribe failed: #{error.inspect}" unless state == "SUBSCRIBED" ``` ## Example — subscribe before the socket is open [#example--subscribe-before-the-socket-is-open] `subscribe` is a one-call entry point. If the socket isn't connected yet, it triggers `socket.connect` (idempotent) and the join is sent automatically when `on_open` fires. Don't call `socket.connect` separately first — the client guards against duplicate joins (which the server would `phx_close`). ```ruby supabase = Supabase.create_client(supabase_url: ..., supabase_key: ...) # Socket is NOT open yet. supabase .channel("room:42") .on_broadcast("chat:message") { |p| puts p } .subscribe { |state, _| puts "state=#{state}" } # Socket opens, join fires, state becomes "SUBSCRIBED". ``` ## Lifecycle [#lifecycle] * `JOINING` → server replies with `phx_reply` (`status: "ok"`) → `JOINED`, block called with `SUBSCRIBED`. * `JOINING` → server replies with `phx_reply` (`status: "error"`) → `ERRORED`, block called with `CHANNEL_ERROR`. The rejoin timer (`2^tries` backoff) schedules a retry. * `JOINING` → no reply within 10s → `ERRORED`, block called with `TIMED_OUT`. Rejoin timer same as above. * Once `JOINED`, a server-side `phx_close` (e.g. another tab opened the same topic) tears the channel down — the registered `on_close` hooks fire and the channel is removed from `client.channels`. * On socket reconnect, `client.rejoin_channels` re-sends the join for every channel in `JOINED` or `JOINING` state. Channels in `LEAVING` / `CLOSED` are left alone — rejoining them would silently revive a subscription the caller explicitly tore down. `subscribe` takes a block (`channel.subscribe { |state, error| ... }`) and is fully synchronous. There is no async surface for `subscribe` — under `async: true` the other sub-clients (auth / postgrest / etc.) are swapped, but realtime stays threaded. The status block runs on the read-thread; don't do long-running work in it (push to a `Queue` instead). Exceptions raised inside the block are caught by `CallbackSafety` and forwarded to the client's `logger:` — they will not crash the read-loop. # Unsubscribe from a channel (/reference/ruby/realtime/unsubscribe) Tear down a single channel's subscription. `unsubscribe` flips the channel state to `LEAVING`, emits a `phx_leave` push to the server, and stays in `LEAVING` until the server acks (or errors / times out). Once the ack arrives, the channel goes to `CLOSED`, every registered `on_close` hook fires, and the channel is removed from `client.channels` so it stops receiving dispatched frames. To also close the underlying socket when the registry empties, use [`remove_channel`](/reference/ruby/realtime/removechannel) instead — `unsubscribe` only tears down this one channel. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] Returns the channel. The leave is in flight when this method returns; the channel is still in `LEAVING` until the server replies. Idempotent — calling `unsubscribe` on an already-CLOSED channel is a no-op. ## Example — unsubscribe a single channel [#example--unsubscribe-a-single-channel] ```ruby channel = supabase.channel("public:countries").on_postgres_changes("*", schema: "public", table: "countries") { |p| handle(p) }.subscribe # ...some work... channel.unsubscribe ``` ## Example — wait for CLOSED before exiting [#example--wait-for-closed-before-exiting] `unsubscribe` returns before the server acks. Hook `on_close` to know when the leave is fully done — useful in test teardown or short-lived scripts. ```ruby done = Queue.new channel.on_close { done.push(:closed) } channel.unsubscribe done.pop # blocks until phx_leave is acked (or times out → on_close still fires via on_leave_ack) ``` ## Example — re-subscribing requires a fresh channel [#example--re-subscribing-requires-a-fresh-channel] `subscribe` may only be called once per channel object. To re-subscribe to the same topic after an unsubscribe, open a new channel. ```ruby channel.unsubscribe # A new channel for the same topic. channel = supabase .channel("public:countries") .on_postgres_changes("*", schema: "public", table: "countries") { |p| handle(p) } .subscribe ``` ## Lifecycle [#lifecycle] * Pre-condition: channel is `JOINED` (or `JOINING`). On `CLOSED` / `LEAVING`, `unsubscribe` is a no-op. * State flips to `LEAVING`. The `phx_leave` push is registered in `pending_pushes` with a 10s timeout. * Server replies with `phx_reply` → `on_leave_ack` → state to `CLOSED`, `on_close` hooks fire, rejoin timer reset. * Server replies with `phx_reply` (`error`) or no reply within 10s → still `on_leave_ack` (same handler for all three statuses), so the channel cleans up locally regardless of server response. * After CLOSED, the channel is removed from `client.channels` via `Client#_remove_channel`. It's no longer dispatched to. `unsubscribe` is synchronous — it returns once the `phx_leave` frame is queued (or sent, if connected). The actual server ack arrives asynchronously on the read-thread, which is why `on_close` is the only reliable way to detect "leave fully complete". For non-blocking teardown of multiple channels, use `supabase.remove_channel(channel)` from the top-level client under `async: true` — it returns an `Async::Task` you can `.wait` on (see [`remove_channel`](/reference/ruby/realtime/removechannel)). # Copy an existing file (/reference/ruby/storage/copy) Duplicate an object from `from_path` to `to_path` inside the same bucket. The source object is left in place. Cross-bucket copies are not supported by this endpoint. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Raw response body from storage-api, typically `{ "Key" => "/" }` (the new object's full key). Not wrapped in a Struct. ## Example — duplicate an object [#example--duplicate-an-object] ```ruby supabase.storage.from("templates").copy( "invoice-blank.pdf", "invoice-blank-2026.pdf" ) ``` ## Example — snapshot before edit [#example--snapshot-before-edit] ```ruby src = "drafts/post.md" supabase.storage.from("blog").copy(src, "drafts/post.snapshot-#{Time.now.to_i}.md") # now safe to mutate src in place ``` Sends `{ bucketId, sourceKey, destinationKey }` to `POST /object/copy`. Same-bucket-only restriction. # Create a bucket (/reference/ruby/storage/createbucket) Create a new bucket. The `id` you pass becomes the slug used in object URLs (e.g. `/object/public//path/to/file.png`), so prefer URL-safe lowercase identifiers. The single most important decision on this call is the `public:` flag: * `public: true` — anonymous reads are allowed via the public URL. Writes still require auth. Use for avatars, marketing images, anything you'd serve from a CDN. * `public: false` (the default if omitted, treated as private by storage-api) — every read goes through a signed URL or an authenticated session. Use for private uploads, paywalled assets, anything that needs RLS. You can flip `public:` later with [`update_bucket`](/reference/ruby/storage/updatebucket), but the bucket cannot be re-created with a different id without first emptying and deleting the old one. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The raw storage-api response body, typically `{ "name" => "" }` echoing the new bucket's id. The body is intentionally not wrapped in a `Bucket` struct — call [`get_bucket`](/reference/ruby/storage/getbucket) afterwards if you need the full record. ## Example — public bucket (CDN-style) [#example--public-bucket-cdn-style] ```ruby supabase.storage.create_bucket("avatars", public: true) # => { "name" => "avatars" } # Anyone can now read /object/public/avatars/. supabase.storage.from("avatars").upload("ada.png", File.binread("ada.png")) supabase.storage.from("avatars").get_public_url("ada.png") # => "https://project.supabase.co/storage/v1/object/public/avatars/ada.png" ``` ## Example — private bucket with size + MIME guards [#example--private-bucket-with-size--mime-guards] ```ruby supabase.storage.create_bucket( "private-uploads", public: false, file_size_limit: 10 * 1024 * 1024, # 10 MB allowed_mime_types: ["image/png", "image/jpeg", "application/pdf"] ) # Reads now require an authenticated session or a signed URL. supabase.storage.from("private-uploads").create_signed_url("contract.pdf", 60) ``` ## Example — minimal call [#example--minimal-call] ```ruby # Defaults to private, no size or MIME guard. supabase.storage.create_bucket("scratch") ``` # Create a signed upload URL (/reference/ruby/storage/createsignedupload) Mint a one-shot pre-signed URL for `path`. The caller hands the returned URL + token to an untrusted client (browser, mobile app, third-party worker) so it can upload bytes without seeing your service-role key. The receiving side uses [`upload_to_signed_url`](/reference/ruby/storage/uploadtosignedurl) to consume the token. The pre-sign endpoint itself rejects the anon key — call this method server-side with a service-role JWT. The URL it returns is what you ship to the untrusted client. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Struct with `signed_url` (fully-qualified URL, alias `signedUrl` / `signedURL`), `token` (the bearer token parsed out of the URL's `?token=...` for caller convenience), and `path` (echo of the requested path). ## Example — mint, ship, upload [#example--mint-ship-upload] ```ruby # Server side — uses your service-role-bearing client. signed = supabase.storage.from("uploads").create_signed_upload_url( "incoming/avatar-#{user_id}.png" ) # Send signed.signed_url + signed.token to the browser. The page then PUTs the # bytes to the URL directly, OR — if Ruby code consumes the token — uses # upload_to_signed_url (see /reference/ruby/storage/uploadtosignedurl). { url: signed.signed_url, token: signed.token, path: signed.path } ``` ## Example — allow overwrite [#example--allow-overwrite] ```ruby signed = supabase.storage.from("uploads").create_signed_upload_url( "user-#{user_id}/avatar.png", upsert: true ) ``` ## Example — alternate reader names [#example--alternate-reader-names] ```ruby signed = supabase.storage.from("uploads").create_signed_upload_url("scratch.bin") signed.signed_url # snake_case signed.signedUrl # camelCase alias signed.signedURL # all-caps alias ``` `Types::SignedUploadURL` exposes the URL under three reader names — `signed_url`, `signedUrl`, and `signedURL` — all returning the same String. The same alias convention applies to `create_signed_url` / `create_signed_urls` (which return Hashes with both string keys). # Create a signed URL (/reference/ruby/storage/createsignedurl) Mint a signed URL that lets the holder read `path` for `expires_in` seconds, without an Authorization header. Use for private buckets, paywalled assets, or temporary share links. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] `{ "signedURL" => "...", "signedUrl" => "..." }` — both keys carry the same fully-qualified URL (already joined with `@base_url`, including any `?download=` / `?transform=` query). The duplicate key is intentional: see the callout below. ## Example — minimal share link [#example--minimal-share-link] ```ruby result = supabase.storage.from("private-uploads").create_signed_url( "contracts/2026/agreement.pdf", expires_in: 60 ) result["signedURL"] # => "https://project.supabase.co/storage/v1/object/sign/private-uploads/contracts/2026/agreement.pdf?token=..." ``` ## Example — force download with custom filename [#example--force-download-with-custom-filename] ```ruby supabase.storage.from("invoices").create_signed_url( "2026/Q2/INV-0001.pdf", expires_in: 300, download: "ACME-Invoice-2026-Q2-0001.pdf" ) ``` ## Example — signed URL for an image transform [#example--signed-url-for-an-image-transform] ```ruby supabase.storage.from("avatars").create_signed_url( "people/ada.png", expires_in: 3600, transform: { width: 64, height: 64, resize: "cover" } ) ``` The Hash returned by `create_signed_url` carries the URL under **both** `"signedURL"` (all-caps URL) and `"signedUrl"` (camelCase, matches supabase-js). Pick whichever spelling your call site uses — the values are identical strings. The pattern repeats on `create_signed_urls` (per-item Hashes carry both keys) and on `Types::SignedUploadURL` (Struct exposes `signed_url`, `signedUrl`, and `signedURL` reader aliases). # Create signed URLs (/reference/ruby/storage/createsignedurls) Batch variant of [`create_signed_url`](/reference/ruby/storage/createsignedurl). Sends one HTTP request, returns one Hash per input path. Useful for slideshow galleries, paginated downloads, anything that needs many time-limited URLs at once. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] One Hash per input path. Each entry carries `"error"` (nil on success, message string on failure), `"path"` (the requested path), and the URL under both `"signedURL"` and `"signedUrl"`. Per-path errors do NOT raise — check the `error` field before reading the URL. ## Example — bulk signed URLs for a gallery [#example--bulk-signed-urls-for-a-gallery] ```ruby paths = (1..12).map { |i| "gallery/2026/photo-#{i}.jpg" } items = supabase.storage.from("private-gallery").create_signed_urls( paths, expires_in: 60 * 60 # one hour ) items.each do |item| next if item["error"] puts "#{item['path']} → #{item['signedURL']}" end ``` ## Example — force download on all URLs [#example--force-download-on-all-urls] ```ruby supabase.storage.from("invoices").create_signed_urls( ["2026/Q2/INV-0001.pdf", "2026/Q2/INV-0002.pdf"], expires_in: 600, download: true ) ``` ## Example — handle per-path errors [#example--handle-per-path-errors] ```ruby items = supabase.storage.from("private-uploads").create_signed_urls( ["exists.png", "missing.png"], expires_in: 60 ) ok, failed = items.partition { |it| it["error"].nil? } puts "signed #{ok.size}; failed #{failed.size}: #{failed.map { |it| it['path'] }.join(', ')}" ``` Each Hash in the returned array carries the URL under **both** `"signedURL"` and `"signedUrl"` — either key returns the same string. See the same note on [`create_signed_url`](/reference/ruby/storage/createsignedurl) for the full rationale. # Delete a bucket (/reference/ruby/storage/deletebucket) Delete a bucket. storage-api refuses to drop a bucket that still contains objects — call [`empty_bucket`](/reference/ruby/storage/emptybucket) first if you want to wipe everything in one go. This is a destructive, irreversible operation. Once deleted, the bucket id is free to be re-used by [`create_bucket`](/reference/ruby/storage/createbucket), but historic signed URLs, public URLs, and object UUIDs are gone. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The raw storage-api response body, typically `{ "message" => "Successfully deleted" }`. ## Example — delete an empty bucket [#example--delete-an-empty-bucket] ```ruby supabase.storage.delete_bucket("scratch") # => { "message" => "Successfully deleted" } ``` ## Example — empty + delete in one go [#example--empty--delete-in-one-go] ```ruby supabase.storage.empty_bucket("old-uploads") supabase.storage.delete_bucket("old-uploads") ``` ## Example — handle a non-empty bucket [#example--handle-a-non-empty-bucket] ```ruby begin supabase.storage.delete_bucket("user-uploads") rescue Supabase::Storage::Errors::StorageApiError => e if e.message.include?("not empty") supabase.storage.empty_bucket("user-uploads") retry else raise end end ``` # Download a file (/reference/ruby/storage/download) Fetch the raw bytes of `path` from the bucket. With `transform:`, the request is routed through `render/image/authenticated` and the keys are passed as query params; without it, the request hits `object/`. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Raw response body bytes. Encoding is `ASCII-8BIT` for binary objects. Returned directly from Faraday — caller is responsible for writing to disk, decoding, or streaming. ## Example — write bytes to disk [#example--write-bytes-to-disk] ```ruby bytes = supabase.storage.from("avatars").download("people/ada.png") File.binwrite("./ada.png", bytes) ``` ## Example — image transform (resize on the fly) [#example--image-transform-resize-on-the-fly] ```ruby thumb = supabase.storage.from("avatars").download( "people/ada.png", transform: { width: 64, height: 64, resize: "cover", quality: 80 } ) File.binwrite("./ada-64.png", thumb) ``` ## Example — extra query params [#example--extra-query-params] ```ruby # Adds ?cb=20260612 to the request URL — useful for cache-busting via a CDN. supabase.storage.from("avatars").download( "people/ada.png", query_params: { "cb" => "20260612" } ) ``` Unknown `transform:` keys are forwarded to storage-api (so server-only flags still work) but emit a one-line `Kernel#warn` to catch typos like `:hieght` early. # Empty a bucket (/reference/ruby/storage/emptybucket) Delete every object inside a bucket without removing the bucket itself. The bucket id, public flag, size limit, and MIME allowlist are preserved — only the contents are wiped. Use this when you want to reset a bucket (e.g. a development scratch area) without re-creating it and re-applying configuration. It's also the standard prelude to [`delete_bucket`](/reference/ruby/storage/deletebucket), which refuses to drop a non-empty bucket. This operation is **irreversible**. Every object is removed in a single server-side sweep — there's no per-object confirmation, no soft-delete, and no `dry_run` flag. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The raw storage-api response body, typically `{ "message" => "Successfully emptied" }`. ## Example — wipe a scratch bucket [#example--wipe-a-scratch-bucket] ```ruby supabase.storage.empty_bucket("scratch") # => { "message" => "Successfully emptied" } supabase.storage.from("scratch").list("") # => [] — bucket is intact, but contains no objects ``` ## Example — empty then delete [#example--empty-then-delete] ```ruby supabase.storage.empty_bucket("old-uploads") supabase.storage.delete_bucket("old-uploads") ``` ## Example — confirm before wiping [#example--confirm-before-wiping] ```ruby files = supabase.storage.from("user-uploads").list("") print "About to delete #{files.size} object(s). Type YES to confirm: " if $stdin.gets.chomp == "YES" supabase.storage.empty_bucket("user-uploads") end ``` # Retrieve a bucket (/reference/ruby/storage/getbucket) Fetch a single bucket record by id. Returns a `Supabase::Storage::Types::Bucket` struct — useful to confirm the bucket's `public` flag, current `file_size_limit`, or `allowed_mime_types` before mutating them with [`update_bucket`](/reference/ruby/storage/updatebucket). storage-api raises `404 Bucket not found` when the id doesn't exist, which the Ruby client surfaces as `Supabase::Storage::Errors::StorageApiError` with `code: "BucketNotFound"`. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] A `Struct` with `:id`, `:name`, `:owner`, `:public`, `:file_size_limit`, `:allowed_mime_types`, `:created_at`, `:updated_at`, `:type`. See the [bucket type table](/reference/ruby/storage#bucket-type) on the group overview for field descriptions. ## Example — fetch a bucket [#example--fetch-a-bucket] ```ruby bucket = supabase.storage.get_bucket("avatars") bucket.id # => "avatars" bucket.public # => true bucket.file_size_limit # => nil (unbounded) bucket.created_at # => "2026-06-12T10:00:00.000Z" ``` ## Example — branch on the public flag [#example--branch-on-the-public-flag] ```ruby bucket = supabase.storage.get_bucket("user-uploads") url = if bucket.public supabase.storage.from(bucket.id).get_public_url("a.png") else supabase.storage.from(bucket.id).create_signed_url("a.png", 60).fetch(:signed_url) end ``` ## Example — rescue not-found [#example--rescue-not-found] ```ruby begin supabase.storage.get_bucket("missing") rescue Supabase::Storage::Errors::StorageApiError => e warn "no such bucket (#{e.code}): #{e.message}" end ``` # Retrieve public URL (/reference/ruby/storage/getpublicurl) Construct the public URL for `path` without making an HTTP request — pure string builder. Only meaningful for buckets created with `public: true`; for private buckets you want [`create_signed_url`](/reference/ruby/storage/createsignedurl) instead. The method does **not** check that the object exists, that the bucket is actually public, or that storage-api will accept the URL — it just glues the base URL, render path, bucket id, and (RFC3986-encoded) path segments together. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The fully-qualified URL. No network request is made. Note that storage-api only honors the URL if the bucket was created with `public: true` — calling `get_public_url` on a private bucket returns a string that will 401 when fetched. ## Example — vanilla public URL [#example--vanilla-public-url] ```ruby supabase.storage.from("avatars").get_public_url("people/ada.png") # => "https://project.supabase.co/storage/v1/object/public/avatars/people/ada.png" ``` ## Example — force download with a custom filename [#example--force-download-with-a-custom-filename] ```ruby supabase.storage.from("downloads").get_public_url( "release-notes/v1.0.0.md", download: "release-notes-1.0.0.md" ) # => ".../object/public/downloads/release-notes/v1.0.0.md?download=release-notes-1.0.0.md" ``` ## Example — image transform [#example--image-transform] ```ruby supabase.storage.from("avatars").get_public_url( "people/ada.png", transform: { width: 128, height: 128, resize: "cover" } ) # => ".../render/image/public/avatars/people/ada.png?width=128&height=128&resize=cover" ``` ## Example — filenames with spaces [#example--filenames-with-spaces] ```ruby supabase.storage.from("attachments").get_public_url("Meeting Notes 2026-Q2.pdf") # => ".../object/public/attachments/Meeting%20Notes%202026-Q2.pdf" ``` Path encoding uses RFC3986 (unreserved set). `URI.encode_www_form_component` is deliberately NOT used because it follows application/x-www-form-urlencoded (spaces → `+`, which storage-api rejects). # Overview (/reference/ruby/storage) The `storage` surface reaches Supabase Storage via the top-level `Supabase::Client`. It exposes two layers: * **Bucket admin API** — `create_bucket`, `get_bucket`, `list_buckets`, `update_bucket`, `delete_bucket`, `empty_bucket`. Methods called directly on `supabase.storage`. * **File API** — `upload`, `download`, `list`, `move`, `copy`, `remove`, signed-URL helpers, public-URL helpers. Reached via `supabase.storage.from(bucket_id)`. ```ruby supabase = Supabase.create_client( supabase_url: ENV.fetch("SUPABASE_URL"), supabase_key: ENV.fetch("SUPABASE_SERVICE_ROLE_KEY") # bucket admin needs service-role ) supabase.storage.create_bucket("avatars", public: true) supabase.storage.list_buckets # => [Supabase::Storage::Types::Bucket(...)] supabase.storage.from("avatars").upload("ada.png", File.binread("ada.png")) ``` ## Bucket admin API [#bucket-admin-api] | Method | Description | | ------------------------------------------------------- | --------------------------------------------------------------------- | | [`create_bucket`](/reference/ruby/storage/createbucket) | Create a new bucket. `public: true` for unauthenticated reads. | | [`get_bucket`](/reference/ruby/storage/getbucket) | Fetch a single bucket record by id. | | [`list_buckets`](/reference/ruby/storage/listbuckets) | List every bucket the caller can see. | | [`update_bucket`](/reference/ruby/storage/updatebucket) | Patch a bucket's `public` / `file_size_limit` / `allowed_mime_types`. | | [`empty_bucket`](/reference/ruby/storage/emptybucket) | Delete every object inside a bucket without removing the bucket. | | [`delete_bucket`](/reference/ruby/storage/deletebucket) | Delete the bucket itself. Bucket must be empty. | ## File API [#file-api] Scoped to one bucket via `supabase.storage.from(bucket_id)` (aliases: `from_` and `bucket`). | Method | Description | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | [`upload`](/reference/ruby/storage/upload) | Upload an object. Accepts `String` bytes, `File`/`IO`, or `Pathname`. | | [`download`](/reference/ruby/storage/download) | Download object bytes. Optional `transform:` routes through the image renderer. | | [`list`](/reference/ruby/storage/list) | List objects under a prefix. Returns the raw response `Array`. | | [`move`](/reference/ruby/storage/move) | Move (rename) an object inside the same bucket. | | [`copy`](/reference/ruby/storage/copy) | Copy an object inside the same bucket. | | [`remove`](/reference/ruby/storage/remove) | Delete one or more objects by path. | | [`create_signed_url`](/reference/ruby/storage/createsignedurl) | Mint a time-limited URL for one object. | | [`create_signed_urls`](/reference/ruby/storage/createsignedurls) | Mint signed URLs in bulk for many objects. | | [`create_signed_upload_url`](/reference/ruby/storage/createsignedupload) | Mint a pre-signed URL that lets a client upload without your service-role key. | | [`upload_to_signed_url`](/reference/ruby/storage/uploadtosignedurl) | Consume a pre-signed URL to upload bytes. Server-side helper. | | [`get_public_url`](/reference/ruby/storage/getpublicurl) | Build the public URL for an object in a `public: true` bucket. | ## Bucket type [#bucket-type] `get_bucket` and `list_buckets` return `Supabase::Storage::Types::Bucket` structs with these fields: | Field | Type | Description | | -------------------- | -------------------- | ---------------------------------------------------------------------------- | | `id` | `String` | Stable identifier (the slug used in URLs). | | `name` | `String` | Display name. Defaults to `id` on create. | | `owner` | `String` | UUID of the user that created the bucket. | | `public` | `Boolean` | `true` if anonymous reads are allowed via the public URL. | | `file_size_limit` | `Integer, nil` | Maximum object size in bytes. `nil` means unbounded. | | `allowed_mime_types` | `Array, nil` | Allowlist of content types. `nil` means any. | | `created_at` | `String` | ISO-8601 timestamp. | | `updated_at` | `String` | ISO-8601 timestamp. | | `type` | `String, nil` | `"STANDARD"` for object buckets. Present only on newer storage-api versions. | ## Authentication [#authentication] Bucket admin operations require the **service-role key** — the anon key is rejected by Supabase Storage for every endpoint in this group. Construct the client with the service-role JWT in `supabase_key:` (or pass a custom `Authorization: Bearer ...` header) before calling any method on this page. Every method on this page requires the service-role JWT. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Keep the service-role key on the server — never ship it to a browser. # List all files in a bucket (/reference/ruby/storage/list) List objects under `prefix` inside the bucket. Pagination is offset/limit-based (the cursor-based `list_v2` is a separate method exposed by `Storage::FileApi#list_v2`). ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Raw response array from storage-api. Each entry carries `"name"`, `"id"`, `"updated_at"`, `"created_at"`, `"last_accessed_at"`, and a `"metadata"` Hash (size, mimetype, etag, cacheControl, etc.). Folders appear as entries whose `"id"` is `nil`. Not wrapped in a Struct — index with string keys. ## Example — list the bucket root [#example--list-the-bucket-root] ```ruby objects = supabase.storage.from("avatars").list objects.first["name"] # => "ada.png" objects.first["metadata"]["size"] # => 49213 ``` ## Example — list under a prefix with paging [#example--list-under-a-prefix-with-paging] ```ruby page = supabase.storage.from("avatars").list( "people/", limit: 50, offset: 0, sort_by: { column: "created_at", order: "desc" } ) ``` ## Example — search within a folder [#example--search-within-a-folder] ```ruby matches = supabase.storage.from("avatars").list( "people/", search: "ada" ) ``` Wire payload: `{ "prefix": ..., "limit": 100, "offset": 0, "sortBy": { column, order }, "search": ... }`. For cursor pagination, use `list_v2(prefix:, limit:, cursor:, with_delimiter:, sort_by:)`. # List all buckets (/reference/ruby/storage/listbuckets) List every bucket the caller can see. Returns an `Array` — empty when there are no buckets, never `nil`. storage-api returns every bucket in the project; there's no server-side filter on the wire. To filter by name / public-flag, do it in Ruby on the returned array. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] This method has no parameters. ## Returns [#returns] Every bucket the caller can see. Empty array if the project has no buckets. See the [bucket type table](/reference/ruby/storage#bucket-type) for field descriptions. ## Example — list every bucket [#example--list-every-bucket] ```ruby buckets = supabase.storage.list_buckets buckets.map(&:id) # => ["avatars", "marketing-assets", "private-uploads"] ``` ## Example — find the public buckets [#example--find-the-public-buckets] ```ruby public_buckets = supabase.storage.list_buckets.select(&:public) public_buckets.each do |b| puts "#{b.id} (created #{b.created_at})" end ``` ## Example — guard against an empty project [#example--guard-against-an-empty-project] ```ruby buckets = supabase.storage.list_buckets if buckets.empty? supabase.storage.create_bucket("avatars", public: true) end ``` The `Array(...)` wrapper around the parsed body means an unexpected `nil` body parses as `[]` rather than raising. # Move an existing file (/reference/ruby/storage/move) Rename or move an object from `from_path` to `to_path` inside the same bucket. Cross-bucket moves are not supported by this endpoint — copy then remove if you need that. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Raw response body from storage-api, typically `{ "message" => "Successfully moved" }`. Not wrapped in a Struct. ## Example — rename within a folder [#example--rename-within-a-folder] ```ruby supabase.storage.from("avatars").move( "people/ada.png", "people/ada-lovelace.png" ) ``` ## Example — move into a subfolder [#example--move-into-a-subfolder] ```ruby supabase.storage.from("invoices").move( "INV-0001.pdf", "2026/Q2/INV-0001.pdf" ) ``` Sends `{ bucketId, sourceKey, destinationKey }` to `POST /object/move`. Same-bucket-only — does NOT silently fall back to copy + remove if you pass a path with a different bucket prefix. # Delete files in a bucket (/reference/ruby/storage/remove) Delete the given paths from the bucket. Accepts either a single `String` path or an `Array` of paths — single paths are wrapped automatically via `Array(paths)`. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The list of object records that were deleted (each entry carries `"name"`, `"id"`, `"bucket_id"`, `"metadata"`, timestamps). Paths that didn't exist are silently omitted — the response is the intersection of `paths` and the bucket's actual contents. ## Example — delete a single object [#example--delete-a-single-object] ```ruby supabase.storage.from("avatars").remove("people/ada.png") ``` ## Example — bulk delete [#example--bulk-delete] ```ruby supabase.storage.from("invoices").remove( ["2025/Q4/INV-0042.pdf", "2025/Q4/INV-0043.pdf", "2025/Q4/INV-0044.pdf"] ) ``` ## Example — clear an entire prefix [#example--clear-an-entire-prefix] ```ruby prefix = "drafts/" paths = supabase.storage.from("blog").list(prefix).map { |row| "#{prefix}#{row['name']}" } supabase.storage.from("blog").remove(paths) unless paths.empty? ``` Sends `DELETE /object/` with `{ "prefixes": [...] }`. `Array(paths)` wrapping means a bare `String` is accepted alongside an array. # Update a bucket (/reference/ruby/storage/updatebucket) Patch a bucket's `public` flag, `file_size_limit`, or `allowed_mime_types`. Pass only the fields you want to change — `nil` (or omitting the keyword) leaves the existing value untouched. The bucket `id` is immutable. To rename a bucket, you'd have to [`create_bucket`](/reference/ruby/storage/createbucket) a new id, copy objects across, and [`delete_bucket`](/reference/ruby/storage/deletebucket) the old one. Bucket admin endpoints reject the publishable / anon key with `401 Unauthorized`. Construct the client with the service-role JWT. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] The raw storage-api response body, typically `{ "message" => "Successfully updated" }`. Re-fetch with [`get_bucket`](/reference/ruby/storage/getbucket) if you need the updated `Bucket` struct. ## Example — make a private bucket public [#example--make-a-private-bucket-public] ```ruby supabase.storage.update_bucket("avatars", public: true) ``` ## Example — tighten the MIME allowlist [#example--tighten-the-mime-allowlist] ```ruby supabase.storage.update_bucket( "avatars", allowed_mime_types: ["image/png", "image/jpeg", "image/webp"] ) ``` ## Example — raise the size cap [#example--raise-the-size-cap] ```ruby supabase.storage.update_bucket("private-uploads", file_size_limit: 50 * 1024 * 1024) ``` ## Example — change multiple fields at once [#example--change-multiple-fields-at-once] ```ruby supabase.storage.update_bucket( "marketing-assets", public: true, file_size_limit: 25 * 1024 * 1024, allowed_mime_types: ["image/png", "image/jpeg", "image/svg+xml", "video/mp4"] ) ``` The wire payload always carries `"name" => id` even on update — storage-api ignores it when present alongside the unchanged id. This is intentional. # Upload a file (/reference/ruby/storage/upload) Upload bytes to `path` inside the bucket scoped by `from(bucket_id)`. The bytes source can be a `File`/`IO`, a `Pathname` (read from disk), or a raw `String` payload — whichever fits the call site best. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Struct with `path` (the relative path you uploaded to), `full_path` (alias `fullPath`) carrying the bucket-prefixed key returned by storage-api, and `key` (same value as `full_path`). ## Example — `File.open` body [#example--fileopen-body] ```ruby File.open("./ada.png", "rb") do |io| response = supabase.storage.from("avatars").upload( "people/ada.png", io, content_type: "image/png", cache_control: 31_536_000, # one year upsert: true ) response.path # => "people/ada.png" response.full_path # => "avatars/people/ada.png" end ``` ## Example — raw `String` body [#example--raw-string-body] ```ruby # A String is uploaded as raw bytes — it is NOT treated as a file path. supabase.storage.from("notes").upload( "scratch/hello.txt", "hello world\n", content_type: "text/plain" ) ``` ## Example — `Pathname` body [#example--pathname-body] ```ruby require "pathname" supabase.storage.from("avatars").upload( "people/grace.png", Pathname("./grace.png"), content_type: "image/png" ) ``` ## Example — with custom metadata [#example--with-custom-metadata] ```ruby supabase.storage.from("invoices").upload( "2026/Q2/INV-0001.pdf", File.binread("INV-0001.pdf"), content_type: "application/pdf", metadata: { customer_id: 42, locked: true } ) ``` A `String` is always raw bytes/text. Disk reads are spelled `File.open(path)`, `File.binread(path)`, or `Pathname(path)`. This matches supabase-js (Blob/Buffer/File, never a path string) and avoids the silent footgun where `upload("a.png", "./a.png")` would upload the literal path string. # Upload to a signed URL (/reference/ruby/storage/uploadtosignedurl) Server-side counterpart to [`create_signed_upload_url`](/reference/ruby/storage/createsignedupload). Sends a multipart `PUT` to the pre-signed endpoint, attaching the bytes the holder of the token wants stored at `path`. Most apps hand the pre-signed URL straight to a browser/mobile client and let it `PUT` directly. This method exists for the (less common) case where a Ruby worker holds the token and needs to perform the upload itself — for example, an intermediary that receives bytes from an untrusted source, validates them, and forwards under the signed token. ## Signature [#signature] ## Parameters [#parameters] ## Returns [#returns] Same Struct as [`upload`](/reference/ruby/storage/upload). `path` carries the relative path you uploaded to; `full_path` (alias `fullPath`) carries the bucket-prefixed key returned by storage-api. ## Example — pre-sign on the server, upload via Ruby worker [#example--pre-sign-on-the-server-upload-via-ruby-worker] ```ruby # Step 1 — mint the URL with service-role credentials. signed = supabase_admin.storage.from("uploads").create_signed_upload_url( "incoming/report-#{job_id}.csv" ) # Step 2 — somewhere else (intermediary worker, no service-role key needed) # the same Ruby code consumes the token to push bytes. supabase_anon.storage.from("uploads").upload_to_signed_url( signed.path, token: signed.token, file: File.open("./report.csv", "rb"), content_type: "text/csv" ) ``` ## Example — raw String body [#example--raw-string-body] ```ruby supabase.storage.from("uploads").upload_to_signed_url( signed.path, token: signed.token, file: "col_a,col_b\n1,2\n3,4\n", content_type: "text/csv" ) ``` See the [`upload`](/reference/ruby/storage/upload) callout for the String-is-bytes rule on `file:`. # Architecture (/reference/starterkits/api/architecture) The starter is a thin shell — most of the auth machinery lives in [`supabase-rails`](/reference/rails). This page covers the integration points the kit relies on so you can reason about (and safely modify) the request lifecycle. ## Middleware stack [#middleware-stack] After Rails 8's default API middleware, three pieces are arranged like this (outermost first): ``` ... default Rails API middleware ... Rack::Cors # mounted only if CORS_ORIGINS is set (or dev/test) JsonUnauthorizedResponder # rewrites any 401 body to {"error":"unauthorized"} Rack::Attack # api/ip + api/token throttles Supabase::Rails::Middleware # JWT verification, populates request env ... Rails router → controllers ... ``` Two things make this order matter: 1. **`Rack::Attack` runs ahead of `Supabase::Rails::Middleware`.** A throttled request returns 429 before the JWKS verification path runs, so an attacker hammering the endpoint can't force JWKS lookups. `config/application.rb`'s `starter_kit.rack_attack` initializer (`after: "supabase.middleware"`) calls `move_before` to put it there — the Rack::Attack railtie's default is at the end of the stack, which would be too late. 2. **`JsonUnauthorizedResponder` runs *outside* `Supabase::Rails::Middleware`.** The gem's default 401 body shape is `{message:, code:}`; we want `{"error":"unauthorized"}` for consistency with the controller-level 401. The responder sees responses on the way out, after the gem has already produced its 401, and rewrites the body. The `starter_kit.json_unauthorized_responder` initializer (`after: "supabase.middleware"`) uses `insert_before Supabase::Rails::Middleware` to land in that slot. You can inspect the live stack with: ```sh bin/rails middleware ``` ## Request lifecycle [#request-lifecycle] For an authenticated request to `GET /api/v1/me`: ``` 1. Client sends: GET /api/v1/me Authorization: Bearer eyJ... 2. Rack::Cors → either pass (origin allowed) or 403 3. JsonUnauthorizedResponder → passes through; only acts on 401 responses 4. Rack::Attack → consults the api/ip + api/token throttles 429 if either exceeded 5. Supabase::Rails::Middleware: a. Parses Authorization header b. Loads the JWKS (cached) — see "JWKS verification" below c. Verifies the JWT signature, exp, aud, iss d. Sets request env: verified Supabase::User + raw claims 6. Rails router → Api::V1::MeController#show 7. Authentication concern → before_action :require_authentication - Sees the verified user → assigns Current.user 8. MeController#show → renders JSON from Current.user ``` For an **unauthenticated** request (missing token): * The middleware passes through because `config.supabase.auth = %i[user none]` includes `:none` as a fallback strategy. The request reaches the controller. * The `Authentication` concern's `require_authentication` finds no user and falls through to `request_authentication`, which renders `{"error":"unauthorized"}` with 401. For an **invalid** token (bad signature, expired, wrong audience): * `Supabase::Rails::Middleware` rejects the request itself and returns 401 with `{message:, code:}`. * `JsonUnauthorizedResponder` sees the 401 on the way out and rewrites the body to `{"error":"unauthorized"}` so clients get the same shape regardless of which layer fails. This is the canonical 401 contract the kit promises to clients: **one body shape, one status code, regardless of failure mode**. ## Supabase integration points [#supabase-integration-points] The starter wires up four distinct things from `supabase-rails`. Knowing which is which makes the rest of the kit much easier to extend. ### 1. Mode [#1-mode] `config.supabase.mode = :api` (in `config/initializers/supabase.rb`) disables the encrypted-cookie session machinery the gem normally installs in `:web` mode. The middleware still installs; it just never reads or writes a session cookie. If you ever need the gem's full web-mode auth (cookie session, sign-in/sign-up controllers) on top of the API, that's the wrong starter — switch to the [Hotwire](/reference/starterkits/hotwire) kit. ### 2. Auth strategies [#2-auth-strategies] `config.supabase.auth = %i[user none]` is a strategy chain. The middleware tries each in order until one succeeds: * `:user` — parse `Authorization: Bearer `, verify, populate user. * `:none` — anonymous request, set no user. The `:none` fallback is intentional: it makes unauthenticated requests reach the controller so the kit's `request_authentication` override can return the canonical `{"error":"unauthorized"}` body. Without `:none`, the gem would short-circuit at the middleware with its own 401 shape, and `JsonUnauthorizedResponder` would rewrite it — same end result, but harder to reason about per-route exceptions like `/healthz`. ### 3. JWKS verification [#3-jwks-verification] The starter verifies JWTs against a JWKS — the JSON Web Key Set published by Supabase Auth — rather than calling a `getUser` endpoint over HTTP. This is what makes auth fast and offline-capable. Two configuration paths: * **From the JWT secret (the default in this kit).** `SUPABASE_JWT_SECRET` is a symmetric HMAC secret. The kit's test setup constructs a synthetic JWKS from this secret, and the production path uses the same approach. This works because Supabase Cloud's `SUPABASE_JWT_SECRET` *is* the HMAC key for HS256 tokens. * **From a JWKS URL.** If you switch to RS256/asymmetric tokens, set `SUPABASE_JWKS_URL` instead and the gem will fetch + cache the JWKS document at boot. In `spec/rails_helper.rb`, the kit installs an in-memory JWKS keyed by the test `SUPABASE_JWT_SECRET`. `SupabaseAuthHelper` signs HS256 tokens with that same secret, so test JWTs verify cleanly through the real middleware path — no stubs, no `WebMock`. ### 4. The `Authentication` concern + `Current.user` [#4-the-authentication-concern--currentuser] `Supabase::Rails::Authentication` (included via `app/controllers/concerns/authentication.rb`) is a thin controller concern that: * Installs `before_action :require_authentication` on every action that includes it. * Exposes `Current.user` populated from the JWT claims that the middleware verified. * Exposes the `allow_unauthenticated_access(only: …)` class macro for whitelisting actions (used by `HealthzController`). `Current.user` is a `Supabase::Rails::User` value object built from the verified claims — `id`, `email`, `role`, `app_metadata`, `user_metadata`, and `raw` (the full claims hash). There is no Postgres lookup; the JWT *is* the source of truth. When you eventually need a host-app `users` table (to join `Current.user.id` against your own foreign keys), generate one with [`bin/rails generate supabase:user_model`](/reference/rails/generators). ## What lives outside the kit [#what-lives-outside-the-kit] A few things look like they belong to the starter but are actually contracts owned elsewhere: * **The JWT format.** Supabase mints the JWTs. `SUPABASE_JWT_SECRET` is the secret you share with them. The claims structure (`sub`, `aud`, `role`, `app_metadata`, `user_metadata`) follows Supabase Auth's contract, not Rails'. * **The user record.** Supabase Auth owns `auth.users`. The kit doesn't have a `users` table. * **Email confirmation, password reset, OAuth.** All happen on the Supabase side. Clients (mobile, SPA) talk directly to Supabase Auth and only call this API once they have a JWT. If you need any of those flows mediated by Rails (e.g. an SSR sign-in page), this isn't the right starter — pick [Hotwire](/reference/starterkits/hotwire) or [Inertia + React](/reference/starterkits/inertia-react) and let `supabase-rails`' `:web` mode handle it. ## Why no `User` model [#why-no-user-model] It's a common first question. The trade-off: * **No AR `User`** — what the kit does. `Current.user` is the verified JWT. Zero DB round-trips on the auth path, zero local user state, zero sync drift between Supabase and Rails. Costs: you can't easily `belongs_to :user` in your domain models, and you don't have a place to hang local profile fields. * **AR `User`** — what `bin/rails generate supabase:user_model` gives you. A `users` table keyed by `id = Current.user.id`, populated lazily (or by webhook) the first time a user shows up. Costs: one round-trip on the auth path, and you have to think about sync. The kit ships without it because most API consumers don't need it on day one; add it the day you have a model that needs `belongs_to :user`. # Customization (/reference/starterkits/api/customization) The kit is small on purpose. These three recipes cover the extensions you'll reach for first. ## Recipe 1 — Add a resource [#recipe-1--add-a-resource] Let's add a `notes` resource scoped to the authenticated user. By the end you'll have `GET /api/v1/notes`, `POST /api/v1/notes`, real specs, and an OpenAPI entry. ### 1. Migration and model [#1-migration-and-model] ```sh bin/rails generate model Note user_id:uuid:index title:string body:text bin/rails db:migrate ``` ```ruby # app/models/note.rb class Note < ApplicationRecord validates :title, presence: true end ``` The `user_id` column is a UUID, not a `belongs_to`. The kit has no `User` AR model — the foreign key points at `Current.user.id`, which is the verified `sub` claim from the JWT. If you want a real `belongs_to :user`, run [`bin/rails generate supabase:user_model`](/reference/rails/generators) first and switch the column to `references`. ### 2. Routes [#2-routes] ```ruby # config/routes.rb namespace :api do namespace :v1 do get "me", to: "me#show" resources :notes, only: %i[index create] end end ``` ### 3. Controller [#3-controller] ```ruby # app/controllers/api/v1/notes_controller.rb module Api module V1 class NotesController < ApplicationController def index notes = Note.where(user_id: Current.user.id).order(created_at: :desc) render json: notes end def create note = Note.new(note_params.merge(user_id: Current.user.id)) if note.save render json: note, status: :created else render json: { errors: note.errors }, status: :unprocessable_entity end end private def note_params params.expect(note: %i[title body]) end end end end ``` `Current.user` is the verified JWT — already authenticated by the time the controller runs, because `Authentication` is included in `ApplicationController`. ### 4. Request specs [#4-request-specs] ```ruby # spec/requests/notes_spec.rb require "rails_helper" RSpec.describe "Api::V1::Notes" do describe "GET /api/v1/notes" do it "returns the caller's notes" do user_id = "11111111-1111-1111-1111-111111111111" Note.create!(user_id: user_id, title: "Mine") Note.create!(user_id: "22222222-2222-2222-2222-222222222222", title: "Theirs") get "/api/v1/notes", headers: auth_headers(sub: user_id) expect(response).to have_http_status(:ok) titles = response.parsed_body.map { |n| n["title"] } expect(titles).to eq(["Mine"]) end it "401s without a token" do get "/api/v1/notes" expect(response).to have_http_status(:unauthorized) end end end ``` `auth_headers(sub: …)` is from `SupabaseAuthHelper` — it mints a real signed JWT against the in-memory JWKS, so the request goes through the actual middleware stack. ### 5. OpenAPI [#5-openapi] Add the integration spec so the docs regenerate to include the new endpoints: ```ruby # spec/integration/notes_spec.rb require "swagger_helper" RSpec.describe "Notes", type: :request do path "/api/v1/notes" do get "List notes for the authenticated user" do tags "Notes" produces "application/json" security [ { bearer_auth: [] } ] response "200", "ok" do schema type: :array, items: { type: :object, required: %w[id title], properties: { id: { type: :integer }, title: { type: :string }, body: { type: :string } } } let(:Authorization) { auth_headers["Authorization"] } run_test! end end end end ``` Then regenerate `swagger/v1/swagger.yaml`: ```sh RAILS_ENV=test bundle exec rake rswag:specs:swaggerize ``` Commit the regenerated YAML — it's the file production serves. ## Recipe 2 — Allow a public route past auth [#recipe-2--allow-a-public-route-past-auth] The `Authentication` concern installs `before_action :require_authentication` on every action. To exempt one, use the `allow_unauthenticated_access` class macro the gem provides. `HealthzController` is the example already in the kit: ```ruby # app/controllers/healthz_controller.rb class HealthzController < ApplicationController allow_unauthenticated_access only: :show def show render json: { status: "ok" } end end ``` Two common variations: ```ruby # All actions on this controller are public. class StatusController < ApplicationController allow_unauthenticated_access ... end # Mix public and authenticated actions on the same controller. class PostsController < ApplicationController allow_unauthenticated_access only: %i[index show] ... end ``` `Current.user` is `nil` on the public actions, so guard any code that depends on it. Read [Authentication → `allow_unauthenticated_access`](/reference/rails/authentication) for the full semantics (including how it interacts with the `:user`/`:none` strategy chain). ### Allow a route through the rate limiter too [#allow-a-route-through-the-rate-limiter-too] If a public route should also bypass `Rack::Attack`, exclude it from the throttles. The kit's throttles only fire on paths under `/api/`, so a top-level `/healthz` is automatically out. If you put a public route under `/api/`, narrow the throttle: ```ruby # config/initializers/rack_attack.rb Rack::Attack.throttle("api/ip", limit: 300, period: 5.minutes) do |req| req.ip if req.path.start_with?("/api/") && req.path != "/api/v1/status" end ``` For a public route under `/api/`, also consider whether CORS preflight (`OPTIONS`) needs to pass — the existing CORS config already allows `OPTIONS`, you just need to make sure `CORS_ORIGINS` is set in the environment. ## Recipe 3 — Change the database schema [#recipe-3--change-the-database-schema] The kit ships with no domain migrations, so your first schema change is your first migration. The interesting question is **what column type to use for the foreign key to `auth.users`** — Supabase user ids are UUIDs. ### Two patterns [#two-patterns] **A. Reference `Current.user.id` directly (no AR `User`).** ```ruby class CreateNotes < ActiveRecord::Migration[8.1] def change create_table :notes do |t| t.uuid :user_id, null: false, index: true t.string :title, null: false t.text :body t.timestamps end end end ``` This is what Recipe 1 above does. Pros: no AR `User`, no sync work, no migration when a user signs up. Cons: no `belongs_to :user`, so no eager loading, no validations, no reflections. **B. Add a host-app `User` model and `belongs_to`.** ```sh bin/rails generate supabase:user_model bin/rails db:migrate ``` This creates a `users` table keyed by `id = Current.user.id` and a `User` AR model. From there, your domain migrations can use `references`: ```ruby t.references :user, type: :uuid, foreign_key: true, null: false ``` And your models can `belongs_to :user`. Trade-off: you now have to populate that `users` row before the FK constraint will allow a write. The generator's docs cover the options (lazy creation in `Current.user`, webhook from Supabase Auth, etc.). ### Multiple databases [#multiple-databases] The kit configures **four** Postgres databases in production: `primary`, `cache` (Solid Cache), `queue` (Solid Queue), and `cable` (Solid Cable). Your migrations go in the default `db/migrate/` and target `primary`. The Solid `*` schemas are loaded from `db/{cache,queue,cable}_schema.rb` by `bin/rails db:prepare` — leave them alone unless you have a reason. If you don't need three separate databases, point all four roles at the same connection in `config/database.yml` — Rails 8 supports it, and it's a fine simplification for small apps. ### Migrating in production with Kamal [#migrating-in-production-with-kamal] Run migrations as part of the deploy. The Kamal config doesn't ship a hook for this — add one to `config/deploy.yml`: ```yaml # config/deploy.yml servers: web: hosts: - HETZNER_HOST options: pre-deploy: | bundle exec rails db:migrate ``` Or use `kamal app exec`: ```sh bin/kamal app exec --interactive --reuse "bin/rails db:migrate" ``` Either way, migrations should be backwards-compatible with the previous deploy until the new image is live — the rolling-deploy rules from the Rails guides apply here too. # Deployment (/reference/starterkits/api/deployment) This page covers the production-readiness concerns that aren't specific to your hosting provider. The kit ships a `Dockerfile` and a `config/deploy.yml` shaped for Kamal, but the same concerns apply if you're running on Fly, Render, Heroku, ECS, or anywhere else that boots a Linux container. This page is intentionally generic. The kit doesn't tie you to a particular host — Fly, Render, Heroku, Kamal-on-Hetzner, and ECS will each have their own opinions about Dockerfiles, secrets, and health checks. The points below are the ones you have to think about regardless of which one you pick. ## Secrets [#secrets] Set these in your platform's secret manager. **Don't ship `.env` to production** — `dotenv-rails` only auto-loads in `development` and `test`, so they wouldn't be read anyway, but it's the kind of mistake that goes unnoticed until the secrets show up in a backup. | Variable | Required? | Set to | | --------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_URL` | required | Your project URL (e.g. `https://abc.supabase.co`). Boot fails fast if missing in production. | | `SUPABASE_ANON_KEY` | required | The anon/publishable key from the Supabase dashboard. | | `SUPABASE_JWT_SECRET` | required | JWT signing secret from Project Settings → API. Treat this like any other production secret. | | `DATABASE_URL` | required | Production Postgres URL. For Supabase Cloud, use the pooled (6543) URL. | | `CORS_ORIGINS` | required if browser clients | Comma-separated list of allowed origins. If empty in production, `Rack::Cors` is **not mounted at all** — fail-closed. | | `SWAGGER_UI_ENABLED` | optional | `true` to expose `/api-docs` in production. Off by default. | | `RAILS_MASTER_KEY` | only if using encrypted credentials | Generated by `bin/rails credentials:edit`. | `SECRET_KEY_BASE` is read from `config/credentials.yml.enc` (or `RAILS_MASTER_KEY`). If you don't use encrypted credentials, set `SECRET_KEY_BASE` directly in the environment. ## Database [#database] A few decisions that matter: * **Use the pooler.** For Supabase Cloud, the pooler URL (port 6543) is what you want — it shares connections across containers so you don't blow through Postgres' `max_connections` ceiling. The direct URL (port 5432) is fine for one-off `db:migrate`, not for the running web container. * **`max_connections` per container.** `config/database.yml` reads `RAILS_MAX_THREADS` (default `5`) — set it to match your Puma `threads` setting so the pool size matches concurrency. * **Run migrations once per deploy, before the new image takes traffic.** With Kamal, hook this into `pre-deploy`; with most other platforms, a "release" or "pre-deploy" hook is the right place. Backwards-compatible migrations are non-negotiable during a rolling deploy. * **Solid Cache / Queue / Cable.** Production runs on the three additional schemas defined in `config/database.yml`. `bin/rails db:prepare` sets them up; the schemas are static (no migrations). ## Rate limiting in production [#rate-limiting-in-production] `Rack::Attack`'s throttles count requests in `Rails.cache`. In development that's an in-process memory store, which is fine. In production with multiple Puma workers and multiple containers, **the cache must be shared** — otherwise each worker has its own counter and a `limit: 300/5min` becomes `300 × workers × containers / 5min`. The kit's production config defaults to **Solid Cache** (`config.cache_store = :solid_cache_store`), which is shared via Postgres — so the throttles work correctly out of the box. If you switch to a different cache store, make sure it's a *shared* one: Redis, Memcached, or another Solid Cache database. Don't point `Rails.cache` at `:memory_store` in production. The throttled response is `429 {"error":"too_many_requests"}` with a `Retry-After` header. Clients should honour it; servers should not retry on 429 with backoff disabled. ## CORS [#cors] `Rack::Cors` is mounted only if `CORS_ORIGINS` is set in production. The default (`CORS_ORIGINS=*`) is **not** applied in production — set it explicitly: ```sh CORS_ORIGINS=https://app.example.com,https://admin.example.com ``` If your API is only ever called server-to-server (mobile/SPA → API, never browser → API), leave `CORS_ORIGINS` empty and the middleware never mounts. That's the most secure posture. ## TLS [#tls] The kit's `Dockerfile` exposes port 3000, and `config/deploy.yml`'s `proxy.app_port: 3000` forwards from the Kamal proxy. Two settings to flip in production: ```ruby # config/environments/production.rb config.assume_ssl = true # we're behind a TLS-terminating proxy config.force_ssl = true # redirect HTTP → HTTPS and use Strict-Transport-Security ``` Both are commented out by default — uncomment them once your TLS terminator is live. With Kamal, that's the moment you flip `proxy.ssl: true` (after the DNS A/AAAA record points at the host so Let's Encrypt can issue a cert). If you're behind a load balancer or CDN (Cloudflare, ALB, Cloud Run), confirm the proxy forwards `X-Forwarded-Proto` so `assume_ssl` recognises the original scheme. ## Health checks [#health-checks] Two endpoints, two jobs: | Path | Purpose | Who reads it | | -------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------- | | `GET /healthz` | Liveness — returns 200 if the app is responding. Custom JSON body `{"status":"ok"}`. | Load balancers, the Kamal proxy. | | `GET /up` | Rails' default health check — 200 if the app booted, 500 otherwise. | Heroku, Render, ECS, anything that expects the Rails default. | Wire your load balancer's health check at `/healthz` (the kit's default; see `proxy.healthcheck.path` in `config/deploy.yml`). Neither endpoint hits the database — they're cheap. Note: `config.silence_healthcheck_path = "/up"` in production silences `/up` from the logs but **not** `/healthz`. If your LB hammers `/healthz` and floods your logs, add it to the silence list too. ## Observability [#observability] The kit ships logs to STDOUT with `:request_id` tags — that's the floor. Add the layers you need: * **Error tracking.** Sentry, Honeybadger, Bugsnag — all install as a gem and a single initializer. Wire it before you have your first 500. * **APM / tracing.** Datadog, New Relic, OpenTelemetry — same shape. Particularly useful here because most of the auth latency is JWKS verification and Postgres round-trips, both of which trace nicely. * **Log aggregation.** Whatever ingests STDOUT works (Better Stack, Papertrail, Datadog Logs, etc.). Make sure you keep `request_id` end-to-end if you also instrument the client. ## CI [#ci] `bin/ci` runs the full quality gate locally — wire the same checks into your CI before letting a branch merge: ```sh bin/rubocop # style bin/brakeman -q # static security analysis bin/bundler-audit # vulnerable-gem audit bundle exec rspec # RSpec suite bin/rails test # Minitest infrastructure suite ``` Or run them all together with `bin/ci`. None of them need a running Supabase project — the spec suite uses an in-memory JWKS keyed by `SUPABASE_JWT_SECRET`, and the Minitest suite covers middleware in isolation. ## Production readiness checklist [#production-readiness-checklist] Walk this before your first cut to production: * [ ] All four `SUPABASE_*` and `DATABASE_URL` secrets set in the production environment. * [ ] `DATABASE_URL` uses the pooler URL (Supabase Cloud) or matches your Postgres connection-pooling story. * [ ] `CORS_ORIGINS` set explicitly — or confirmed empty because there's no browser client. * [ ] `config.force_ssl = true` and `config.assume_ssl = true` uncommented in `config/environments/production.rb`. * [ ] `SWAGGER_UI_ENABLED` decided — usually off in production for closed APIs. * [ ] `Rails.cache` points at a shared store (Solid Cache, Redis, Memcached) so `Rack::Attack` throttles count correctly across containers. * [ ] Health check path on the LB is `/healthz`. * [ ] Migrations run before traffic flips (Kamal `pre-deploy` hook or platform equivalent). * [ ] Error tracking and log aggregation wired up. * [ ] `bin/ci` is green in CI on every PR. When all of the above is true, the kit is ready for production traffic. Anything provider-specific (Fly/Render/Heroku/ECS/etc.) goes on top — the host concerns are local to that platform and not in scope for this guide. # Getting started (/reference/starterkits/api/getting-started) This guide takes you from a clean machine to a green test suite and a verified `GET /api/v1/me` round-trip. Budget 15 minutes if you already have Ruby and Docker installed; 30 if you don't. ## Prerequisites [#prerequisites] | Tool | Version | Why | | ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ruby | matches `.ruby-version` (Rails 8.1, currently 4.0.x) | App runtime. Install with `rbenv`, `asdf`, or `mise`. | | Bundler | 4.x | `gem install bundler` after Ruby is installed. | | Postgres | 14+ | Rails needs a local DB. The Supabase CLI bundles one — see below. | | Supabase CLI | 2.x | `brew install supabase/tap/supabase`, or [official install docs](https://supabase.com/docs/guides/local-development/cli/getting-started). Only needed for a fully local Supabase stack. | | Docker | recent | Required by `supabase start` and by the Kamal deploy. | | Git | any | For cloning. | You can skip Postgres if you let the Supabase CLI start one in Docker. You can skip the Supabase CLI if you point the kit at a Supabase Cloud project instead. ## 1. Get the code [#1-get-the-code] The starter is published as a standalone repo, not a generator — copy it, don't render it: ```sh git clone https://github.com/supabase-ruby/starter-kit-api my-api cd my-api ``` Then either commit it into a fresh repo of your own, or keep the upstream remote if you want to pull future starter updates. ## 2. Install gems [#2-install-gems] ```sh bundle install ``` The first install pulls a Rails 8 baseline plus `supabase-rails`, `rack-attack`, `rack-cors`, `rswag-*`, and the test stack (`rspec-rails`, `factory_bot_rails`). ## 3. Run the tests [#3-run-the-tests] Run the spec suite before any environment setup — it's hermetic. `spec/rails_helper.rb` seeds `SUPABASE_*` test ENV with a dummy JWT secret and an in-memory JWKS so signed tokens minted by `SupabaseAuthHelper` verify without network calls. ```sh bundle exec rspec # 9 examples, 0 failures ``` If you see failures here, stop and read [Troubleshooting → Spec suite won't start](/reference/starterkits/api/troubleshooting#spec-suite-wont-start) before going further — a broken hermetic suite usually means a bad Ruby/Bundler install, not a configuration problem. ## 4. Set environment variables [#4-set-environment-variables] The dev server needs real Supabase URLs and keys (the spec suite doesn't). Copy the template: ```sh cp .env.example .env ``` You need four values. Pick **A** or **B** depending on whether you want a fully local Supabase or a Cloud project. ### A. Local Supabase via the CLI [#a-local-supabase-via-the-cli] From the project root: ```sh supabase init # first time only — creates ./supabase supabase start # boots Postgres + GoTrue + Studio in Docker ``` The CLI prints something like: ``` API URL: http://127.0.0.1:54321 DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres anon key: eyJhbGciOi... JWT secret: super-secret-jwt-token-with-at-least-32-characters-long ``` Copy each into `.env`: | CLI key | `.env` variable | | ------------ | --------------------- | | `API URL` | `SUPABASE_URL` | | `anon key` | `SUPABASE_ANON_KEY` | | `JWT secret` | `SUPABASE_JWT_SECRET` | | `DB URL` | `DATABASE_URL` | ### B. Supabase Cloud [#b-supabase-cloud] If you already have a project at [supabase.com](https://supabase.com): | `.env` variable | Dashboard location | | --------------------- | -------------------------------------------------------------------- | | `SUPABASE_URL` | Project Settings → API → **Project URL** | | `SUPABASE_ANON_KEY` | Project Settings → API → **`anon` / `public` key** | | `SUPABASE_JWT_SECRET` | Project Settings → API → **JWT Settings → JWT Secret** | | `DATABASE_URL` | Project Settings → Database → **Connection string** (use the pooler) | The `.env` file is gitignored. `dotenv-rails` loads it automatically in `development` and `test`. **In production, `.env` is not read** — set the same variables through your deploy platform's secret manager (see [Deployment](/reference/starterkits/api/deployment)). ## 5. Prepare the database [#5-prepare-the-database] ```sh bin/rails db:prepare ``` Even though the kit has no domain models, Rails 8's Solid Cache / Queue / Cable schemas need to be loaded. ## 6. Boot the server [#6-boot-the-server] ```sh bin/rails s # Listening on http://127.0.0.1:3000 ``` ## 7. Make your first requests [#7-make-your-first-requests] Smoke-test the two public-shape endpoints: ```sh curl -i http://127.0.0.1:3000/healthz # HTTP/1.1 200 OK # {"status":"ok"} curl -i http://127.0.0.1:3000/api/v1/me # HTTP/1.1 401 Unauthorized # {"error":"unauthorized"} ``` The 401 is the right answer — there's no Bearer token on the request. To hit `/api/v1/me` successfully, you need a real Supabase JWT. The fastest way to get one in development is from `supabase-js` in a quick Node script: ```ts import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, ); const { data, error } = await supabase.auth.signInWithPassword({ email: "alice@example.com", password: "correct-horse-battery-staple", }); if (error) throw error; console.log(data.session!.access_token); ``` Then call the API with that token: ```sh TOKEN="eyJhbGciOi..." # paste the access_token here curl -i http://127.0.0.1:3000/api/v1/me \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" # HTTP/1.1 200 OK # {"id":"...","email":"alice@example.com","role":"authenticated", ...} ``` For local Supabase, create the user first via Studio (`http://127.0.0.1:54323` → Authentication → Users → "Add user"), or via `supabase-js`'s `signUp`. For a Cloud project, the same flow works against the real auth endpoint. ## 8. Open the API docs [#8-open-the-api-docs] ```sh open http://127.0.0.1:3000/api-docs ``` `rswag-ui` renders `swagger/v1/swagger.yaml`. The "Authorize" button there accepts a Bearer token so you can try `GET /api/v1/me` directly from the page. ## Next [#next] * [Project structure](/reference/starterkits/api/project-structure) — what each directory is for. * [Customization](/reference/starterkits/api/customization) — adding your own resource is the natural next step. # Rails API starter (/reference/starterkits/api) The Rails API starter is a thin, production-shaped Rails 8 backend for clients that already have a Supabase identity. Mobile apps, single-page apps, and other server-to-server callers sign in directly with Supabase, then call this API with `Authorization: Bearer `. The starter never mints its own sessions, never holds a cookie, never serves HTML — it verifies the JWT on every request through [`supabase-rails`](/reference/rails) middleware, populates `Current.user` from the verified claims, and returns JSON. ## What is it [#what-is-it] A `:api`-mode Rails 8 app pre-wired with the pieces every JSON API needs the day it ships: * **JWT auth** — `supabase-rails` middleware in front of `/api/*` verifies tokens against your project's JWKS. Invalid tokens never reach a controller. * **Rate limiting** — `Rack::Attack` runs ahead of JWT verification with two throttles (per-IP and per-token) on `/api/*`. * **CORS** — `Rack::Cors` driven by a `CORS_ORIGINS` env var; safe-by-default (no origins allowed in production until you set it). * **OpenAPI** — `rswag` integration specs are the source of truth for `swagger/v1/swagger.yaml`, served at `/api-docs` by `rswag-ui`. * **Tests** — RSpec request specs and rswag integration specs that mint real signed JWTs against an in-memory JWKS. * **Deploy** — `Dockerfile` and a Kamal `config/deploy.yml` for a single-command production push. ## Who it's for [#who-its-for] You should pick this kit when: * Your authenticated clients are **not** browsers asking the same Rails app to render HTML — they're a mobile app, an SPA, a CLI, or another backend. * You want Supabase Auth to own the identity surface (sign-up, email confirmation, OAuth, password reset, MFA) and your Rails app to focus on domain logic. * You'd rather verify a JWT on every request than carry a server-side session. If your frontend is a Rails view layer, look at the [Hotwire](/reference/starterkits/hotwire) or [Inertia + React](/reference/starterkits/inertia-react) starters instead — both run `supabase-rails` in `:web` mode with cookie sessions, which is the right shape for a server-rendered monolith. ## What's included [#whats-included] | Layer | What ships in the box | | ---------- | ------------------------------------------------------------------------------------------------------------ | | Auth | `supabase-rails` middleware, `Authentication` concern, `JsonUnauthorizedResponder` for canonical 401 bodies | | Endpoints | `GET /api/v1/me` (authenticated), `GET /healthz` (public liveness), `GET /up` (Rails default) | | Throttling | `Rack::Attack` with `api/ip` (300/5min) and `api/token` (1000/min) limiters | | CORS | `Rack::Cors` keyed off `CORS_ORIGINS`, off-by-default in production | | Tests | RSpec request specs, rswag integration specs, Minitest infrastructure tests, `SupabaseAuthHelper` JWT minter | | Docs | `swagger/v1/swagger.yaml`, `/api-docs` UI gated by `SWAGGER_UI_ENABLED` in production | | Deploy | `Dockerfile`, Kamal `config/deploy.yml`, `.kamal/secrets` template, Hetzner-shaped defaults | ## What's *not* included [#whats-not-included] The kit is deliberately small so you can grow it in whichever direction your product needs. It does **not** ship: * **A domain model.** There's no `User` AR record, no business-logic models, no migrations beyond Solid Cable/Cache/Queue schemas. `Current.user` is a value object built from JWT claims — opt into a host-app `users` table later with [`bin/rails generate supabase:user_model`](/reference/rails/generators). * **Server-rendered views or cookie sessions.** `config.api_only = true`, no `ActionDispatch::Cookies`, no CSRF tokens. If you need either, use the Hotwire or Inertia starters. * **A background job pipeline.** Solid Queue is configured but unused — the kit has no jobs. * **Per-target deploy guides.** `Dockerfile` and Kamal config are generic; turning them into a Fly/Render/Heroku flow is left to your platform. * **Multi-tenancy primitives.** No org/team scoping, no per-tenant Postgres schemas — bring your own. * **Observability.** Logs go to STDOUT, nothing else. Wire up your APM (Sentry, Datadog, OpenTelemetry) when you need it. ## Next [#next] ## Repository [#repository] * Source: [`supabase-ruby/starter-kit-api`](https://github.com/supabase-ruby/starter-kit-api) * Underlying gem: [`supabase-rails`](/reference/rails) # Project structure (/reference/starterkits/api/project-structure) The kit is a stock Rails 8 API-only app with a small surface of starter-kit-specific files. This page walks each top-level directory in the order you'll touch them. ## `app/` [#app] Everything custom to the kit lives here. The rest of `app/` (`jobs/`, `mailers/`, `views/layouts/mailer.*`) is the Rails generator default and can be ignored until you need a job or a mailer. ### `app/controllers/` [#appcontrollers] ``` app/controllers/ ├── application_controller.rb # ActionController::API + Authentication ├── healthz_controller.rb # public liveness probe ├── concerns/ │ └── authentication.rb # wraps Supabase::Rails::Authentication └── api/ └── v1/ └── me_controller.rb # GET /api/v1/me — returns verified claims ``` `ApplicationController` inherits from `ActionController::API` (no views, no cookies, no CSRF) and `include`s the `Authentication` concern from the same directory. The concern simply wraps `Supabase::Rails::Authentication` and overrides `request_authentication` so a missing token returns the canonical `{"error":"unauthorized"}` JSON body instead of the gem's default `head :unauthorized`. You'll also see `otp_controller.rb`, `passwords_controller.rb`, `sessions_controller.rb`, `registrations_controller.rb`, and `oauth_controller.rb` in `app/controllers/`. These are scaffolded by `supabase-rails`' install generator and are not wired into the kit's API routes — they're inert in `:api` mode. Leave them in place until you decide whether you want to expose any Supabase-side auth flows through Rails; delete them otherwise. ### `app/middleware/` [#appmiddleware] ``` app/middleware/ └── json_unauthorized_responder.rb ``` A single-purpose Rack responder: any 401 from a downstream middleware (in particular `Supabase::Rails::Middleware`, which emits `{message:, code:}`) gets its body rewritten to `{"error":"unauthorized"}`. It's inserted just outside the Supabase middleware so it sees the response on the way out. See [Architecture → Middleware stack](/reference/starterkits/api/architecture#middleware-stack) for where it sits in the chain. ### `app/models/` [#appmodels] ``` app/models/ ├── application_record.rb # ActiveRecord::Base └── current.rb # ActiveSupport::CurrentAttributes ``` `Current` declares `:user` and `:session` attributes. The `Supabase::Rails::Authentication` concern populates `Current.user` from the verified JWT claims on every request — there is no `User` ActiveRecord model in the kit. When you add your first AR model, generate it as usual (`bin/rails g model …`). If you later want a host-app `users` table that joins to `Current.user.id`, run [`bin/rails generate supabase:user_model`](/reference/rails/generators) — it ships a generator that writes the migration and reflection. ## `config/` [#config] Everything kit-specific lives in `config/initializers/`, `config/routes.rb`, and `config/application.rb`. The rest is Rails defaults. ### `config/application.rb` [#configapplicationrb] Two non-default bits worth knowing about: * `config.api_only = true` — the entire reason the cookie/CSRF/flash middleware isn't in the stack. * Two custom initializers, both `after: "supabase.middleware"`, manipulate the middleware stack: 1. `starter_kit.json_unauthorized_responder` — inserts `JsonUnauthorizedResponder` *before* `Supabase::Rails::Middleware` (outer side), so it sees the gem's 401s on the way out. 2. `starter_kit.rack_attack` — moves `Rack::Attack` ahead of `Supabase::Rails::Middleware` so throttled requests short-circuit before JWT verification spends a JWKS lookup. [Architecture](/reference/starterkits/api/architecture) draws the resulting middleware order. ### `config/routes.rb` [#configroutesrb] ```ruby mount Rswag::Ui::Engine => "/api-docs" # dev/test always, prod when SWAGGER_UI_ENABLED=true mount Rswag::Api::Engine => "/api-docs" supabase_authentication_routes # from supabase-rails — inert in :api mode namespace :api do namespace :v1 do get "me", to: "me#show" end end get "healthz", to: "healthz#show" get "up" => "rails/health#show" ``` `supabase_authentication_routes` expands to the Supabase-side sign-in/sign-up/OTP/OAuth route table from `supabase-rails`. It's harmless to keep; remove the line if you're certain you'll never expose those flows. ### `config/initializers/` [#configinitializers] ``` config/initializers/ ├── supabase.rb # mode = :api, auth strategies, env fail-fast ├── cors.rb # Rack::Cors driven by CORS_ORIGINS ├── rack_attack.rb # api/ip + api/token throttles ├── rswag_api.rb / rswag_ui.rb ├── filter_parameter_logging.rb └── inflections.rb ``` `supabase.rb` is the highest-leverage file in `config/`. It sets: * `config.supabase.mode = :api` — disables cookie session machinery in the gem. * `config.supabase.auth = %i[user none]` — try JWT auth first, fall through to anonymous so missing-token requests reach the controller and get the canonical 401 body. * A boot-time fail-fast in production if `SUPABASE_URL`, `SUPABASE_ANON_KEY`, or `SUPABASE_JWT_SECRET` is missing. * Maps `SUPABASE_ANON_KEY` onto the gem's expected `SUPABASE_PUBLISHABLE_KEY` so the apikey middleware works without a second env var. `rack_attack.rb` has two throttles — `api/ip` (300 req per 5 min) and `api/token` (1000 req per min) — both gated on `req.path.start_with?("/api/")`. The throttled responder returns `{"error":"too_many_requests"}` with a `Retry-After` header. `Rack::Attack.enabled = false` in test so request specs stay deterministic. `cors.rb` reads `CORS_ORIGINS`, defaults to `*` in development/test, and is **not mounted** if the var is empty in production — fail-closed. ## `db/` [#db] ``` db/ ├── seeds.rb # empty (no domain seeds) ├── cable_schema.rb # Solid Cable ├── cache_schema.rb # Solid Cache └── queue_schema.rb # Solid Queue ``` No `migrate/` directory and no `schema.rb` because the kit has no domain models. The three `*_schema.rb` files are loaded into the matching Postgres databases declared in `config/database.yml` (`primary`, `cache`, `queue`, `cable`) by `bin/rails db:prepare`. When you add your first domain model, Rails will generate `db/migrate/` and `db/schema.rb` (or `structure.sql`) as usual. ## `spec/` [#spec] ``` spec/ ├── spec_helper.rb ├── rails_helper.rb # seeds SUPABASE_* test ENV + in-memory JWKS ├── swagger_helper.rb # rswag DSL config — drives swagger/v1/swagger.yaml ├── requests/ # plain RSpec request specs │ ├── healthz_spec.rb │ └── me_spec.rb ├── integration/ # rswag integration specs — also the OpenAPI source │ ├── healthz_spec.rb │ └── me_spec.rb └── support/ └── supabase_auth_helper.rb # mints HS256 JWTs against the in-memory JWKS ``` The thing to internalise: **`spec/integration/` is dual-purpose**. The DSL there describes behaviour for RSpec *and* serializes to `swagger/v1/swagger.yaml` when you run `rake rswag:specs:swaggerize`. If you change a request/response shape, update the matching integration spec — the docs follow automatically. `spec/requests/` is plain RSpec request specs — same coverage, no DSL overhead. Use this style for the high-cardinality coverage you don't need in the OpenAPI doc (error edge cases, etc.). `spec/support/supabase_auth_helper.rb` exposes `auth_headers(claims = {})` to request specs. The companion in-memory JWKS keyed by `SUPABASE_JWT_SECRET` (configured in `rails_helper.rb`) means tokens minted in tests round-trip through real middleware — no stubbing. The kit also has a Minitest tree under `test/` covering CORS and Rack::Attack at the rack level. New work should go in `spec/`; the Minitest suite is kept around to exercise infrastructure that's awkward to drive from request specs. ## `swagger/` [#swagger] ``` swagger/ └── v1/ └── swagger.yaml ``` The OpenAPI 3 document served by `rswag-ui` at `/api-docs`. It's checked in so production can ship the docs without running the test suite at boot. Regenerate it with: ```sh RAILS_ENV=test bundle exec rake rswag:specs:swaggerize ``` (`RAILS_ENV=test` is required — `rswag-specs` is in the `:test` Bundler group, so the `swaggerize` task is only registered then.) `swagger_helper.rb` defines the top-level OpenAPI metadata: title, description, server URL template, and the `bearer_auth` security scheme. Edit it if you need to add another security scheme, change the server URL convention, or version the document. ## Other directories [#other-directories] | Directory | What it is | Touch it when | | -------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------- | | `bin/` | Rails 8 bin stubs + `bin/ci`, `bin/kamal`, `bin/rubocop`, `bin/brakeman`, `bin/bundler-audit` | Running quality gates or Kamal commands | | `lib/` | Empty `lib/tasks/` — for rake tasks you add | You add a rake task | | `public/` | Default Rails 8 error pages | You change error page styling | | `storage/`, `tmp/`, `log/` | Rails runtime data | Never directly | | `vendor/` | Vendored dependencies (rare) | You vendor a gem | | `test/` | Minitest suite for CORS + Rack::Attack | You break those middlewares | | `script/` | Project scripts | You add a one-off script | # Troubleshooting (/reference/starterkits/api/troubleshooting) A guide to the failures you're most likely to hit on the path from `git clone` to a working request. Each section names the symptom you'll see and the fix. ## "Missing required Supabase environment variable(s)" on boot [#missing-required-supabase-environment-variables-on-boot] The initializer in `config/initializers/supabase.rb` fails fast if `SUPABASE_URL`, `SUPABASE_ANON_KEY`, or `SUPABASE_JWT_SECRET` is missing **in production**. In development it warns and continues. * **In dev:** copy `.env.example` to `.env` and fill in the four Supabase values (see [Getting started](/reference/starterkits/api/getting-started#4-set-environment-variables)). Restart `bin/rails s` — `dotenv-rails` reads `.env` at boot, not at request time. * **In production:** set the same three vars through your deploy platform's secret manager. `.env` is not loaded in production. The error message names the missing variable(s) explicitly. ## Spec suite won't start [#spec-suite-wont-start] ``` LoadError: cannot load such file -- jwt ``` — `bundle install` didn't finish or you're using the wrong Ruby. Re-run `bundle install` after confirming `ruby --version` matches `.ruby-version` (4.0.x for Rails 8.1). ``` ActiveRecord::NoDatabaseError ``` — `db:test:prepare` hasn't run. `bundle exec rspec` will trigger it automatically; if it fails, run `bin/rails db:prepare RAILS_ENV=test` and check Postgres is up. If the suite hangs or makes network calls, you've broken the in-memory JWKS — most likely by editing `spec/rails_helper.rb` and removing the `ENV['SUPABASE_JWKS']` setup. Restore it; the in-memory JWKS is what makes the suite hermetic. ## `GET /api/v1/me` returns 401 with a valid-looking token [#get-apiv1me-returns-401-with-a-valid-looking-token] The middleware accepts a JWT only if signature, audience, issuer, and expiry all check out. The body shape (`{"error":"unauthorized"}`) doesn't tell you which one failed, so add `RAILS_LOG_LEVEL=debug` and look at the log line from `Supabase::Rails::Middleware`. Common causes: * **Wrong `SUPABASE_JWT_SECRET`.** Local CLI prints a long static secret; Cloud projects have a per-project one in **Project Settings → API → JWT Settings**. Production env must match production JWTs. * **Wrong project.** If `SUPABASE_URL` points at one project and the token was minted by another, the `iss` claim won't match and verification fails. * **Expired token.** `supabase-js` refreshes automatically; a hand-copied token from a debug script expires in an hour. * **Old anon-key format.** A JWT-shaped `eyJ...` anon key works as both `SUPABASE_ANON_KEY` and (effectively) `SUPABASE_PUBLISHABLE_KEY`. New `sb_publishable_...` keys are not JWTs — the kit handles either, but if you mixed an old project's secret with a new project's anon key, the validation can be inconsistent. ## `GET /api/v1/me` returns 401 with no token at all (expected) — but the body is wrong [#get-apiv1me-returns-401-with-no-token-at-all-expected--but-the-body-is-wrong] If you see `{"message":"...","code":"..."}` instead of `{"error":"unauthorized"}`, the `JsonUnauthorizedResponder` middleware isn't in the stack. Two likely causes: * You edited `config/application.rb` and broke the `starter_kit.json_unauthorized_responder` initializer. Confirm it's still there and references `Supabase::Rails::Middleware` (which only exists *after* the gem's own initializer, hence `after: "supabase.middleware"`). * You removed the `JsonUnauthorizedResponder` Rack class from `app/middleware/`. The class needs to exist on disk and be autoload-resolvable — `config.autoload_paths << Rails.root.join("app/middleware")` in `config/application.rb` is what makes that work. Run `bin/rails middleware` to see the live stack. `JsonUnauthorizedResponder` should appear immediately above `Supabase::Rails::Middleware`. ## "CORS error" in the browser, but `curl` works [#cors-error-in-the-browser-but-curl-works] `Rack::Cors` only mounts if `CORS_ORIGINS` is set (or you're in dev/test). In production with `CORS_ORIGINS` empty, the middleware doesn't mount **at all** and every browser request gets blocked by the browser before it reaches Rails. Symptoms: * Browser dev tools show "Access to fetch at … from origin … has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header". * `curl` works fine because curl doesn't enforce CORS. Fix: set `CORS_ORIGINS=https://your-frontend.example.com` in the deploy environment and redeploy. Multiple origins go comma-separated. Don't use `*` in production unless you've thought about it. ## Postgres connection refused [#postgres-connection-refused] ``` PG::ConnectionBad: connection to server ... failed: Connection refused ``` * **Local CLI:** the Supabase CLI's Postgres isn't running. `supabase start` it. The CLI prints the right `DATABASE_URL` — copy it into `.env` exactly (port `54322`, not the default `5432`). * **Local non-CLI:** your host Postgres isn't listening. Start it (`brew services start postgresql`, `pg_ctl`, etc.) and confirm the user/password match `config/database.yml` or `DATABASE_URL`. * **Production / Supabase Cloud:** make sure `DATABASE_URL` points at the **pooler** URL (port 6543), not the direct connection (5432). The direct connection is rate-limited and not meant for app traffic. ## `/api-docs` returns 404 in production [#api-docs-returns-404-in-production] By default, `rswag-ui` is only mounted in production if `SWAGGER_UI_ENABLED=true`. The route table in `config/routes.rb` short-circuits the mount when both conditions are false. Set the env var to `true` and redeploy. If you've set it and still get 404, the YAML file at `swagger/v1/swagger.yaml` may be missing from the production build. Confirm `swagger/` is checked in (it should be) and not in `.dockerignore`. ## `swagger/v1/swagger.yaml` is stale after a change [#swaggerv1swaggeryaml-is-stale-after-a-change] The OpenAPI doc is regenerated from `spec/integration/` specs only on demand: ```sh RAILS_ENV=test bundle exec rake rswag:specs:swaggerize ``` The `RAILS_ENV=test` is required — `rswag-specs` is in the `:test` Bundler group, so the task isn't registered in development. Commit the regenerated YAML. If the task says `Task 'rswag:specs:swaggerize' not found`, you forgot the `RAILS_ENV=test`. If it generates but the doc doesn't show your changes, the spec under `spec/integration/` doesn't actually describe them — `rswag` only serializes what the DSL declares. ## `Rack::Attack` is throttling me in development [#rackattack-is-throttling-me-in-development] It shouldn't be — `Rack::Attack.enabled = false` is set in the test env (`config/initializers/rack_attack.rb`). Development has the throttles on, with limits that are generous for a single user (300 req per 5 min per IP). If you're getting throttled while iterating, either bump the limits in that initializer or temporarily set `Rack::Attack.enabled = false` in `development.rb`. ## Rate limit isn't enforced in production [#rate-limit-isnt-enforced-in-production] You set `Rack::Attack` to count per-IP, but the limit doesn't kick in. Almost always: `Rails.cache` is process-local in production, so each Puma worker has its own counter. Confirm `config.cache_store` in `production.rb` is `:solid_cache_store`, `:redis_cache_store`, or `:mem_cache_store` — anything **shared**. The kit defaults to `:solid_cache_store` and it works out of the box, but a deploy that swapped it for `:memory_store` will silently fail-open. ## Boot warning: `apikey` middleware not configured [#boot-warning-apikey-middleware-not-configured] `Supabase::Rails::Middleware` looks for `SUPABASE_PUBLISHABLE_KEY`. The kit's `config/initializers/supabase.rb` maps `SUPABASE_ANON_KEY` onto it at boot: ```ruby ENV["SUPABASE_PUBLISHABLE_KEY"] ||= ENV["SUPABASE_ANON_KEY"] ``` If you removed that line or both vars are empty, the apikey middleware won't recognise the inbound `apikey` header. Set `SUPABASE_ANON_KEY` (the canonical kit env var) and the mapping does the rest. Newer Supabase projects label the same value `sb_publishable_...`; either format works. ## Health check is failing the LB [#health-check-is-failing-the-lb] The Kamal proxy (and most LBs) hit `GET /healthz`. The controller returns `{"status":"ok"}` with 200 and doesn't touch the database. If it's failing: * **404:** check `config/routes.rb` still has `get "healthz", to: "healthz#show"`. * **500:** read the Rails log — `HealthzController#show` is two lines, so a 500 usually means a Rack-level boot error. `bin/rails middleware` will tell you if the stack is broken. * **401:** you accidentally removed `allow_unauthenticated_access only: :show` from `HealthzController`. Add it back; without it, the `Authentication` concern will demand a token. # Architecture (/reference/starterkits/hotwire/architecture) The starter is a Rails 8.1 monolith — most of the auth machinery lives in [`supabase-rails`](/reference/rails) in `:web` mode. This page covers the integration points the kit relies on so you can reason about (and safely modify) the request lifecycle. ## High-level shape [#high-level-shape] ``` Browser ─► Rails (Hotwire HTML) │ │ │ ├─ Supabase::Rails::Middleware │ │ ↳ reads encrypted sb-session cookie │ │ ↳ refreshes Supabase access token if needed │ │ ↳ populates Current.user + request.env[CONTEXT_KEY] │ │ │ └─ Controller ─► per-request supabase client (RLS-scoped) │ │ │ ▼ │ Supabase Postgres (PostgREST) │ └─► Supabase Auth (sign-up, OAuth, password reset) ↳ redirects back through Rails so the gem can mint the cookie ``` The browser never holds a token. The kit holds the encrypted `sb-session` cookie; the *cookie* contains the Supabase access token, which the gem unwraps server-side and uses to make per-request, RLS-scoped Supabase calls. ## Cookie session lifecycle [#cookie-session-lifecycle] The `Supabase::Rails::Middleware` (installed by `config.supabase.mode = :web`) participates in every request, even unauthenticated ones: 1. **On request in.** The middleware reads the encrypted `sb-session` cookie. If present and decryptable, it loads the stored Supabase access + refresh tokens, checks expiry, and refreshes the access token if needed (asking the Supabase token endpoint for a new pair). 2. **Populates `Current`.** Verified claims become a `Supabase::Rails::User` value object on `Current.user`. The session itself goes on `Current.session`. A per-request `Supabase::Client` (its `Authorization` header set to the unwrapped access token) is stashed in `request.env[Supabase::Rails::CONTEXT_KEY]` for controllers to use. 3. **On the way out.** If the access token was refreshed mid-request, the middleware rewrites the `sb-session` cookie with the new ciphertext. The cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` when behind TLS. If decryption fails (key rotation, tampered cookie) or the refresh round-trip fails (revoked session, expired refresh token), the middleware drops `Current.user` to `nil`. The `Authentication` concern's `request_authentication` then redirects to `/session/new` — and the `ExpiredSessionFlash` override in `app/controllers/concerns/authentication.rb` attaches a `"Your session has expired"` flash, but only when there *was* a cookie on the request (so a fresh visitor never sees the flash). The kit's `SessionsController#destroy` calls `terminate_session` (a gem helper that clears the `sb-session` cookie and `Current.*`) and redirects to `/welcome` rather than `/`, so a signed-out user lands on an explicit public landing page with log-in / register CTAs. ## Per-request Supabase client [#per-request-supabase-client] The gem doesn't just verify the user — it also gives the controller a Supabase client preconfigured with that user's access token. The pattern is used directly by `NotesController`: ```ruby class NotesController < ApplicationController def index response = current_supabase_client.from("notes").select("id,content,created_at").execute @notes = response.data || [] end private def current_supabase_client request.env[Supabase::Rails::CONTEXT_KEY].supabase end end ``` The client returned by `request.env[Supabase::Rails::CONTEXT_KEY].supabase` carries an `Authorization: Bearer ` header. Every PostgREST call made through that client is **RLS-scoped to `Current.user`** — Postgres sees the request as coming from that user (because `auth.uid()` in policies is read from the JWT), and the row-level policies on `public.notes` filter reads, inserts, updates, and deletes accordingly. This is the invariant the kit's e2e tests verify: two browsers signed in as different users, hitting the same `/notes` index, see disjoint lists; an `update` against another user's row returns zero affected rows ("Note not found"); a `delete` likewise short-circuits. ### Why route writes through PostgREST instead of ActiveRecord [#why-route-writes-through-postgrest-instead-of-activerecord] `NotesController#update` could have used `ActiveRecord` with a Postgres adapter pointed at Supabase. The kit chooses PostgREST through the per-request client because: * **RLS works automatically.** AR runs against a Postgres role that bypasses RLS (or that you'd have to set per-request with `SET LOCAL "request.jwt.claims"`). PostgREST embeds the JWT as the request identity, so policies fire. * **No shadow schema.** No `db/migrate/`, no `db/schema.rb`, no migration drift between Rails and Supabase. Supabase Postgres is the source of truth; `supabase/migrations/` is the migration toolchain. * **Same wire path as the rest of Supabase.** Realtime, Storage, and the JS client all hit PostgREST — keeping your Rails app on the same path means RLS policies are written once. When you need full ActiveRecord features (joins, `belongs_to`, eager loading) for a domain table, the kit doesn't force a choice: add a Postgres connection to `config/database.yml` keyed on the Rails service role, or generate [`bin/rails generate supabase:user_model`](/reference/rails/generators) for the join pattern. ## Hotwire integration [#hotwire-integration] The Hotwire pieces are wired in the standard Rails 8 way — `turbo-rails` and `stimulus-rails` in the Gemfile, Importmap pins in `config/importmap.rb`, and `app/javascript/application.js` importing `@hotwired/turbo-rails` plus the Stimulus controller bundle. ### Turbo Drive [#turbo-drive] Turbo Drive intercepts every `` and `
` and swaps the `` over an XHR fetch. The kit's only deliberate interaction with Turbo Drive is `stale_when_importmap_changes` in `ApplicationController`, which busts the HTML etag whenever an Importmap pin changes — so the browser pulls a fresh shell after a deploy that touches `importmap.rb`. ### Turbo Frames and Streams [#turbo-frames-and-streams] The kit's views don't use Frames or Streams yet — the Notes view is a single-page list. They're available the moment you reach for them: every controller can `render turbo_stream:`, and `` elements work out of the box. Recipe 1 in [Customization](/reference/starterkits/hotwire/customization#recipe-1--add-an-inline-turbo-frame-for-editing-notes) walks through adding an inline-edit Frame to `notes#index`. ### Stimulus [#stimulus] Stimulus is loaded via the eager controller registration pattern. `app/javascript/controllers/index.js` calls `eagerLoadControllersFrom("controllers", application)` so any new `*_controller.js` file is auto-registered with a tag matching the filename — drop `app/javascript/controllers/realtime_notes_controller.js` and `data-controller="realtime-notes"` works. The shipping `hello_controller.js` is a Rails default — feel free to delete it. The application layout already references `data-controller="sidebar"`; you supply the matching `sidebar_controller.js` when you customise the mobile chrome (or rely on CSS-only toggling and remove the data-controller attribute). ## Supabase integration points [#supabase-integration-points] The starter wires up four distinct things from `supabase-rails`. ### 1. Mode [#1-mode] `config.supabase.mode = :web` (in `config/initializers/supabase.rb`) installs the cookie-session machinery. The gem mounts: * `Web::CookieCredentialStrategy` — reads/writes the `sb-session` cookie and manages refresh. * `Supabase::Rails::SessionStore` — the encrypted-cookie session store backing the strategy. * The full `supabase_authentication_routes` table — sessions, registrations, passwords, OTP, OAuth. If you ever want JWT-only auth with no cookies, switch to `:api` mode — but the Rails API starter is built around it and is the easier starting point. ### 2. Auth strategies [#2-auth-strategies] The default `:web`-mode strategy chain (cookie → none) is what the kit relies on. `allow_unauthenticated_access` whitelists actions; everything else demands a session. `HomeController#index` is the example to study — `allow_unauthenticated_access only: :index, unless: -> { request.path == dashboard_path }` says "let the public `/` variant through, but require auth when the same action is mounted at `/dashboard`." ### 3. View overrides [#3-view-overrides] `supabase-rails` ships default ERB views for sign-in, sign-up, password reset, OTP, and the OAuth buttons. The kit overrides every one of them in `app/views/supabase/rails/`. Rails view inheritance resolves the kit's copy first, so the gem's defaults never render — you get Tailwind, ViewComponent, and Railsblocks-styled forms instead. When you upgrade the gem, the gem's view changes don't auto-merge into your overrides. Diff the gem's `supabase/rails/sessions/new.html.erb` against yours and port any new fields by hand. ### 4. Account updates [#4-account-updates] `Settings::ProfilesController#update` calls `supabase_update_user(...)` — a controller-side helper exposed by the gem that wraps Supabase's `auth.updateUser` endpoint. The same machinery handles email + display-name changes. It returns a `Result` object the controller pattern-matches against to render the flash. No AR `User` model means no `params.permit :email, :display_name` against a Rails model — the form posts directly to Supabase Auth via the gem. ## What lives outside the kit [#what-lives-outside-the-kit] A few things look like they belong to the starter but are actually contracts owned elsewhere: * **Email delivery.** Supabase Auth sends confirmation, password-reset, magic-link, and OAuth completion emails. The kit's `app/mailers/` is empty. In development, the kit ships `letter_opener_web` on `/letter_opener` so you can read what Supabase would have sent (when configured to use the dev SMTP relay). * **The user record.** Supabase Auth owns `auth.users`. The kit doesn't have a `users` table. `Current.user.id` is the Supabase UUID — use it as the foreign key for your own tables (see Recipe 1 in [Customization](/reference/starterkits/hotwire/customization)). * **OAuth providers.** GitHub OAuth is configured in the Supabase dashboard, not in Rails. The "Continue with GitHub" button on `sessions/new` and `registrations/new` just kicks off the Supabase OAuth flow. * **The session token format.** Supabase mints the access/refresh JWTs. The kit handles the cookie wrapping but not the signing — `SUPABASE_JWT_SECRET` (if you swap to API-style JWT verification later) is the secret you share with Supabase. ## Why no `User` model [#why-no-user-model] Same trade-off as the API kit: * **No AR `User`** — what the kit does. `Current.user` is the verified session. Zero local user state, zero sync drift between Supabase and Rails. Domain models foreign-key directly to `Current.user.id` (a UUID). * **AR `User`** — what `bin/rails generate supabase:user_model` gives you. A `users` table keyed by `id = Current.user.id`, populated lazily or by webhook. You get `belongs_to :user`, eager loading, reflections — at the cost of keeping the table in sync with Supabase Auth. The kit ships without it because most Hotwire apps don't need it on day one. Add it the day you have a model that needs `belongs_to :user` or a profile field you don't want to store as Supabase user metadata. # Customization (/reference/starterkits/hotwire/customization) The kit is small on purpose. These three recipes cover the extensions you'll reach for first, and they're shaped around Hotwire — Turbo Frames, Stimulus, ViewComponent — rather than around generic Rails patterns. ## Recipe 1 — Add an inline Turbo Frame for editing notes [#recipe-1--add-an-inline-turbo-frame-for-editing-notes] The shipping `/notes` view is a read-only list. Let's add inline editing through a Turbo Frame: clicking a note swaps it for a form, submitting the form swaps it back for the updated text — no full page nav, no Stimulus. ### 1. Wrap each note in a Turbo Frame [#1-wrap-each-note-in-a-turbo-frame] ```erb <%# app/views/notes/index.html.erb %> <% if @notes.any? %>
    <% @notes.each do |note| %> <%= turbo_frame_tag dom_id(note, :frame), class: "block" do %>
  • <%= note["content"] %> <%= link_to "Edit", edit_note_path(note["id"]), class: "ml-2 text-sm text-blue-600 hover:underline" %>
  • <% end %> <% end %>
<% end %> ``` `dom_id(note, :frame)` produces a deterministic id like `note__frame`. Turbo intercepts the `Edit` link and replaces the Frame's contents with whatever the response renders inside a matching ``. ### 2. Add the route + controller action [#2-add-the-route--controller-action] ```ruby # config/routes.rb resources :notes, only: %i[index update destroy] do get :edit, on: :member end ``` ```ruby # app/controllers/notes_controller.rb (add this) def edit response = current_supabase_client.from("notes").select("id,content").eq("id", params[:id]).execute @note = (response.data || []).first redirect_to notes_path, alert: NOT_FOUND_MESSAGE if @note.nil? end ``` ### 3. Render the form inside a matching Frame [#3-render-the-form-inside-a-matching-frame] ```erb <%# app/views/notes/edit.html.erb %> <%= turbo_frame_tag dom_id(@note, :frame) do %> <%= form_with url: note_path(@note["id"]), method: :patch, class: "flex gap-2" do |f| %> <%= f.text_field "note[content]", value: @note["content"], class: "flex-1 rounded-md border border-zinc-300 p-2 dark:border-zinc-700 dark:bg-zinc-900" %> <%= f.submit "Save", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white" %> <%= link_to "Cancel", notes_path, class: "rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium" %> <% end %> <% end %> ``` ### 4. Re-render the list item from `#update` [#4-re-render-the-list-item-from-update] The existing `NotesController#update` redirects to `notes_path` after a successful write. For a Turbo Frame round-trip you want to render *just* the updated `` instead: ```ruby # app/controllers/notes_controller.rb (replace the update action) def update response = current_supabase_client .from("notes") .update({ content: params.expect(note: [ :content ])[:content] }) .eq("id", params[:id]) .select("id,content,created_at") .execute @note = Array(response.data).first if @note.nil? redirect_to notes_path, alert: NOT_FOUND_MESSAGE else render :show_frame # renders app/views/notes/show_frame.html.erb end end ``` ```erb <%# app/views/notes/show_frame.html.erb %> <%= turbo_frame_tag dom_id(@note, :frame) do %>
  • <%= @note["content"] %> <%= link_to "Edit", edit_note_path(@note["id"]), class: "ml-2 text-sm text-blue-600 hover:underline" %>
  • <% end %> ``` Now clicking **Edit** swaps in the form, submitting swaps in the new content, and the rest of the page stays put. The whole exchange is **still RLS-scoped** — `current_supabase_client.from("notes").update(...).eq("id", params[:id])` will return zero rows if the note doesn't belong to `Current.user`, and the `redirect_to notes_path, alert: NOT_FOUND_MESSAGE` branch handles the cross-user case the same way it does for the non-Frame path. ## Recipe 2 — Add a Stimulus controller wired to Supabase Realtime [#recipe-2--add-a-stimulus-controller-wired-to-supabase-realtime] Let's make the `/notes` list live-update when another tab (or another device) inserts a row. We'll add a Stimulus controller that opens a Supabase Realtime channel on the `public.notes` table and uses Turbo Stream's `` to prepend new rows. ### 1. Pin the Supabase JS client in Importmap [#1-pin-the-supabase-js-client-in-importmap] ```sh bin/importmap pin @supabase/supabase-js ``` This adds a pin to `config/importmap.rb`. The first run downloads the package and writes it under `vendor/javascript/` — Importmap doesn't need Node, but it does cache the file locally so production isn't hitting jsdelivr at runtime. ### 2. Expose the anon key to the browser [#2-expose-the-anon-key-to-the-browser] Realtime auth uses the anon key to bootstrap the WebSocket connection, then the user's JWT for RLS. The anon key is public; expose it via a `` tag in `layouts/_head.html.erb`: ```erb <%# app/views/layouts/_head.html.erb (add inside ) %> ``` The access token is harder — it lives inside the encrypted cookie, not on the page. The simplest approach is to render it server-side into a `data-` attribute on the page that needs Realtime: ```erb <%# app/views/notes/index.html.erb (top of the file) %>
    ``` `Current.session.access_token` is the verified access token the gem unwrapped from `sb-session`. It's safe to embed on a page that's already gated to the signed-in user — same threat surface as a cookie. ### 3. Add the Stimulus controller [#3-add-the-stimulus-controller] ```js // app/javascript/controllers/realtime_notes_controller.js import { Controller } from "@hotwired/stimulus" import { createClient } from "@supabase/supabase-js" export default class extends Controller { static values = { token: String } connect() { const url = document.querySelector('meta[name="supabase-url"]').content const anonKey = document.querySelector('meta[name="supabase-anon"]').content this.client = createClient(url, anonKey, { global: { headers: { Authorization: `Bearer ${this.tokenValue}` } }, realtime: { params: { eventsPerSecond: 5 } } }) this.client.realtime.setAuth(this.tokenValue) this.channel = this.client .channel("notes-feed") .on("postgres_changes", { event: "INSERT", schema: "public", table: "notes" }, (payload) => this.handleInsert(payload.new)) .subscribe() } disconnect() { if (this.channel) this.client.removeChannel(this.channel) } handleInsert(note) { const list = document.querySelector('[data-test="notes-list"]') if (!list) return const li = document.createElement("li") li.dataset.test = "note-item" li.className = "rounded-md border border-zinc-200 p-3 dark:border-zinc-700" li.textContent = note.content list.prepend(li) } } ``` Two things make this RLS-safe: * `createClient` is initialised with the **user's access token** in the `Authorization` header. Realtime postgres\_changes events fired by Postgres run through the same RLS policies as PostgREST — the client only sees inserts that the user is permitted to read. * `client.realtime.setAuth(this.tokenValue)` reinforces the auth for the WebSocket transport. Without it the channel falls back to the anon role and you'd see *other users'* inserts (which the RLS policy on `public.notes` correctly blocks anyway, but it's clearer to set auth up front). The controller auto-registers because `eagerLoadControllersFrom` in `app/javascript/controllers/index.js` picks up any `*_controller.js`. No further wiring. ### 4. Enable Realtime on the table [#4-enable-realtime-on-the-table] In the Supabase dashboard → **Database → Replication**, toggle the `notes` table on. Or via SQL: ```sql alter publication supabase_realtime add table public.notes; ``` After that, an insert from any signed-in browser will fan out to every connected client whose JWT lets them read it. ### Where to take this next [#where-to-take-this-next] * Replace the imperative DOM manipulation with `Turbo.renderStreamMessage("...")` if you want the new row to come back from the server (so it can use the same partial as the index view). * Cap the access-token's lifetime on the page — when it expires (default 1 hour), the channel's `setAuth` call will start failing. Add a Stimulus disconnect/reconnect on `turbo:before-cache` and re-read a fresh token from a server-side endpoint that returns the current session's access token. * Add a presence channel for "who's looking at this note" UX. ## Recipe 3 — Add a ViewComponent and wire it into the sidebar [#recipe-3--add-a-viewcomponent-and-wire-it-into-the-sidebar] The kit uses ViewComponent for every UI primitive — adding a new one is the natural way to extend the chrome. Let's add a `BadgeComponent` for unread-count indicators and wire it into the sidebar. ### 1. Generate the component [#1-generate-the-component] ```sh bin/rails generate component Badge label count ``` That writes `app/components/badge_component.rb` and `app/components/badge_component.html.erb`. Edit them: ```ruby # app/components/badge_component.rb class BadgeComponent < ApplicationComponent def initialize(label:, count: nil) @label = label @count = count end def show_count? @count.is_a?(Integer) && @count.positive? end end ``` ```erb <%# app/components/badge_component.html.erb %> <%= @label %> <% if show_count? %> <%= @count %> <% end %> ``` Inheriting from `ApplicationComponent` (rather than `ViewComponent::Base`) gives the template the kit's `icon` helper for free if you later want to add a Lucide icon to the badge. ### 2. Pass an unread-count into `SidebarComponent` [#2-pass-an-unread-count-into-sidebarcomponent] ```ruby # app/components/sidebar_component.rb (modify nav_items) def nav_items [ { label: "Dashboard", href: helpers.dashboard_path, icon: "layout-grid" }, { label: "Notes", href: helpers.notes_path, icon: "sticky-note", badge: notes_badge }, { label: "Settings", href: "/settings/profile", icon: "settings" } ] end private def notes_badge # Replace with a real count — e.g. cache an unread count in Current.user metadata, # or read from a per-user view. The signature is the BadgeComponent constructor. BadgeComponent.new(label: "new", count: @user&.app_metadata&.dig("unread_notes")) end ``` ### 3. Render the badge in the sidebar template [#3-render-the-badge-in-the-sidebar-template] ```erb <%# app/components/sidebar_component.html.erb — wherever the nav item is rendered %> <%= link_to item[:href], class: link_classes(active: current?(item[:href])), data: { test: "sidebar-#{item[:label].downcase}" } do %> <%= icon item[:icon], class: "size-4" %> <%= item[:label] %> <% if item[:badge] %> <%= render item[:badge] %> <% end %> <% end %> ``` The chrome now picks up a coloured badge for any nav item with a `:badge` key. Add a `notes_badge_component_test.rb` under `test/components/` if you want unit coverage — `render_inline` from `view_component/test_helpers` is the canonical entrypoint. ### When to reach for a ViewComponent vs a partial [#when-to-reach-for-a-viewcomponent-vs-a-partial] | Need | Reach for | | --------------------------------------------------------------------- | ---------------------------------------------- | | Reusable primitive with state, helper methods, or unit tests | ViewComponent | | One-off chunk of markup inside a view | Partial | | Frame-level Turbo content that changes per-action | Partial in a `` | | Stimulus-attached widget with both server- and client-rendered states | ViewComponent that emits `data-controller="…"` | The kit's existing components (`Button`, `Avatar`, `UserMenu`, `Sidebar`) are all in the first row — make new primitives by following the same pattern. # Deployment (/reference/starterkits/hotwire/deployment) This page covers the production-readiness concerns that aren't specific to your hosting provider. The kit ships a `Dockerfile` and a `config/deploy.yml` shaped for Kamal, but the same concerns apply if you're running on Fly, Render, Heroku, ECS, or anywhere else that boots a Linux container. This page is intentionally generic. The kit doesn't tie you to a particular host — Fly, Render, Heroku, Kamal-on-Hetzner, and ECS each have their own opinions about Dockerfiles, secrets, and health checks. The points below are the ones you have to think about regardless of which one you pick. ## Secrets [#secrets] Set these in your platform's secret manager. The kit's `.env.example` is for local development — Rails doesn't auto-load `.env` even in development (you export with `direnv`, `dotenv`, or your process manager), and in production a `.env` file should never ship. | Variable | Required? | Set to | | ------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_URL` | required | Your project URL (e.g. `https://abc.supabase.co`). The gem reads it at boot. | | `SUPABASE_ANON_KEY` | required | The anon / publishable key from the Supabase dashboard. Used by the gem for user-scoped requests (sign-in, sign-up, password reset, OAuth). | | `SUPABASE_SERVICE_ROLE_KEY` | required for account deletion / admin calls | The `service_role` key. Server-only — never expose to browsers. | | `RAILS_MASTER_KEY` | required | Decrypts `config/credentials.yml.enc`. Generate with `bin/rails credentials:edit` if you don't already have one. | | `RAILS_ENV` | required | `production`. | | `RAILS_LOG_LEVEL` | optional | `info` (default) or `debug` while bringing up a deploy. | | `PORT`, `WEB_CONCURRENCY`, `RAILS_MAX_THREADS` | optional | Puma tuning — defaults in `config/puma.rb`. `RAILS_MAX_THREADS` doubles as the connection-pool size in `config/database.yml`. | | `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET` | documentation-only | These are pasted into the Supabase dashboard, not read by Rails — they live in `.env.example` to make sharing values across environments easy. | The kit doesn't fail-fast on missing Supabase env vars in production — `supabase-rails` will surface its own actionable error message at first request if the URL or anon key is missing. If you want a louder boot-time check, add one to `config/initializers/supabase.rb`. ## Database [#database] The kit defaults to **SQLite** for all four roles — `primary` (app data), `cache` (Solid Cache), `queue` (Solid Queue), `cable` (Solid Cable). For a single-server Kamal deploy with persistent disk, that's a perfectly viable production posture; for multi-server deploys, you'll want Postgres for `primary` (and probably for the Solid roles too). Choices to make: * **SQLite (default).** Mount `storage/` as a persistent Docker volume so the SQLite files survive container restarts. `config/deploy.yml` declares this volume. Back it up the way you'd back up any single-server datastore (LiteFS, Litestream, or filesystem snapshots). * **Postgres.** Swap `adapter: sqlite3` for `adapter: postgresql` in `config/database.yml`, add `gem "pg"` to the Gemfile, and set `DATABASE_URL`. For Solid Cache/Queue/Cable on Postgres, point each role at the same database with `migrations_paths:` separating them — or run a separate Postgres for each, depending on traffic. * **Domain data vs Supabase Postgres.** Remember the kit's split: `auth.users` and `public.notes` live in **Supabase Postgres**, not in the Rails-owned DB. Your AR models live in `primary` (SQLite or Postgres of your choice). Per-request RLS-scoped calls keep going to Supabase via PostgREST. * **Migrations.** Run migrations once per deploy, before the new image takes traffic. With Kamal, add a `pre-deploy` hook in `config/deploy.yml`. With most other platforms, a "release" or "pre-deploy" hook is the right place. ## Sessions [#sessions] The encrypted `sb-session` cookie is signed and encrypted with Rails' message verifier. The key comes from `Rails.application.secret_key_base`, which the gem reads at boot. Two things to confirm before going live: * **`secret_key_base` is stable across deploys.** It's read from `config/credentials.yml.enc` (decrypted with `RAILS_MASTER_KEY`) or directly from the `SECRET_KEY_BASE` env var. If it rotates between deploys, **every signed-in user is signed out at the rotation** — their old `sb-session` cookies become undecryptable. * **Cookie domain.** The kit relies on the default — the cookie scopes to the request host. If you serve the app from multiple subdomains, set `Rails.application.config.supabase.session = { ..., domain: ".example.com" }` in `config/initializers/supabase.rb` and the cookie will work across them. ## TLS [#tls] The kit's `Dockerfile` exposes port 3000, and `config/deploy.yml`'s `proxy.app_port: 3000` forwards from the Kamal proxy. Two settings to flip in production: ```ruby # config/environments/production.rb config.assume_ssl = true # we're behind a TLS-terminating proxy config.force_ssl = true # redirect HTTP → HTTPS and use Strict-Transport-Security ``` Both are commented out by default — uncomment them once your TLS terminator is live. With Kamal, that's the moment you flip `proxy.ssl: true` in `config/deploy.yml` (after the DNS A/AAAA record points at the host so Let's Encrypt can issue a cert). The `sb-session` cookie ships with `Secure: nil` (auto-detect) in the gem's defaults — once `assume_ssl = true`, the cookie is marked `Secure` automatically and never leaks over HTTP. If you're behind a load balancer or CDN (Cloudflare, ALB, Cloud Run), confirm the proxy forwards `X-Forwarded-Proto` so `assume_ssl` recognises the original scheme. ## Cache [#cache] The `cache` role in `config/database.yml` points `Rails.cache` at Solid Cache by default (`config.cache_store = :solid_cache_store` in production). Solid Cache is **shared across Puma workers and containers** (it's a database) — anything you put in `Rails.cache` (HTTP fragment caches, etag caches, your own application caches) works correctly out of the box on a multi-worker deploy. If you swap `Rails.cache` for `:memory_store`, every Puma worker has its own in-process cache and you lose the cross-worker invariant. Don't. ## Third-party CDN pins [#third-party-cdn-pins] The kit's Importmap pins several Railsblocks dependencies to CDN URLs (Shoelace, Tom-Select, Air-Datepicker, PhotoSwipe, Embla, Motion). In development that's fine; in production you have two failure modes to think about: * **CDN outage.** A jsdelivr or esm.sh blip means your JS partially loads. Mitigate by vendoring: `bin/importmap pin --download` pulls the file into `vendor/javascript/` and updates `config/importmap.rb` to serve it locally. Then commit the change. * **CSP.** If you add a strict Content Security Policy, you'll need to allow `cdn.jsdelivr.net` and `esm.sh` as script sources (or vendor as above). The kit's `config/initializers/content_security_policy.rb` is the Rails default — empty. ## Health checks [#health-checks] The kit ships **`GET /up`** — Rails' default health check. It returns 200 if the app booted and 500 otherwise. Wire your load balancer's health check at `/up` (the Kamal proxy uses `/up` out of the box). Note: `config.silence_healthcheck_path = "/up"` in production silences the `/up` line from access logs. If you also want `/up` to verify the database, replace it with a controller that runs `ActiveRecord::Base.connection.execute("SELECT 1")` — but be aware that turns the LB health check into a DB heartbeat, which can take a healthy host down on a transient DB blip. ## Observability [#observability] The kit ships logs to STDOUT with `:request_id` tags — that's the floor. Add the layers you need: * **Error tracking.** Sentry, Honeybadger, Bugsnag — all install as a gem and a single initializer. Wire it before you have your first 500. * **APM / tracing.** Datadog, New Relic, OpenTelemetry — same shape. Particularly useful here because most of the latency on Hotwire pages is Supabase PostgREST round-trips, which trace cleanly. * **Log aggregation.** Whatever ingests STDOUT works (Better Stack, Papertrail, Datadog Logs, etc.). Make sure you keep `request_id` end-to-end if you also instrument the browser. ## CI [#ci] `bin/ci` runs the full quality gate locally — wire the same checks into your CI before letting a branch merge: ```sh bin/rubocop # style bin/brakeman --quiet --no-pager # static security analysis bin/bundler-audit # vulnerable-gem audit bin/rails test # Minitest suite bin/rails test:system # Capybara + headless Chrome system tests bin/e2e # end-to-end against live local Supabase ``` `bin/ci` chains all of the above and automatically skips `bin/e2e` if Docker isn't running or `SKIP_E2E=1` is set. Neither case fails the run — you get a notice and move on. On hosted CI runners without Docker, set `SKIP_E2E=1` so the rest of the suite still gates the PR. ## Production readiness checklist [#production-readiness-checklist] Walk this before your first cut to production: * [ ] `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_ROLE_KEY` set in the production environment. * [ ] `RAILS_MASTER_KEY` set (and `secret_key_base` stable across deploys — no rotation between releases). * [ ] Database choice made — SQLite with a persistent volume, or Postgres with `DATABASE_URL` and `gem "pg"`. * [ ] `supabase/migrations/` applied against the production Supabase project (`supabase db push` after `supabase link`, or paste through the dashboard SQL editor). * [ ] `config.force_ssl = true` and `config.assume_ssl = true` uncommented in `config/environments/production.rb`. * [ ] `proxy.ssl: true` and `proxy.host` set in `config/deploy.yml` (if using Kamal). * [ ] CDN-pinned Railsblocks JS either vendored (`bin/importmap pin … --download`) or allowed by your CSP / accepted as a third-party dependency. * [ ] `Rails.cache` points at a shared store — `:solid_cache_store` (the kit default), Redis, or another shared cache. * [ ] Health check path on the LB is `/up`. * [ ] Rails-side migrations run before traffic flips (Kamal `pre-deploy` hook or platform equivalent). * [ ] Error tracking and log aggregation wired up. * [ ] `bin/ci` is green in CI on every PR. When all of the above is true, the kit is ready for production traffic. Anything provider-specific (Fly/Render/Heroku/ECS/etc.) goes on top — the host concerns are local to that platform and not in scope for this guide. # Getting started (/reference/starterkits/hotwire/getting-started) This guide takes you from a clean machine to a signed-in dashboard. Budget 15 minutes if you already have Ruby and Docker installed; 30 if you don't. No Node.js, no `npm install` — Importmap serves JavaScript directly. ## Prerequisites [#prerequisites] | Tool | Version | Why | | ------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ruby | matches `.ruby-version` (currently 3.3+) | App runtime. Install with `rbenv`, `asdf`, or `mise`. | | Bundler | any recent | `gem install bundler` after Ruby is installed. | | SQLite | 3.8.0+ | Rails app data, Solid Cache, Solid Queue, and Solid Cable all run on SQLite. The `sqlite3` CLI must be on PATH. | | Docker | recent | Required by `supabase start` (local stack) and by the Kamal deploy. Optional if you point the kit at Supabase Cloud. | | Supabase CLI | 2.x | `brew install supabase/tap/supabase`, or [official install docs](https://supabase.com/docs/guides/local-development/cli/getting-started). Only needed for a fully local Supabase stack. | | Git | any | For cloning. | **Node.js is not required.** JavaScript is served via Importmap; Tailwind is built by `tailwindcss-rails` from a precompiled binary. ## 1. Get the code [#1-get-the-code] The starter is published as a standalone repo, not a generator — copy it, don't render it: ```sh git clone https://github.com/supabase-ruby/starter-kit-hotwire my-app cd my-app ``` Commit it into a fresh repo of your own, or keep the upstream remote if you want to pull future starter updates. ## 2. Run `bin/setup` [#2-run-binsetup] ```sh bin/setup --skip-server ``` `bin/setup` is idempotent — re-run it any time. It runs `bundle install`, runs `bin/rails db:prepare` (which creates the four SQLite databases — `primary`, `cache`, `queue`, `cable`), and clears `log/` and `tmp/`. Pass `--skip-server` to skip the auto-launch of `bin/dev` (useful in CI or while you're still filling in `.env`), or `--reset` to drop and recreate the SQLite files. ## 3. Set environment variables [#3-set-environment-variables] Copy the template: ```sh cp .env.example .env ``` Rails does **not** auto-load `.env` — the file is read by your shell (via `direnv`, `dotenv`, or your process manager) before `bin/dev` runs. The supplied `.env.example` documents this at the top. You need three Supabase values. Pick **A** or **B** depending on whether you want a fully local Supabase or a Cloud project. ### A. Local Supabase via the CLI [#a-local-supabase-via-the-cli] From the project root: ```sh supabase start # boots Auth + Postgres + Studio in Docker ``` The CLI prints something like: ``` API URL: http://127.0.0.1:54321 anon key: eyJhbGciOi... service_role key: eyJhbGciOi... ``` Copy each into `.env`: | CLI key | `.env` variable | | ------------------ | --------------------------- | | `API URL` | `SUPABASE_URL` | | `anon key` | `SUPABASE_ANON_KEY` | | `service_role key` | `SUPABASE_SERVICE_ROLE_KEY` | The `supabase/` directory is checked in — `supabase start` automatically applies the migrations under `supabase/migrations/`, including the `public.notes` table and its RLS policies. ### B. Supabase Cloud [#b-supabase-cloud] If you already have a project at [supabase.com](https://supabase.com): | `.env` variable | Dashboard location | | --------------------------- | -------------------------------------------------------- | | `SUPABASE_URL` | Project Settings → API → **Project URL** | | `SUPABASE_ANON_KEY` | Project Settings → API → **`anon` / `public` key** | | `SUPABASE_SERVICE_ROLE_KEY` | Project Settings → API → **`service_role` `secret` key** | For the Notes view to render any rows, you'll need to apply the `supabase/migrations/` files to the Cloud project — either link the project (`supabase link --project-ref `) and run `supabase db push`, or paste the SQL into the dashboard's SQL editor. The `service_role` key is server-only. It's used by the gem for admin-side calls (account deletion, admin user lookups). Never expose it to browsers. ## 4. Boot the dev server [#4-boot-the-dev-server] ```sh bin/dev ``` `bin/dev` runs `Procfile.dev`, which starts `bin/rails server` on port 3000 and `bin/rails tailwindcss:watch` on a second process. Tailwind's watcher rebuilds `app/assets/builds/application.css` on every `app/assets/tailwind/application.css` change. Then open [http://127.0.0.1:3000](http://127.0.0.1:3000). ## 5. Sign up your first user [#5-sign-up-your-first-user] The public landing page at `/welcome` has **Log in** and **Register** CTAs. Click **Register** and fill in an email + a password of 12 or more characters. What happens behind the scenes: 1. The form POSTs to `RegistrationsController#create` (subclassed from `Supabase::Rails::RegistrationsController`). 2. The gem calls Supabase Auth's sign-up endpoint with the anon key. 3. Supabase emails a confirmation link. In development, the kit ships [`letter_opener_web`](https://github.com/fgrehm/letter_opener_web) — open [http://127.0.0.1:3000/letter\_opener](http://127.0.0.1:3000/letter_opener) to read it without a real inbox. 4. Click the confirmation link in the email. Supabase confirms the account, sets the session, and redirects back to the kit. 5. The gem's middleware unwraps the redirect, sets the encrypted `sb-session` cookie, and you land on `/` authenticated. If you don't see the dashboard chrome (sidebar + user menu), the cookie wasn't set — see [Troubleshooting → Cookie isn't being set](/reference/starterkits/hotwire/troubleshooting#cookie-isnt-being-set). ## 6. Visit the notes page [#6-visit-the-notes-page] ```sh open http://127.0.0.1:3000/notes ``` The view at `/notes` lists rows from `public.notes` scoped to the signed-in user. The list will be empty on a fresh project — insert a row through the Supabase Studio at [http://127.0.0.1:54323](http://127.0.0.1:54323) (Table Editor → notes → "Insert row", with `user_id = `) and the row appears on refresh. The full pattern is covered in [Architecture → Per-request Supabase client](/reference/starterkits/hotwire/architecture#per-request-supabase-client). ## 7. Run the tests [#7-run-the-tests] ```sh bin/rails test # Minitest controllers + models bin/rails test:system # Capybara + headless Chrome system tests ``` To run the end-to-end suite against your local Supabase stack: ```sh bin/e2e # boots/reuses `supabase start`, runs test/e2e/ ``` `bin/e2e` is idempotent — if `supabase start` is already running it reuses it. First boot pulls Docker images and can take a few minutes. The full quality gate (`bin/ci`) chains everything together — see [Deployment → CI](/reference/starterkits/hotwire/deployment#ci). ## Next [#next] * [Project structure](/reference/starterkits/hotwire/project-structure) — what each directory is for. * [Customization](/reference/starterkits/hotwire/customization) — adding Turbo Frames, Stimulus, and Supabase Realtime patterns. # Hotwire starter (/reference/starterkits/hotwire) The Hotwire starter is a server-rendered Rails 8.1 monolith with Supabase Auth bolted in through [`supabase-rails`](/reference/rails) in `:web` mode. Sign-in, sign-up, password reset, OTP / magic-link, and GitHub OAuth all run through the gem's controllers; the browser holds an encrypted `sb-session` cookie, not a token. Every request through the Rails app forwards that cookie to Supabase, the gem's middleware unwraps it into `Current.user`, and controllers render Turbo-powered HTML. ## What is it [#what-is-it] A Rails 8.1 monolith pre-wired with the pieces a Hotwire app needs the day it ships: * **Cookie-backed auth** — `supabase-rails` in `:web` mode mints an encrypted `sb-session` cookie (`AES-GCM`), refreshes the underlying Supabase access token transparently, and exposes `Current.user` plus a per-request RLS-scoped Supabase client to every controller. * **Hotwire** — Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus are all wired into the layout via Importmap; no JavaScript build step. * **ViewComponent** — UI primitives (`SidebarComponent`, `UserMenuComponent`, `ButtonComponent`, `AvatarComponent`, etc.) under `app/components/`. * **Tailwind v4 + Railsblocks** — Tailwind built via `tailwindcss-rails`, Railsblocks components installed via Importmap pins and CDN CSS/JS. * **Lucide icons** — `lucide-rails` provides the `icon` helper used across views and components. * **Notes resource** — a RLS-governed `notes` table plus a `NotesController` that demonstrates the per-request, cookie-overlaid Supabase client pattern. * **Local Supabase** — a checked-in `supabase/` directory with `config.toml` and seed migrations boots a full Auth + Postgres stack via `supabase start`. * **System + E2E tests** — Minitest, Capybara + headless Chrome, plus an end-to-end suite under `test/e2e/` that runs against a real local Supabase stack. ## Who it's for [#who-its-for] You should pick this kit when: * You want a productive **server-rendered Rails app** — controllers render HTML, Hotwire turns it into a snappy SPA-feeling experience, and you don't want to stand up a separate API tier. * You want Supabase Auth to own the **identity surface** (sign-up, email confirmation, OAuth, password reset, magic-link) and let your Rails app focus on domain logic. * You're comfortable with an **encrypted cookie session** rather than holding tokens in JavaScript. The browser never sees an access token. * You'd rather use **RLS in Postgres** than re-encode authorisation in Pundit policies — every `Current.user.supabase` call inherits the signed-in user's row-level policies. If your frontend is a mobile or third-party SPA, look at the [Rails API](/reference/starterkits/api) starter — JWT-only, no cookies. If you want a typed React frontend in the same Rails process, look at the [Inertia + React](/reference/starterkits/inertia-react) starter — same cookie-auth model, different render layer. ## What's included [#whats-included] | Layer | What ships in the box | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Auth | `supabase-rails` in `:web` mode, `Authentication` concern, expired-session flash, custom `SessionsController#destroy` that lands on `/welcome` | | Auth UI | `app/views/supabase/rails/{sessions,registrations,passwords,otp,oauth}/` view overrides on a Tailwind `auth.html.erb` layout | | Routes | Public `/welcome`, public `/` (dashboard shell), authenticated `/dashboard`, `/notes`, `/settings/profile`, `/settings/appearance` | | Components | `SidebarComponent`, `UserMenuComponent`, `AvatarComponent`, `AppLogoComponent`, `ButtonComponent`, `SeparatorComponent`, plus auth-specific components | | Stimulus | `app/javascript/controllers/` wired by `eagerLoadControllersFrom`; ships a placeholder `hello_controller.js` and a `sidebar` controller used by the application layout | | Data | `supabase/migrations/` boots a `public.notes` table with RLS policies; SQLite drives Rails app data plus Solid Cache/Queue/Cable | | Tests | Minitest controllers + system tests, plus `test/e2e/` running against `supabase start` | | Deploy | `Dockerfile` and Kamal `config/deploy.yml` | ## What's *not* included [#whats-not-included] The kit deliberately stops short of the post-v1 auth surface so you can grow into it when you need it: * **MFA (TOTP + backup codes), passkeys / WebAuthn, sudo mode, identity verification.** All v2 work — the matching gems (`rotp`, `rqrcode`, `webauthn`, `bcrypt`) are not in the Gemfile, and the settings sidebar has no security section. * **An ActiveRecord `User` model.** `Current.user` is a value object built from the Supabase session — no shadow `users` table, no sync drift. Opt into a host-app users table later with [`bin/rails generate supabase:user_model`](/reference/rails/generators). * **A JavaScript bundler.** Importmap + Propshaft serve everything; there is no `package.json`, no `node_modules`, no Vite or esbuild. Add one if your needs outgrow Importmap. * **Background jobs.** Solid Queue is configured but unused — the kit ships no jobs. * **Per-target deploy guides.** `Dockerfile` and Kamal config are generic; turning them into a Fly/Render/Heroku flow is left to your platform. * **Production observability.** Logs go to STDOUT. Wire your APM (Sentry, Datadog, OpenTelemetry) when you need it. ## Next [#next] ## Repository [#repository] * Source: [`supabase-ruby/starter-kit-hotwire`](https://github.com/supabase-ruby/starter-kit-hotwire) * Underlying gem: [`supabase-rails`](/reference/rails) # Project structure (/reference/starterkits/hotwire/project-structure) The kit is a stock Rails 8.1 app with a small surface of starter-kit-specific files. This page walks each top-level directory in the order you'll touch them. ## `app/` [#app] Everything custom to the kit lives here. Standard Rails directories (`jobs/`, `mailers/`, `helpers/`) are mostly defaults; the action is in `components/`, `controllers/`, `views/`, and `javascript/`. ### `app/controllers/` [#appcontrollers] ``` app/controllers/ ├── application_controller.rb # ActionController::Base + Authentication ├── home_controller.rb # `/` (public) + `/dashboard` (auth) ├── pages_controller.rb # public `/welcome` ├── notes_controller.rb # RLS-scoped CRUD through PostgREST ├── concerns/ │ └── authentication.rb # wraps Supabase::Rails::Authentication ├── settings/ │ ├── profiles_controller.rb # display name + email via gem's update_user │ └── appearances_controller.rb # theme switcher (cookie-backed at JS level) ├── sessions_controller.rb # subclass — auth layout + welcome_path on destroy ├── registrations_controller.rb # subclass — auth layout ├── passwords_controller.rb # subclass — auth layout ├── otp_controller.rb # subclass — auth layout └── oauth_controller.rb # subclass — no overrides ``` `ApplicationController` inherits from `ActionController::Base` and `include`s the `Authentication` concern from `concerns/`. The concern bundles two things: 1. `include Supabase::Rails::Authentication` — installs `before_action :require_authentication` on every action, exposes `Current.user`, and wires up the `allow_unauthenticated_access` macro. 2. `prepend ExpiredSessionFlash` — an override of `request_authentication` that attaches a `"Your session has expired"` flash before redirecting to sign-in, but only when the request arrived with an `sb-session` cookie that the middleware just had to invalidate (expired refresh token, tampered ciphertext). The `prepend` is what makes it win method-resolution against the gem's own `request_authentication`. `HomeController` is the most interesting public route — it uses `allow_unauthenticated_access only: :index, unless: -> { request.path == dashboard_path }` so the same controller serves both the public landing variant (`/`) and the gated dashboard (`/dashboard`). `SessionsController`, `RegistrationsController`, `PasswordsController`, `OtpController`, and `OauthController` all subclass `Supabase::Rails::*Controller`. The only overrides are `layout "auth"` (so they render on the dedicated auth chrome) and `SessionsController#destroy`, which lands on `/welcome` instead of `/` after sign-out. `NotesController` is the kit's reference example for **per-request RLS** through the gem's Supabase client. See [Architecture → Per-request Supabase client](/reference/starterkits/hotwire/architecture#per-request-supabase-client) for what `request.env[Supabase::Rails::CONTEXT_KEY].supabase` resolves to. ### `app/components/` [#appcomponents] ``` app/components/ ├── application_component.rb # base class — exposes `icon` helper ├── app_logo_component.{rb,html.erb} # brand mark + name ├── app_logo_icon_component.{rb,html.erb} # mark-only variant for tight slots ├── auth_header_component.{rb,html.erb} # title + description for the auth chrome ├── auth_session_status_component.{rb,html.erb} # "Already signed in / Not signed in" callout ├── avatar_component.{rb,html.erb} ├── button_component.{rb,html.erb} ├── placeholder_pattern_component.{rb,html.erb} ├── separator_component.{rb,html.erb} ├── sidebar_component.{rb,html.erb} # navigation chrome — driven by Current.user + request.path └── user_menu_component.{rb,html.erb} # avatar + dropdown in the top-right ``` ViewComponent is in use across `app/views/` — open any layout or view and you'll see `render SidebarComponent.new(...)` rather than partials. The base `ApplicationComponent` is where shared helpers (including `lucide-rails`' `icon` helper) get re-exposed inside components. `SidebarComponent` is illustrative: its initialiser takes `user:` and `current_path:`, and its `current?(href)` method computes the active state for each nav item by prefix-matching `request.path`. Add a new sidebar item by extending its `nav_items` array — see [Customization → Add a sidebar entry](/reference/starterkits/hotwire/customization). ### `app/views/` [#appviews] ``` app/views/ ├── home/index.html.erb # dashboard shell (`/` and `/dashboard`) ├── pages/welcome.html.erb # public landing ├── notes/index.html.erb # signed-in user's notes list ├── settings/ │ ├── profiles/show.html.erb # display name + email form │ └── appearances/show.html.erb # theme switcher ├── layouts/ │ ├── application.html.erb # authenticated chrome (sidebar + header) │ ├── auth.html.erb # auth chrome — wraps Tailwind auth surfaces │ ├── _card.html.erb _simple.html.erb _split.html.erb # auth chrome variants │ ├── _head.html.erb # shared head — theme init, Importmap, Railsblocks CDN │ ├── mailer.{html,text}.erb # outbound email chrome (unused — Supabase sends mail) └── supabase/rails/ # gem view overrides — see below ├── sessions/new.html.erb # sign-in (email + password, GitHub OAuth) ├── registrations/new.html.erb # sign-up ├── passwords/{new,edit}.html.erb # request reset + new password form ├── otp/{new,verify}.html.erb # magic-link / OTP request + verify ├── oauth/_buttons.html.erb # "Continue with GitHub" partial └── shared/_flash.html.erb # flash rendering shared across the gem's views ``` `app/views/supabase/rails/` is how the gem's view overrides work: Rails resolves `Supabase::Rails::SessionsController#new` against this kit's `app/views/supabase/rails/sessions/new.html.erb` before falling back to the gem's bundled copy. Same shape as `inertia_rails` view inheritance — the *controllers* live in the gem, the *views* live in your app and can use your own components. `layouts/application.html.erb` is the dashboard chrome. The whole template is gated on `Current.user.present?` — signed-out visitors of public routes (e.g. the `/` variant of `HomeController#index`) get a stripped-down container layout with no sidebar. `layouts/_head.html.erb` is worth knowing about: it inlines a theme-init script that reads `localStorage["theme"]` and toggles the `dark` class on `` before paint (no flash of wrong theme), pulls Importmap pins, loads the Railsblocks third-party CSS/JS via CDN (Shoelace, Tom-Select, Air Datepicker, PhotoSwipe), and emits the Tailwind stylesheet built by `tailwindcss-rails`. ### `app/javascript/` [#appjavascript] ``` app/javascript/ ├── application.js # entrypoint — imports turbo-rails + controllers └── controllers/ ├── application.js # the Stimulus Application instance ├── index.js # eager-loads all *_controller.js in this dir └── hello_controller.js # placeholder — delete it or replace it ``` The Stimulus setup is the Rails default: `eagerLoadControllersFrom("controllers", application)` auto-registers any `app/javascript/controllers/*_controller.js` as a Stimulus controller whose tag matches the filename (`hello_controller.js` → `data-controller="hello"`). The application layout already references one controller you won't find on disk: `data-controller="sidebar"` opens / closes the mobile sidebar. That's a small Stimulus controller you'll add yourself when you customise the layout — the kit currently relies on the Hotwire defaults and basic CSS toggle classes for the mobile breakpoint. See [Customization → Add a Stimulus controller](/reference/starterkits/hotwire/customization#recipe-2--add-a-stimulus-controller-wired-to-supabase-realtime) for the canonical pattern. ### `app/models/` [#appmodels] ``` app/models/ ├── application_record.rb # ActiveRecord::Base ├── concerns/ # empty — yours to populate └── current.rb # ActiveSupport::CurrentAttributes ``` `Current` declares `:user` and `:session` attributes. The `Supabase::Rails::Authentication` concern populates both on every authenticated request — `Current.user` is the value object built from the verified Supabase claims, `Current.session` is the encrypted-cookie session record. There is no `User` ActiveRecord model in the kit. When you add your first AR model, generate it as usual (`bin/rails g model …`). If you later need a host-app `users` table that joins to `Current.user.id`, run [`bin/rails generate supabase:user_model`](/reference/rails/generators). ### `app/helpers/`, `app/jobs/`, `app/mailers/` [#apphelpers-appjobs-appmailers] Defaults from the Rails generator. The mailer layouts ship for future use; the kit itself sends no email — Supabase Auth handles confirmation, password reset, and OTP delivery. ## `config/` [#config] ### `config/application.rb` [#configapplicationrb] Standard Rails 8.1 application config with `config.load_defaults 8.1`. No custom middleware insertion (compare with the API starter, which inserts two custom middlewares around `Supabase::Rails::Middleware`). ### `config/routes.rb` [#configroutesrb] ```ruby supabase_authentication_routes # gem-provided: /session, /registration, /passwords, /otp, /oauth get "sign_in", to: redirect("/session/new"), as: :sign_in get "sign_up", to: redirect("/registration/new"), as: :sign_up get "welcome", to: "pages#welcome", as: :welcome get "dashboard", to: "home#index", as: :dashboard resources :notes, only: %i[index update destroy] namespace :settings do resource :profile, only: %i[show update destroy], controller: "profiles" resource :appearance, only: %i[show], controller: "appearances" end get "up" => "rails/health#show", as: :rails_health_check root "home#index" ``` `supabase_authentication_routes` expands to the Supabase-side sign-in/sign-up/OTP/OAuth/password-reset route table from `supabase-rails`. The kit's subclass controllers (in `app/controllers/`) take over these routes so they render with the kit's `auth` layout and components. `HomeController` serves both `/` and `/dashboard` from `#index` — the controller-level `allow_unauthenticated_access` macro picks the right behaviour based on `request.path`. ### `config/initializers/supabase.rb` [#configinitializerssupabaserb] The highest-leverage file in `config/`. The non-default bits: * `config.supabase.mode = :web` — turns on the encrypted-cookie session machinery. The gem installs `Web::CookieCredentialStrategy`, which mounts the session middleware that reads/writes the `sb-session` cookie. * Commented-out blocks for `allowed_redirect_origins`, `expose_current_user`, and `session` cookie defaults. The `:web`-mode defaults are usually fine — see [`supabase-rails` reference](/reference/rails) for what they map to. * An `Rails.env.e2e?` branch that bridges the Supabase CLI's environment-variable naming (`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`) onto what the gem reads (`SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`). This block only runs in the `e2e` Rails environment. ### `config/importmap.rb` [#configimportmaprb] ```ruby pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" # Railsblocks JS dependencies — CDN-pinned pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/...+esm" pin "tom-select", to: "https://cdn.jsdelivr.net/...+esm" # … (others — see the file) ``` The first block is the Hotwire baseline that every Rails 8 app gets. The Railsblocks pins are CDN URLs — fine in development, but consider [vendoring them with `bin/importmap pin --download`](/reference/starterkits/hotwire/deployment#third-party-cdn-pins) before you ship to production. ### `config/database.yml` [#configdatabaseyml] The kit runs on **SQLite** by default. Production declares four roles — `primary`, `cache`, `queue`, `cable` — each pointing at a different SQLite file under `storage/`. The Solid Cache/Queue/Cable schemas are loaded from `db/cache_schema.rb`, `db/queue_schema.rb`, and `db/cable_schema.rb` by `bin/rails db:prepare`. The `e2e` Rails environment mirrors `test` — Supabase URLs and keys come from the environment, not from this file. If you'd rather run on Postgres in production, swap the adapter in `config/database.yml` and add the `pg` gem to the Gemfile. The kit doesn't depend on SQLite-specific features. ### `config/deploy.yml` [#configdeployyml] Kamal config — `service: supabase_rails_starter`, container image, registry, secret list, and a `proxy` block with SSL termination commented out. Edit before deploying. See [Deployment](/reference/starterkits/hotwire/deployment) for the production checklist. ## `supabase/` [#supabase] ``` supabase/ ├── config.toml # `supabase start` configuration └── migrations/ ├── 20260615040700_create_notes.sql # public.notes + RLS read/insert policies └── 20260615041500_notes_write_policies.sql # RLS update/delete policies ``` The checked-in `supabase/` directory is what makes `bin/e2e` and `supabase start` work without a `supabase init` round-trip. `config.toml` configures the local CLI: the API URL on `:54321`, the database on `:54322`, Studio on `:54323`, and which schemas PostgREST exposes (`public` + `graphql_public`). `migrations/` is the canonical place for SQL that defines your application schema — RLS policies, tables, functions, RPC. They're applied automatically on `supabase start` against the local stack. For production, link the project (`supabase link --project-ref `) and run `supabase db push`, or paste the SQL into the dashboard. The notes table is the minimal demonstration of: * Owner column with `auth.uid()` as the default — Postgres knows who the inserter is. * `enable row level security` plus four policies (read, insert, update, delete), all keyed on `auth.uid() = user_id`. * The PostgREST-friendly shape that `NotesController` reads through `current_supabase_client.from("notes")`. ## `db/` [#db] ``` db/ ├── seeds.rb # empty — no domain seeds ├── cable_schema.rb # Solid Cable ├── cache_schema.rb # Solid Cache └── queue_schema.rb # Solid Queue ``` No `migrate/` directory and no `schema.rb` because the kit has no domain ActiveRecord models — `notes` lives in Supabase Postgres, not in the Rails-owned SQLite. The three `*_schema.rb` files are loaded into the matching SQLite databases declared in `config/database.yml` by `bin/rails db:prepare`. When you add your first domain model that you want in the Rails-owned DB, Rails will generate `db/migrate/` and `db/schema.rb` as usual. ## `test/` [#test] ``` test/ ├── test_helper.rb # fake SUPABASE_* + SupabaseAuthStubs prepend ├── application_system_test_case.rb # Capybara + headless Chrome at 1400×1400 ├── controllers/ # ActionController::TestCase ├── models/ ├── helpers/ ├── mailers/ ├── integration/ ├── system/ # full-stack browser tests ├── fixtures/ ├── support/ │ └── supabase_auth_stubs.rb # in-process auth bypass for non-e2e tests └── e2e/ ├── README.md # author's guide for new e2e tests ├── e2e_test_case.rb # base class — sets up real Supabase reset ├── smoke_test.rb # registration → dashboard ├── sign_{up,in,out}_flow_test.rb # happy + negative paths ├── session_persistence_flow_test.rb # multi-request session + JWT claim ├── session_expiry_flow_test.rb # expired-session redirect + flash └── rls_unauthorized_{reads,writes}_flow_test.rb # cross-user RLS checks ``` Two test stacks live side by side. The regular `test/controllers/`, `test/system/`, etc. run against the **stubbed** Supabase Auth — `SupabaseAuthStubs` (under `test/support/`) is prepended into `ApplicationController` and short-circuits the gem's middleware so tests can sign in with `sign_in_as(...)` without an HTTP round-trip to Supabase. Fast, hermetic, and the default suite (`bin/rails test`, `bin/rails test:system`). `test/e2e/` runs against a **live local Supabase stack** booted by `bin/e2e`. The base class (`E2ETestCase`) inherits from `ApplicationSystemTestCase`, calls `SupabaseReset.clean!` between tests (drains `auth.users` + your registered tables), and exposes `sign_up_as` / `sign_in_as` helpers that drive real forms. Use this suite to catch regressions in the cookie session lifecycle, RLS enforcement, and the OAuth round-trip — the things stubs can't validate. ## Other directories [#other-directories] | Directory | What it is | Touch it when | | -------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `bin/` | Rails 8 bin stubs + `bin/ci`, `bin/dev`, `bin/e2e`, `bin/setup`, `bin/kamal`, `bin/rubocop`, `bin/brakeman`, `bin/bundler-audit` | Running quality gates, the dev server, or Kamal commands | | `lib/` | Empty `lib/tasks/` — for rake tasks you add | You add a rake task | | `public/` | Default Rails 8 error pages + `icon.png`/`icon.svg` | You change error page styling or the favicon | | `storage/` | SQLite database files + Active Storage blobs | Never directly | | `script/` | Project scripts | You add a one-off script | | `vendor/` | Vendored dependencies (rare) | You vendor a gem | | `tmp/`, `log/` | Rails runtime data | Never directly | # Troubleshooting (/reference/starterkits/hotwire/troubleshooting) A guide to the failures you're most likely to hit on the path from `git clone` to a signed-in dashboard with a working `/notes` view. Each section names the symptom you'll see and the fix. ## "Supabase URL not configured" on first request [#supabase-url-not-configured-on-first-request] The gem reads `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and (for admin calls) `SUPABASE_SERVICE_ROLE_KEY` from the environment. If any is missing, the first request raises a configuration error from `Supabase::Rails::Middleware`. * **In dev:** export the variables before running `bin/dev`. Rails does **not** auto-load `.env` — the kit's `.env.example` is for `direnv`, `dotenv`, or your process manager to read. The simplest fix is `cp .env.example .env`, fill it in, and source it through your shell tool of choice (`direnv allow .`, `dotenv -- bin/dev`, etc.). * **In production:** set the same three variables through your platform's secret manager. ## Cookie isn't being set [#cookie-isnt-being-set] You signed up, see no error, but every refresh sends you back to `/session/new`. Two possible causes: 1. **You're on `http://` and `config.force_ssl = true` (or `config.assume_ssl = true`) is on.** The `sb-session` cookie is marked `Secure`, and the browser silently drops it on a non-HTTPS origin. For local development make sure those two are commented out in `config/environments/production.rb` *and* you're not somehow booting the app under `production`. Local dev runs `config/environments/development.rb`, which doesn't set either. 2. **`secret_key_base` is not stable.** If Rails regenerates a key between requests (e.g. you have no `config/master.key` and no `RAILS_MASTER_KEY` env var), the cookie written by request N is undecryptable by request N+1. Check `bin/rails credentials:show` runs cleanly; if it errors, generate a master key with `bin/rails credentials:edit` and commit the resulting `config/credentials.yml.enc` (the key itself stays in `config/master.key`, which is gitignored). ## "Your session has expired" loop [#your-session-has-expired-loop] You get redirected to `/session/new` with the expired-session flash, sign in, get redirected, see the flash again, and so on. The flash is attached by `ExpiredSessionFlash` in `app/controllers/concerns/authentication.rb`, but only when the request arrived with an `sb-session` cookie that the middleware just had to invalidate. A loop usually means one of: * **The browser is sending an old cookie that you can't clear.** Open dev tools → Application → Cookies → delete `sb-session` for the origin. Refresh. * **Your Supabase project changed.** If `SUPABASE_URL` now points at a different project than the one that issued the original session, refresh fails and the cookie keeps getting invalidated. Clear the cookie or sign out from another tab first. * **Clock skew.** A wildly wrong system clock can make the gem think every token is expired. Check `date` on your local machine; on a server, confirm NTP is working. If the loop persists with a clean browser, check `Current.session.access_token` (in the Rails console) is what you expect — if it's `nil`, the cookie isn't reaching the gem at all and you're back at the "Cookie isn't being set" case. ## `/notes` is empty when I know I inserted a row [#notes-is-empty-when-i-know-i-inserted-a-row] The `/notes` page lists rows from `public.notes` **scoped to the signed-in user**. Empty almost always means one of: * **The row's `user_id` doesn't match `Current.user.id`.** When you inserted from Supabase Studio, `auth.uid()` was `null` (Studio runs as `postgres`, not as the user), so the default `user_id` ended up `null` or the row was rejected. Insert from the `notes` page (once you've added a Recipe-1-style create form) or run an SQL statement that sets the column explicitly: `insert into public.notes (user_id, content) values ('', 'hello');`. * **RLS is blocking the read.** Confirm the policies are in place: `select policyname, cmd from pg_policies where tablename = 'notes';` should list `Users can read own notes`, `Users can insert own notes`, `Users can update own notes`, `Users can delete own notes`. If they're missing, you skipped the migrations — run `supabase db push` (against a linked project) or paste `supabase/migrations/*.sql` into the dashboard SQL editor. * **You're not actually signed in.** The `NotesController` requires authentication. If `Current.user` is `nil` you'd get redirected to `/session/new`, not the empty state — but a misconfigured `before_action` (you edited `Authentication` and broke `require_authentication`) might let the request through unauthenticated. The PostgREST call without an `Authorization` header returns nothing under RLS. ## Tailwind classes aren't applied [#tailwind-classes-arent-applied] You added a class like `bg-emerald-500` to a view, refreshed, and it didn't take. * **`bin/dev` isn't running the Tailwind watcher.** `Procfile.dev` runs both `bin/rails server` and `bin/rails tailwindcss:watch`. If you started Rails with `bin/rails s` directly, the watcher isn't rebuilding `app/assets/builds/application.css`. Use `bin/dev` instead. * **The class isn't in any scanned source.** Tailwind v4 scans only files declared in `app/assets/tailwind/application.css`'s `@source` directives. If you added a view in a non-default location (e.g. `lib/`), add a matching `@source` line. Same for new ViewComponents under `app/components/` — that path is already scanned by default; non-default paths are not. * **Browser cached the old CSS.** Hard refresh (Cmd-Shift-R on macOS / Ctrl-Shift-R elsewhere). Turbo Drive's `data-turbo-track="reload"` on the stylesheet tag in `_head.html.erb` is supposed to bust this, but a hard refresh is the surefire fix. ## "No matching importmap entry" in dev tools [#no-matching-importmap-entry-in-dev-tools] After adding a Stimulus controller that imports a new package: ``` Uncaught TypeError: Failed to resolve module specifier "". ``` You forgot the Importmap pin. Run `bin/importmap pin ` to add it; that updates `config/importmap.rb` and (for downloadable packages) drops a vendored copy into `vendor/javascript/`. Restart the dev server so the new `importmap.rb` is read. If the package is published only on a CDN (e.g. `@floating-ui/dom` is already pinned to jsdelivr), add the pin by hand: ```ruby pin "the-package", to: "https://cdn.jsdelivr.net/npm/the-package@1.2.3/+esm" ``` ## `/letter_opener` shows no emails [#letter_opener-shows-no-emails] In development, `letter_opener_web` is mounted at `/letter_opener`. It only captures mail sent **via Rails' `ActionMailer`** — Supabase Auth sends its confirmation, password-reset, and OTP mails from Supabase's own infrastructure, not through Rails. For a fully local Supabase stack (`supabase start`), the CLI runs `inbucket` on port `54324` (open [http://127.0.0.1:54324](http://127.0.0.1:54324)) and routes outbound auth mails there. That's where the confirmation email lands in dev. `letter_opener_web` only matters once you start sending mail from Rails — e.g. a "Welcome" mailer the kit doesn't ship. ## "Could not start Supabase: port 54321 already in use" [#could-not-start-supabase-port-54321-already-in-use] Another Supabase stack — almost always one from a different project on the same machine — is bound to the local ports. Either stop that stack first: ```sh cd ../other-project supabase stop ``` …or change this project's ports in `supabase/config.toml` (look for `[api] port = 54321`, `[db] port = 54322`, `[studio] port = 54323`) and re-run `supabase start`. ## E2E suite says "Supabase CLI not found" or "Docker is not running" [#e2e-suite-says-supabase-cli-not-found-or-docker-is-not-running] `bin/e2e` needs both. Both errors are reported up-front before any test runs: * **"Supabase CLI not found"** — install via `brew install supabase/tap/supabase` (macOS) or follow the [official install docs](https://supabase.com/docs/guides/local-development/cli/getting-started). Re-open your shell so the new PATH entry is picked up. * **"Docker is not running"** — start Docker Desktop and wait for the whale icon to stop animating. Re-run `bin/e2e`. `bin/ci` will silently skip the E2E step when Docker is unreachable; the rest of the suite still runs. If the stack boots but tests fail with "Supabase reset failed", bump the boot budget with `E2E_SUPABASE_TIMEOUT=240 bin/e2e`. For a clean slate, `supabase stop --no-backup` drops the local DB volume — the next `bin/e2e` cold-boots a fresh state. `--no-backup` is destructive (your local data is gone), so reach for it only when you suspect a corrupted local DB. ## GitHub OAuth redirects to a 500 page [#github-oauth-redirects-to-a-500-page] The "Continue with GitHub" button kicks off the Supabase OAuth flow, which goes Browser → Supabase → GitHub → Supabase → Rails. If the final redirect to `/oauth/callback` 500s: * **Supabase isn't configured for GitHub.** In the Supabase dashboard, **Authentication → Providers → GitHub** must be enabled and have the Client ID + Client Secret pasted in. The credentials live in your local `.env` for documentation only — they're not read by Rails. * **The callback URL on the GitHub OAuth app is wrong.** It must be the Supabase callback URL (`https://.supabase.co/auth/v1/callback`), not your Rails app's URL. Supabase is the OAuth target; it then hands the session back to Rails. * **Your Supabase project's "Redirect URLs" allowlist doesn't include the kit's origin.** Add `http://localhost:3000` (dev) and your production URL under **Authentication → URL Configuration**. The end-to-end smoke flow for OAuth is in the kit's [README "Sign in with GitHub" section](https://github.com/supabase-ruby/starter-kit-hotwire#sign-in-with-github). ## "Lock timeout" or "database is locked" on SQLite [#lock-timeout-or-database-is-locked-on-sqlite] SQLite serialises writes — under concurrent writes (a job process + a web process, or two Puma workers writing at once), one will block until the other commits. Symptoms: ``` SQLite3::BusyException: database is locked ``` Mitigations, in order: * **Confirm WAL is on.** Rails 8.1 enables `journal_mode = WAL` by default, but check `bin/rails db -p` → `PRAGMA journal_mode;` returns `wal`. * **Lower `RAILS_MAX_THREADS`.** Default is 3 in the kit's `.env.example`. If you're seeing locks, you're probably running too many concurrent writes; a smaller pool serialises at the Ruby level instead of inside SQLite. * **Move off SQLite for hot tables.** The most common offender is Solid Queue (a job-heavy app writes constantly to `solid_queue_*`). Pointing the `queue` role at Postgres while keeping `primary` on SQLite is a valid compromise. * **One server max.** SQLite is local-disk; the file isn't shared across hosts. For multi-server deploys, you have to swap to Postgres regardless. ## `bin/ci` fails at rubocop on a fresh checkout [#binci-fails-at-rubocop-on-a-fresh-checkout] The kit ships `rubocop-rails-omakase` — the Rails team's preset. If `bin/rubocop` fails on a fresh checkout with no edits from you, the cause is almost always: * **`bundle install` didn't run.** Re-run `bin/setup --skip-server` to pick up the latest gems. * **Your local Ruby is older than `.ruby-version`.** Some omakase cops disable themselves on old Rubies and warn on `nil`. `rbenv install` / `mise install` whatever `.ruby-version` says and re-run. For real style failures (after you've edited code), `bin/rubocop -a` auto-corrects what it can. # Architecture (/reference/starterkits/inertia-react/architecture) The starter is a Rails 8.1 monolith — Inertia owns the seam between Rails controllers and React pages, Vite drives the build, and [`supabase-rails`](/reference/rails) in `:web` mode handles the auth surface. This page covers each layer so you can reason about (and safely modify) the request lifecycle. ## High-level shape [#high-level-shape] ``` Browser ─► Rails (Inertia JSON or full-page HTML) │ │ │ ├─ Supabase::Rails::Middleware │ │ ↳ reads encrypted sb-session cookie │ │ ↳ refreshes Supabase access token if needed │ │ ↳ populates Current.user + Current.session │ │ │ ├─ InertiaController (parent) │ │ ↳ shares { auth: { user: current_user_props } } on every page │ │ │ └─ Action ─► render inertia: "", props: { … } │ │ │ ▼ │ React (Vite-served or SSR-rendered) │ └─► Supabase Auth (sign-up, password reset, magic-link, OAuth) ↳ redirects back through Rails so the gem can mint the cookie ``` The browser holds an **encrypted `sb-session` cookie**, not a token — the cookie carries the Supabase access + refresh tokens, the gem unwraps them server-side, and React only ever sees the *user object* through the shared `auth.user` prop. ## Inertia request lifecycle [#inertia-request-lifecycle] Inertia is best understood as a "controllers return props, the page is React" abstraction. There are two request shapes and four steps that vary slightly between them. ### First visit (full HTML) [#first-visit-full-html] 1. The browser requests `/dashboard`. No `X-Inertia` header. 2. The `Supabase::Rails::Middleware` resolves the cookie, sets `Current.user`, and continues. 3. `DashboardController < InertiaController` runs `#index`. `default_render: true` infers `pages/dashboard/index.tsx`. 4. Rails renders `application.html.erb`, which inlines a `