supabase-rb-rb
Migrating from Rails 8 auth

Migrating from Rails 8 auth

Step-by-step migration from the Rails 8 bin/rails g authentication generator to supabase-rails — artifact mapping, migration steps for the User model, session controller, password reset, routes, tests, plus rollback points and a verification checklist.

Supabase::Rails::Authentication is shape-compatible with the concern Rails 8's bin/rails generate authentication emits. The method names (require_authentication, allow_unauthenticated_access, authenticated?, start_new_session_for, terminate_session), the Current model, and SessionsController#new/create/destroy line up one-for-one — Supabase Auth verifies the credentials, but the controller-level API your app calls is unchanged.

This guide is a manual migration. There is no automatic upgrade script: passwords cannot be moved out of has_secure_password's bcrypt hashes (Supabase Auth hashes them differently), the Rails 8 signed user_id cookie is incompatible with the encrypted Supabase session cookie, and your existing AR rows are yours to keep, replace, or backfill. The walkthrough is split into eight steps with rollback points marked at each one.

Prerequisites

You have a Rails app already running bin/rails generate authentication from Rails 8, with has_secure_password on User, an AR-backed Session model, and a PasswordsMailer issuing reset links. You have a Supabase project provisioned (free tier is fine) and have read Getting started end-to-end.

Artifact mapping

The Rails 8 auth generator writes 11 files and patches routes.rb + application_controller.rb. supabase:install writes 8 files and patches the same two host files. The table below maps every Rails 8 artifact to its supabase-rails counterpart so you know up front what gets overwritten, what gets deleted, and what gets replaced by a generator-managed equivalent.

Rails 8 artifactsupabase-rails equivalentAction at migration time
app/controllers/concerns/authentication.rbSame path, gem versioninclude Supabase::Rails::AuthenticationOverwrite. Re-apply host-specific overrides (after_authentication_url, etc.) after the include (see Step 5).
app/controllers/sessions_controller.rb3-line subclass of Supabase::Rails::SessionsControllerOverwrite. Move any create-time analytics into the new subclass and call super.
app/controllers/passwords_controller.rb3-line subclass of Supabase::Rails::PasswordsControllerOverwrite. Supabase sends the recovery email; your PasswordsMailer is removed (see Step 4).
app/models/user.rb (has_secure_password)Either a Supabase::Rails::User value object or the opt-in AR shadow UserReplace. Strip has_secure_password; choose Option A (shadow AR model) or Option B (value object only) in Step 3.
app/models/session.rb (AR-backed Session)Encrypted sb-session cookie + JWT — no DB tableDelete. Drop the model and its migration in Step 4.
app/models/current.rbSame shapeSkip if you already have attribute :user, :session — the gem's current.rb.tt is identical.
app/mailers/passwords_mailer.rbSupabase recovery email (sent by Supabase Auth)Delete the mailer and its views unless you want to keep it for unrelated transactional mail.
app/views/passwords_mailer/reset.html.erb/.text.erbSupabase project email template (Dashboard → Authentication → Email Templates → "Reset Password")Delete locally. Edit the copy in the Supabase dashboard instead.
app/views/sessions/new.html.erbapp/views/supabase/rails/sessions/new.html.erbInline at the new path via bin/rails g supabase:views. Form-anchor names match; CSS / class-name customisation ports verbatim (see Step 6).
app/views/passwords/new.html.erb + edit.html.erbapp/views/supabase/rails/passwords/{new,edit}.html.erbInline at the new path. Re-apply CSS file-by-file.
Sessions migration (create_sessions)None — sessions live in the encrypted cookieDelete the migration file and run bin/rails db:migrate (or write a DropSessions migration if already applied to production).
bcrypt gem (transitively via has_secure_password)Not usedOptional remove. Drop from Gemfile once has_secure_password is gone, unless another model still depends on it.
resource :session, only: [:new, :create, :destroy] in routes.rbFolded into supabase_authentication_routesDelete. Replaced by the single supabase_authentication_routes line the install generator inserts.
resources :passwords, param: :token in routes.rbFolded into supabase_authentication_routesDelete. Same param: :token shape so URL helpers (new_password_path, edit_password_url(token: t)) keep working.
include Authentication in ApplicationControllerSame line, no-op patchNo-op. The gem's patch checks for the substring and skips if present.

