supabase-rb-rb
Rails API starter

Architecture

How supabase-rails plugs into the Rails API starter — the middleware stack, the request lifecycle, JWT verification against the JWKS, and how Current.user gets populated.

The starter is a thin shell — most of the auth machinery lives in supabase-rails. This page covers the integration points the kit relies on so you can reason about (and safely modify) the request lifecycle.

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:

bin/rails middleware

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

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

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

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 <jwt>, 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

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

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.

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 or Inertia + React and let supabase-rails' :web mode handle it.

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.

On this page