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:
-
Rack::Attackruns ahead ofSupabase::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'sstarter_kit.rack_attackinitializer (after: "supabase.middleware") callsmove_beforeto put it there — the Rack::Attack railtie's default is at the end of the stack, which would be too late. -
JsonUnauthorizedResponderruns outsideSupabase::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. Thestarter_kit.json_unauthorized_responderinitializer (after: "supabase.middleware") usesinsert_before Supabase::Rails::Middlewareto land in that slot.
You can inspect the live stack with:
bin/rails middlewareRequest 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.userFor an unauthenticated request (missing token):
- The middleware passes through because
config.supabase.auth = %i[user none]includes:noneas a fallback strategy. The request reaches the controller. - The
Authenticationconcern'srequire_authenticationfinds no user and falls through torequest_authentication, which renders{"error":"unauthorized"}with 401.
For an invalid token (bad signature, expired, wrong audience):
Supabase::Rails::Middlewarerejects the request itself and returns 401 with{message:, code:}.JsonUnauthorizedRespondersees 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— parseAuthorization: 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_SECRETis 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'sSUPABASE_JWT_SECRETis the HMAC key for HS256 tokens. - From a JWKS URL. If you switch to RS256/asymmetric tokens, set
SUPABASE_JWKS_URLinstead 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_authenticationon every action that includes it. - Exposes
Current.userpopulated from the JWT claims that the middleware verified. - Exposes the
allow_unauthenticated_access(only: …)class macro for whitelisting actions (used byHealthzController).
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_SECRETis 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 auserstable. - 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.useris 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 easilybelongs_to :userin your domain models, and you don't have a place to hang local profile fields. - AR
User— whatbin/rails generate supabase:user_modelgives you. Auserstable keyed byid = 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.
Project structure
A directory-by-directory walkthrough of the Rails API starter — app/, config/, db/, spec/, and swagger/ — at the level of detail a new contributor needs.
Customization
Concrete recipes for extending the Rails API starter — add a resource, allow an unauthenticated route, change the database schema.