The full Rails 8 generator surface ports cleanly: nothing in your app's controllers or views needs to change names or routes. Everything below is either a delete (User.has_secure_password, Session AR model, PasswordsMailer) or a verbatim drop-in (concern + 5 controllers + initializer).

What stays

  • The Current model (app/models/current.rb) — same name, same attribute :user, :session.
  • The Authentication concern's public method names (require_authentication, allow_unauthenticated_access, authenticated?, start_new_session_for, terminate_session, after_authentication_url, store_location_for_redirect, stored_location_for_redirect).
  • SessionsController's actions (new, create, destroy).
  • PasswordsController's actions (new, create, edit, update) — same routes, same parameter names, param: :token URL shape preserved.
  • View structure — the gem ships forms whose ERB anchors (form_with, email_field, password_field, submit "Sign in") match the Rails 8 baseline verbatim, so any custom CSS / class names you applied keep working when re-applied at the new view paths.
  • Your URL helpers — new_session_path, session_path, new_password_path, edit_password_url(token: t), password_path(token: t).

What changes

  • Credentials are checked by Supabase Auth, not by User.authenticate_by(email:, password:). The password_digest column on your users table is no longer the source of truth and bcrypt is no longer load-bearing.
  • The session cookie now carries a Supabase session payload (access_token, refresh_token, expires_at, token_type, ...) encrypted with secret_key_base, not the Rails 8 signed user_id integer.
  • There is no sessions table. Session state is the encrypted sb-session cookie — no DB lookups per request, no Session.find_by(...) joins to introspect signed-in users.
  • Current.user is a Supabase::Rails::User value object by default. To keep an AR User row (so Post belongs_to :user still resolves), opt into the shadow user model. The shadow table is UUID-keyed to match Supabase's auth.users.id.
  • Sign-up, OAuth, and OTP / magic-link flows are handled by additional controllers the gem ships (RegistrationsController, OtpController, OauthController) — Rails 8's built-in auth doesn't include these.
  • Password-reset emails are sent by Supabase, not by PasswordsMailer. Copy is configured in the Supabase dashboard. The :token URL segment in edit_password_url(token: t) is now an opaque parameter — the controller authenticates via the recovery session cookie, not the path.

Passwords cannot be migrated

Rails 8 hashes passwords with bcrypt via has_secure_password; Supabase Auth uses its own scheme (currently bcrypt with a different cost factor and serialisation, and the hash is namespaced inside auth.users.encrypted_password). There is no automatic re-hashing path. Every existing user will need to reset their password as part of the migration. Plan for this in your rollout comms and bake it into Step 7.

Migration steps

Step 1. Add the gem and provision Supabase

# Gemfile
gem "supabase-rails"
bundle install

Set the same env vars you'd use in :api mode:

SUPABASE_URL="https://<ref>.supabase.co"
SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."
SUPABASE_SECRET_KEY="sb_secret_..."
SUPABASE_JWKS_URL="https://<ref>.supabase.co/auth/v1/.well-known/jwks.json"
# Or set SUPABASE_JWKS to the inline JWKS JSON.

See Configuration → Environment variables for the full env-var resolution table. The SUPABASE_JWKS_URL value is what :web mode uses to verify every incoming request's JWT.

Rollback point. At this stage nothing has changed — you've only added a gem and set env vars. bundle remove supabase-rails and unset the env vars to revert.

Step 2. Run the install generator

bin/rails generate supabase:install

