Authentication
The Supabase::Rails::Authentication concern, the generated app/controllers/concerns/authentication.rb, the Current model, and the supabase_context request object.
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 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.
# 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
endThe generated Authentication concern
bin/rails generate supabase:install writes a 7-line concern at app/controllers/concerns/authentication.rb:
# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
include Supabase::Rails::Authentication
end
endThe host's ApplicationController then includes Authentication, which transitively includes Supabase::Rails::Authentication. The thin wrapper exists so host apps can add app-specific before_actions, 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:
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?
endbefore_action :require_authentication— every action requires a signed-in user unless explicitly opted out viaallow_unauthenticated_access.before_action :populate_current_attributes— runs afterrequire_authenticationand writesCurrent.user/Current.sessionfromsupabase_context, including on actions that opted out (so anonymous pages can still readCurrent.userif a session happens to be present).helper_method :authenticated?—authenticated?is callable from views.helper_method :current_user—current_useris callable from views whenconfig.supabase.expose_current_userresolves totrue(the default in:webmode).
The concern is included only once
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
Every method below is an instance method on any controller that includes the Authentication concern. Two are also registered as helper_methods so views can call them directly.
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).
<%# 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 %>class PostsController < ApplicationController
def index
if authenticated?
@posts = Current.user.posts
else
@posts = Post.public_only
end
end
endcurrent_user
| Returns | Helper method |
|---|---|
Supabase::Rails::User 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 is set:
user_modelunset (default) —current_useris a frozenSupabase::Rails::Uservalue object built from the verified JWT claims.user_modelset —current_useris theActiveRecordrow returned by<Model>.from_supabase(claims)(seesupabase:user_model).
Whether views can call current_user is controlled by config.supabase.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.
<%# app/views/dashboard/show.html.erb — web mode, expose_current_user resolves true %>
<p>Signed in as <%= current_user.email %>.</p>class CommentsController < ApplicationController
def create
comment = Current.user.comments.create!(comment_params)
redirect_to comment.post
end
end`Current.user` vs `current_user`
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
| 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 to opt specific actions out.
allow_unauthenticated_access(only:, 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.
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
endclass HomeController < ApplicationController
allow_unauthenticated_access only: :index
def index; end
endInternally:
def self.allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
endpopulate_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
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:
# 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# Option B — name the actions that DON'T need auth.
class HomeController < ApplicationController
allow_unauthenticated_access only: %i[index about pricing]
endOption 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)
| 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).
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
endIn :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)
| Returns | Helper method |
|---|---|
nil | no |
Signs the user out. Calls supabase-rb's auth.sign_out(scope:) 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).
class SessionsController < ApplicationController
def destroy
terminate_session
redirect_to root_path
end
endIn :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:)
| 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 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.
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
endThe bcrypt-imposed 72-byte password ceiling applies — keep maxlength: 72 on the password_field in any custom sign-in form.
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. |
class ApplicationController < ActionController::Base
include Authentication
private
def after_authentication_url
Current.user.admin? ? admin_root_url : dashboard_url
end
endThe Current model
bin/rails generate supabase:install writes app/models/current.rb:
# frozen_string_literal: true
class Current < ActiveSupport::CurrentAttributes
attribute :user, :session
endActiveSupport::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. |
class PostsController < ApplicationController
def create
post = Current.user.posts.create!(post_params)
redirect_to post
end
end<%# Available in views without an explicit helper — Current is autoloaded. %>
<p>Welcome back, <%= Current.user.email %>!</p>Adding your own Current attributes
Hosts often add app-specific attributes alongside :user and :session — e.g. :account, :request_id, :locale. Extend the generated Current model directly:
class Current < ActiveSupport::CurrentAttributes
attribute :user, :session, :account, :request_id
endThe gem only reads and writes :user and :session. Everything else is yours to populate from your own before-actions.
Supabase::Rails::User value object
When config.supabase.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:
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
Users with the same attributes are==. - Pattern-matching works directly:
case Current.user in {role: "service_role"}.
Current.user.id # => "9e7d2..."
Current.user.email # => "alice@example.com"
Current.user.user_metadata # => { "full_name" => "Alice" }
Current.user.raw["aud"] # => "authenticated"When to switch to an ActiveRecord user model
The value object is the right default — zero DB writes, no migrations, and authentication works out of the box. Reach for supabase: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 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.
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). |
class PostsController < ApplicationController
def index
@posts = supabase_context.supabase
.from("posts")
.select("*")
.order("created_at", desc: true)
.execute
.data
end
endclass Admin::UsersController < ApplicationController
before_action :require_admin
def index
@users = supabase_context.supabase_admin
.auth
.admin
.list_users
.users
end
endAccess the env key directly if you want to test for context presence without instantiating the controller helper:
request.env["supabase.context"] # => SupabaseContext or nil
request.env[Supabase::Rails::CONTEXT_KEY] # constant formThe 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
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 Migrating from Rails 8 auth if you are coming from the built-in generator.
See also
- JWT verification —
Supabase::Rails::JWT.verifyand the JWKS cache that backs every authenticated request. - Session store — the encrypted-cookie wrapper and the Rack middleware that reads it on every
:web-mode request. - Configuration →
expose_current_user— toggle whethercurrent_useris exposed to views. - Configuration →
user_model— switchCurrent.userto anActiveRecordrow. supabase:installgenerator — writes theAuthenticationconcern and theCurrentmodel.supabase:user_modelgenerator — shadowuserstable +User.from_supabase(claims).- Controllers — the gem's stock
SessionsController/RegistrationsController/ etc. all include this concern. - Migrating from Rails 8 auth — drop-in mapping for hosts coming from
bin/rails g authentication. - supabase-rb: 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).
supabase:views
Copies the gem's eight default auth view templates into app/views/supabase/rails/ so they can be customised — Rails' view-resolution order picks the host copies ahead of the gem's.
JWT verification
Supabase::Rails::JWT.verify — the access-token verifier behind every authenticated request, with JWKS resolution, in-memory caching, and the AuthError shape it raises.