supabase-rb-rb
Authentication

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
end

The 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
end

The 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?
end
  1. before_action :require_authentication — every action requires a signed-in user unless explicitly opted out via 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_usercurrent_user is callable from views when config.supabase.expose_current_user resolves to true (the default in :web mode).

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?

ReturnsHelper method
Booleanyes (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
end

current_user

ReturnsHelper method
Supabase::Rails::User value object, or the configured user_model AR record, or nilwhen config.supabase.expose_current_user resolves to true

Delegates to Current.user. The shape depends on whether config.supabase.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 setcurrent_user is the ActiveRecord row returned by <Model>.from_supabase(claims) (see supabase:user_model).

Whether views can call current_user is controlled by config.supabase.expose_current_usertrue 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

ReturnsHelper method
Truthy on success; calls request_authentication on failureno

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
end
class HomeController < ApplicationController
  allow_unauthenticated_access only: :index

  def index; end
end

Internally:

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

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]
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)

ReturnsHelper method
The supplied supabase_sessionno

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
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)

ReturnsHelper method
nilno

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
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:)

ReturnsHelper method
Supabase::Auth::Types::Session on success, nil on a 4xx failureno

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
end

The 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).

HookDefaultWhat it does
request_authenticationredirect_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_urlstored_location_for_redirect || root_urlURL SessionsController#create redirects to after a successful sign-in. Override to send users to a per-role dashboard.
store_location_for_redirectsession[: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_redirectsession.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
end

The Current model

bin/rails generate supabase:install writes app/models/current.rb:

# 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:

AttributeTypeWhen written
Current.userSupabase::Rails::User value object (default), or the user_model AR record when configured, or nilSet by populate_current_attributes (after require_authentication) and by start_new_session_for (on sign-in). Cleared by terminate_session.
Current.sessionThe verified JWT claims Hash on the resume path, or the upstream Supabase::Auth::Types::Session struct after start_new_session_forSet 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
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

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
AttributeSource claimDescription
idsubThe Supabase user UUID. Stable across sessions; safe to use as a foreign key.
emailemailThe user's email address.
roleroleThe Postgres role the JWT was issued for (authenticated, anon, or a custom role).
app_metadataapp_metadataProvider-managed metadata (provider, providers).
user_metadatauser_metadataUser-supplied metadata — what data: sets on sign-up and what supabase_update_user(data: ...) writes.
rawthe full claims HashEvery 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
)
AttributeTypeDescription
supabaseSupabase::ClientA per-request client bound to the verified user's JWT — every PostgREST / Storage / Functions call goes through this. Honours RLS.
supabase_adminSupabase::ClientA per-request client bound to the project's secret key. Bypasses RLS. Reach for this only in trusted admin paths.
user_claimsHashThe user-claims subset of the JWT payload (sub, email, role, aud, exp).
jwt_claimsHashThe full verified JWT payload, including custom claims.
auth_modeSymbolThe mode the credentials were verified under: :user, :publishable, :secret, or :none.
auth_key_nameString or nilThe named entry in SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYS that matched, when applicable.
current_userSupabase::Rails::User or nilThe 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
end
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:

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

supabase-rails ships Rails-8 vocabulary, not Devise's. The mapping is one-for-one for the helpers most apps use:

Devisesupabase-rails
current_usercurrent_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_sessionCurrent.session (the verified JWT claims)
sign_in(user)start_new_session_for(supabase_session)
sign_outterminate_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 verificationSupabase::Rails::JWT.verify and 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 whether current_user is exposed to views.
  • Configuration → user_model — switch Current.user to an ActiveRecord row.
  • supabase:install generator — writes the Authentication concern and the Current model.
  • supabase:user_model generator — shadow users table + 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).

On this page