This emits the gem's versions of the files Rails 8 wrote when you ran bin/rails g authentication originally. Thor will prompt on every collision with the [Ynaqdh] overwrite prompt. At each prompt:

  • app/controllers/concerns/authentication.rboverwrite. The gem's concern is include Supabase::Rails::Authentication; host-specific overrides go in after the include (see Step 5).
  • app/controllers/sessions_controller.rboverwrite (it'll be a 3-line subclass). If you had customisation in create (analytics, audit logs), move it into the new subclass and call super. See SessionsController.
  • app/controllers/passwords_controller.rboverwrite. Same shape as the Rails 8 version, but the upstream call goes to Supabase. Your PasswordsMailer is no longer in the loop — Supabase sends the recovery email. To keep custom mailer behaviour, override create and skip calling super, then call supabase_reset_password(email:, redirect_to:) yourself.
  • app/models/current.rbskip if you already have one with the same attribute :user, :session shape. The gem's version is identical.
  • config/initializers/supabase.rboverwrite (it shouldn't exist yet). Edit allowed_redirect_origins, oauth_providers, and session.secure as needed.

The generator also writes three controllers Rails 8 didn't ship — registrations_controller.rb, otp_controller.rb, oauth_controller.rb. Accept all three; you can ignore them if you don't enable the corresponding routes.

The generator patches:

  • config/routes.rb — adds supabase_authentication_routes after Rails.application.routes.draw do. This adds the gem's full route set. Delete your old hand-written resource :session and resources :passwords lines to avoid duplicates. Keep your root to: "..." and any other host routes.
  • app/controllers/application_controller.rb — adds include Authentication (no-op if you already had it from the Rails 8 generator).

Rollback point. Every overwritten file is recoverable via git checkout -- <path>. The supabase_authentication_routes line removes cleanly with git checkout -- config/routes.rb. Don't delete your old resource :session / resources :passwords lines until you've run Step 3 — keeping them paired with the gem's routes will fail loudly (route conflict), which is the desired signal.

Step 3. Decide on the shadow User model

You have two choices. Pick before deleting anything from your existing User.

Option A — Keep your existing users table (recommended if any domain model has belongs_to :user or you store per-user columns beyond what Supabase carries).

bin/rails g supabase:user_model

The generated migration assumes a fresh users table with a UUID PK:

class CreateSupabaseUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users, id: :uuid do |t|
      t.string :email
      t.timestamps
    end
  end
end

Edit this migration to alter the existing users table instead. The non-trivial piece is converting the PK from bigint to uuid:

class MigrateUsersToSupabaseShadow < ActiveRecord::Migration[8.0]
  def change
    # Required for :uuid PKs on self-hosted Postgres. Supabase projects
    # have pgcrypto on by default.
    enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")

    add_column :users, :supabase_id, :uuid

    # Backfill: provision each existing AR user in Supabase via the admin
    # API (server-side, using SUPABASE_SECRET_KEY), then set supabase_id
    # to the returned auth.users.id. See https://supabase.com/docs/reference/javascript/auth-admin-createuser.
    # Done as a one-off task; do NOT inline it here in a production
    # migration — the API call is rate-limited.

    # Then swap the PK (in a follow-up migration after every row has a
    # supabase_id), or keep the old bigint PK and add a NOT-NULL UNIQUE
    # supabase_id column that domain models reference via foreign_key:
    # { column: :supabase_id, primary_key: :supabase_id }.
  end
end

Then write a one-off rake task to:

  1. For each existing AR User, create a corresponding Supabase user via the admin API (server-side, using SUPABASE_SECRET_KEY) with the same email.
  2. Backfill the Supabase user id (UUID) onto the AR row as supabase_id (or as the new PK, depending on the schema choice above).
  3. Notify users to reset their passwords (passwords can't be migrated; see the callout above).

The generated User model looks like:

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

If you kept your old PK and added supabase_id, change find_or_create_by! to key off supabase_id:

def self.from_supabase(claims)
  find_or_create_by!(supabase_id: claims["sub"]) do |u|
    u.email = claims["email"]
  end
end

Option B — Drop the AR User and rely on the value object (recommended if your app has no per-user data beyond what lives in Supabase).

Don't run supabase:user_model. Current.user becomes a Supabase::Rails::User value object (#id, #email, #role, #user_metadata). Drop any belongs_to :user reflections from your domain models, or change them to store the user UUID as a plain :uuid column. Useful when your app is mostly auth + per-user feature flags with no per-user content tables.

Rollback point. Until you alter the AR PK or drop the users table, this step is reversible. If you went with Option A and rolled the column addition forward, you can roll back the migration with bin/rails db:rollback — no Supabase data is touched. If you ran the admin-API backfill and want to revert, delete the provisioned Supabase users via the admin API (or from the dashboard) to keep the two systems in sync.

Step 4. Remove has_secure_password and old authentication code

If you went with Option A:

# app/models/user.rb (after editing the generated file from Step 3)
class User < ApplicationRecord
  self.primary_key = :id              # or :supabase_id if you kept the bigint PK
  # has_secure_password               # ← remove
  # validates :email, presence: true, uniqueness: true  # Supabase owns email uniqueness

  def self.from_supabase(claims)
    find_or_create_by!(id: claims["sub"]) do |u|
      u.email = claims["email"]
    end
  end
end

Drop the password_digest column once the migration has run for every user (do this in a follow-up release, not the cutover release — see Step 8):

class RemovePasswordDigestFromUsers < ActiveRecord::Migration[8.0]
  def change
    remove_column :users, :password_digest, :string
  end
end

Delete the AR-backed Session model and its table — :web mode keeps session state in the encrypted cookie, not the database:

class DropSessions < ActiveRecord::Migration[8.0]
  def change
    drop_table :sessions
  end
end
rm app/models/session.rb

Delete the password mailer and its views — Supabase Auth sends the recovery email:

rm app/mailers/passwords_mailer.rb
rm -r app/views/passwords_mailer

(Keep the mailer files if PasswordsMailer is the only ActionMailer::Base subclass in your app and you want to retain it as a parent class for future mailers; rename it to ApplicationMailer in that case.)

Gotcha — encrypted attributes. If your User model uses encrypts :email or any other encrypts call from ActiveRecord::Encryption, the stored ciphertext is keyed against Rails.application.credentials.active_record_encryption, not against secret_key_base. Migration to the shadow model preserves the row, so the existing ciphertext is still readable — but the from_supabase block sets u.email = claims["email"], which will re-encrypt with the current key. Verify your active_record_encryption.primary_key rotation policy hasn't dropped the historical key before backfilling, otherwise existing rows will become unreadable mid-migration. If you can't read the old encrypts :email, drop the column from the shadow model and read the email from Current.user.email (which is the verified-JWT-claim value) instead.

Rollback point. Until you run RemovePasswordDigestFromUsers, the old auth path is still usable in :api-mode tests — User.authenticate_by(email:, password:) works against the surviving password_digest. This is what makes a staggered rollout safe (see Step 8). Once the column is dropped, rolling back requires restoring the migration AND issuing recovery emails (you cannot recover the hashes).

Step 5. Move your overrides into the new concern

Anything you added inside the Rails-8 Authentication concern body — typically after_authentication_url, request_authentication, or custom rescue handlers — goes into the new app/controllers/concerns/authentication.rb after the include Supabase::Rails::Authentication line:

module Authentication
  extend ActiveSupport::Concern

  included do
    include Supabase::Rails::Authentication
  end

  private

  def after_authentication_url
    Current.user.admin? ? admin_dashboard_path : root_path
  end
end

If you had a custom request_authentication (the Rails 8 concern's redirect-to-login fallback) it still works — the gem's concern defines the same method name and calls it from require_authentication. See Authentication helpers for the full list of override hooks.

Session-token compatibility

The Rails 8 concern's Current.session was a Session AR record (Current.session.user_id, Current.session.user_agent). The gem's Current.session is not uniformly typed — it's the verified JWT claims Hash on the resume path and a Supabase::Auth::Types::Session struct after start_new_session_for. Any helper that read Current.session.user_agent or Current.session.id will raise NoMethodError; rewrite it to read request.user_agent (for the User-Agent) or to use Current.user.id (for the session subject). The Authentication reference lists both shapes explicitly.

Step 6. Update views

The gem's controllers look up supabase/rails/sessions/new, supabase/rails/passwords/new, etc. — not the Rails 8 top-level paths (sessions/new.html.erb). Two paths forward:

Recommended — run bin/rails g supabase:views. This writes the gem defaults under app/views/supabase/rails/ (8 files: sessions/new, registrations/new, passwords/{new,edit}, otp/{new,verify}, oauth/_buttons, shared/_flash). The form anchors match the Rails 8 baseline, so most CSS / class-name customisations port verbatim. Re-apply your changes file-by-file. See the views generator for the override-precedence mechanics.

Alternative — keep your existing top-level views. Override the controller actions to render the existing template paths:

class SessionsController < Supabase::Rails::SessionsController
  def new
    render template: "sessions/new"   # your existing app/views/sessions/new.html.erb
  end
end
class PasswordsController < Supabase::Rails::PasswordsController
  def new
    render template: "passwords/new"
  end

  def edit
    render template: "passwords/edit"
  end
end

Either path: confirm your forms preserve the view customisation contractform_with url: session_path (URL helper), :email / :password field names, maxlength: 72 on the password field, and render "supabase/rails/shared/flash" for flash messages.

Gotcha — the :token URL segment. The Rails 8 passwords#edit action read params[:token], decoded it with User.find_by_password_reset_token!, and assigned @user for the form. The gem's passwords#edit does not decode the token — the recovery session is authenticated by Supabase's signed cookie (set when the user clicked the email link). The :token URL segment is opaque to your code. If you'd customised edit to look up @user.email, remove that lookup — Current.user.email is populated from the recovery session and renders fine. See PasswordsController for the full action body.

Rollback point. All view changes are file edits and git checkout -- app/views reverts them. The controller-override fallback above lets you ship the cutover release with existing views intact and migrate them in a follow-up release.

Step 7. Adjust your test suite

Rails 8 tests typically sign users in via a sign_in_as(user) helper that posts to session_path with the AR user's plaintext password (from a fixture):

# Rails 8 — typical pattern
def sign_in_as(user)
  post session_path, params: { email_address: user.email, password: "secret" }
end

This path runs through User.authenticate_by(...), which is gone after Step 4. Two options:

Option A — Stub the credential pipeline. The gem's middleware calls Supabase::Rails.create_context to build the SupabaseContext per request. Stub it to return a synthetic context for the user under test:

# spec/support/sign_in_as.rb
def sign_in_as(user)
  allow(Supabase::Rails).to receive(:create_context).and_return(
    Supabase::Rails::SupabaseContext.new(
      user_claims: Supabase::Rails::UserClaims.new(sub: user.id, email: user.email, role: "authenticated"),
      jwt_claims:  { "sub" => user.id, "email" => user.email, "role" => "authenticated", "aud" => "authenticated" },
      access_token: "test-access-token",
      supabase:     instance_double(Supabase::Client),
      supabase_admin: instance_double(Supabase::Client),
    ),
  )
end

This bypasses Supabase entirely — the verified-JWT path is short-circuited at the middleware boundary. See SupabaseContext for the full field shape.

Option B — Drive the real flow against a test Supabase instance. Run supabase start locally, provision a test user via the admin API in a before(:suite) hook, and post to session_path with the live credentials. Slower but exercises the real cookie / refresh / JWKS path. Required for any test that asserts on the response to an expired or refreshed token.

System tests that drive a real browser (Capybara, Cuprite, Selenium) need the same change — either stub at the middleware boundary, or use a real test Supabase instance.

Gotcha — fixture passwords. Rails 8 fixtures often inline plaintext passwords for User records. After removing has_secure_password, the column is gone — but if you still load AR fixtures referencing it, the fixture loader raises ActiveRecord::Fixture::FixtureError. Remove the password / password_digest columns from your fixtures and any password: "secret" references in fixture YAMLs.

Rollback point. Test suite changes are reversible via git. Keep the old sign_in_as helper in a commit so a forced revert to the previous-release branch still passes — the test layer is the only thing that gives you a usable signal during the cutover deploy.

Step 8. Production rollout

Plan a two-release rollout so each step is independently reversible:

Release 1 — cutover.

  • Ship the gem install, supabase:install, the shadow User migration (Option A) or domain-model edits (Option B), the new concern + controllers + views, and the test-suite changes.
  • Keep the password_digest column. Don't run RemovePasswordDigestFromUsers yet.
  • Communicate to users: "Your sessions will be reset; please sign in again. You'll be prompted to reset your password the first time."
  • Provision Supabase users via the admin-API backfill (Option A) before the deploy so existing accounts can request password resets at sign-in.

Release 2 — cleanup (≥ 1 week later).

  • Ship RemovePasswordDigestFromUsers and DropSessions.
  • Remove the bcrypt gem from Gemfile if nothing else depends on it.
  • Delete PasswordsMailer and app/views/passwords_mailer/ if you kept them as a fallback.

Stagger the deploy / coexistence. With password_digest still on the model in Release 1, you can run both auth paths behind a feature flag if you want incremental cutover. The gem ignores password_digest; the existing User.authenticate_by path can remain available on a ?auth=legacy query toggle during the transition for ops debugging.

Expire existing sessions. The Rails 8 signed user_id cookie is incompatible with the encrypted Supabase session cookie — users will be redirected to /session/new once on first request after deploy. Communicate the password-reset requirement in the same notification.

Rotate secret_key_base if you want to forcibly invalidate every cookie at deploy time (Rails will re-issue new keys on first request). This also re-keys the sb-session cookie's encryption, so existing rows in your encrypted-attribute columns (encrypts :email etc.) MUST be re-encrypted ahead of time — read the ActiveRecord::Encryption key rotation docs before doing this on a production database.

Rollback point. Until you ship Release 2, you can revert to the pre-migration commit. password_digest is intact, has_secure_password re-applies cleanly, and the AR Session table (if still present) makes sign-in work again. After Release 2, full rollback requires restoring password_digest from a backup AND issuing recovery emails for every user — keep a database snapshot from immediately before Release 2 for at least 30 days.

Gotchas

A short index of the gotchas called out inline above, so you can scan before starting:

GotchaWhere it bitesMitigation
Passwords cannot be migratedEvery existing user at first sign-in after cutoverCommunicate the password-reset requirement; queue recovery emails as part of Release 1 (callout near the top of this page).
password_digest removal is one-wayRelease 2 cleanup; no recovery without a DB snapshotStagger removals (Release 1 keeps the column; Release 2 drops it after ≥ 1 week).
Session token incompatibilityFirst request after deploy — every signed-in user gets a 302 to /session/newNotify users; document the expected sign-out as part of the upgrade messaging (Step 8).
Current.session is not an AR recordAny helper reading Current.session.user_agent / .id / .user_idRewrite to request.user_agent and Current.user.id; the Authentication reference documents both shapes.
Encrypted attributes (encrypts :email)Mid-migration if the encryption primary key has rotated; or after secret_key_base rotationVerify your AR encryption deterministic-key + key-derivation-salt rotation policy preserves historical keys before backfilling.
params[:token] is opaque on editCustomised passwords#edit reading params[:token] for user lookupRemove the lookup; the recovery session cookie carries the authentication, Current.user.email is populated.
users table PK type mismatcht.references :user defaults to bigint; UUID-keyed users requires type: :uuidUpdate child-table migrations to t.references :user, type: :uuid, foreign_key: true. Documented in the user_model generator.
Fixtures inlining passwordsActiveRecord::Fixture::FixtureError on test load after password_digest removalRemove password / password_digest keys from test/fixtures/users.yml (or spec/fixtures/).
PasswordsMailer deletionOther mailers inheriting from PasswordsMailer instead of ApplicationMailerAudit ActionMailer::Base subclasses before rm; rename to ApplicationMailer if it's the only parent.
Duplicate routesForgetting to delete resource :session after running supabase:installRoute conflict on boot is the desired signal; delete the old line and re-run.

Verification checklist

Run through this list once after Release 1 in staging, and again on the day-of-deploy in production. Each item maps to a behavioural assertion you can test or eyeball:

Sign-in / sign-out

  • A new Supabase user (provisioned via the admin API or via bin/rails g supabase:views + the sign-up form) can sign in at /session/new with the email + password they were issued.
  • The sb-session cookie is set after sign-in (visible in DevTools → Application → Cookies), is httponly, same_site=lax, and secure in production.
  • terminate_session clears the cookie and Current.user is nil on the next request — verified by clicking the sign-out button rendered from your layout.
  • A subsequent request to a before_action :require_authentication action without the cookie redirects to new_session_path (HTTP 302).

Sign-up

  • /registration/new form renders and POST /registration creates a Supabase user (visible in Supabase dashboard → Authentication → Users).
  • With the project's "Confirm email" toggle off, the response sets sb-session and signs the user in immediately.
  • With "Confirm email" on, the user is emailed a confirmation link and Current.user remains nil until the link is clicked.
  • (Option A only) An AR User row is created via from_supabase on the first sign-in, with id == the Supabase auth.users.id (or supabase_id if you kept the bigint PK).

Password reset

  • /passwords/new form submits to POST /passwords and Supabase emails the recovery link (check Supabase dashboard → Authentication → Logs → "Password Recovery").
  • Clicking the recovery link lands on /passwords/<token>/edit (your edit_password_url) and the form renders without errors.
  • PATCH /passwords/<token> updates the password upstream (verified by signing in with the new password in an incognito window).
  • On success, the redirect goes to new_session_path (Supabase invalidates the recovery session on update — you must sign in again).

Existing user data (Option A only)

  • Current.user.id (UUID, from JWT) maps to the right AR row — User.find(Current.user.id).email == Current.user.email.
  • Existing has_many / belongs_to :user reflections resolve in production. Pick the most-trafficked one (Current.user.posts.first, etc.) and verify it returns the expected rows.
  • Encrypted attributes (encrypts :email etc.) decrypt cleanly on every backfilled row — pick 5 rows at random and read each attribute.

Routes and URL helpers

  • rails routes | grep -E "session|password|registration|otp|oauth" shows the gem's route table (15 rows: 3 for sessions, 2 for registrations, 4 for passwords, 4 for otp, 2 for oauth). See the controllers overview for the full reference.
  • No duplicate session / passwords routes from the original Rails 8 generator.
  • URL helpers from views (new_session_path, edit_password_url(token: t)) render the expected paths.

Tests

  • bin/rails test (or bundle exec rspec) is green.
  • sign_in_as(user) helper signs the test in cleanly — assert authenticated? is true in the next controller action.
  • System tests that drive /session/new end-to-end pass against the real (or stubbed) Supabase context.

Operational

  • Supabase::Rails.logger is wired and emits one log line per sign-in / sign-out attempt (visible in log/production.log). See the logging configuration for the format.
  • The middleware is inserted into the stack (verified by bin/rails middleware | grep Supabase).
  • (:web mode only) secret_key_base is set in production and identical across all replicas — losing it forces every user to sign in again.
  • API endpoints that previously used Bearer JWTs (if any) still authenticate via verify_supabase_auth(mode: :api). See Hybrid :web + :api.

Backout

  • You have a tagged commit of the pre-migration main branch, named explicitly (e.g. pre-supabase-migration-2026-06-14).
  • You have a database snapshot taken immediately before Release 2 (the password_digest drop). Confirm the snapshot is restorable in a staging environment.
  • Your rollback runbook lists: (a) revert to the tagged commit, (b) restore the DB snapshot, (c) issue recovery emails for any users who signed in between Release 2 and the rollback.

If every box above is checked, the migration is complete and the rollback paths in this guide will work if you ever need them.

See also

  • Getting started — the same install path written for fresh Rails apps without the migration baggage.
  • Generatorssupabase:install, supabase:user_model, supabase:views.
  • Authentication — the Authentication concern, override hooks, Current.user, Current.session.
  • Controllers — per-controller action / override reference, full routes table.
  • Views — the customisation contract every form must preserve.
  • Web mode overview — how the cookie session, refresh, and middleware fit together.
  • Configurationconfig.supabase.* keys, env-var resolution, OAuth providers.

On this page