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 artifact | supabase-rails equivalent | Action at migration time |
|---|---|---|
app/controllers/concerns/authentication.rb | Same path, gem version — include Supabase::Rails::Authentication | Overwrite. Re-apply host-specific overrides (after_authentication_url, etc.) after the include (see Step 5). |
app/controllers/sessions_controller.rb | 3-line subclass of Supabase::Rails::SessionsController | Overwrite. Move any create-time analytics into the new subclass and call super. |
app/controllers/passwords_controller.rb | 3-line subclass of Supabase::Rails::PasswordsController | Overwrite. 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 User | Replace. 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 table | Delete. Drop the model and its migration in Step 4. |
app/models/current.rb | Same shape | Skip if you already have attribute :user, :session — the gem's current.rb.tt is identical. |
app/mailers/passwords_mailer.rb | Supabase 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.erb | Supabase project email template (Dashboard → Authentication → Email Templates → "Reset Password") | Delete locally. Edit the copy in the Supabase dashboard instead. |
app/views/sessions/new.html.erb | app/views/supabase/rails/sessions/new.html.erb | Inline 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.erb | app/views/supabase/rails/passwords/{new,edit}.html.erb | Inline at the new path. Re-apply CSS file-by-file. |
Sessions migration (create_sessions) | None — sessions live in the encrypted cookie | Delete 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 used | Optional 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.rb | Folded into supabase_authentication_routes | Delete. Replaced by the single supabase_authentication_routes line the install generator inserts. |
resources :passwords, param: :token in routes.rb | Folded into supabase_authentication_routes | Delete. Same param: :token shape so URL helpers (new_password_path, edit_password_url(token: t)) keep working. |
include Authentication in ApplicationController | Same line, no-op patch | No-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
Currentmodel (app/models/current.rb) — same name, sameattribute :user, :session. - The
Authenticationconcern'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: :tokenURL 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:). Thepassword_digestcolumn on youruserstable is no longer the source of truth andbcryptis no longer load-bearing. - The session cookie now carries a Supabase session payload (
access_token,refresh_token,expires_at,token_type, ...) encrypted withsecret_key_base, not the Rails 8 signeduser_idinteger. - There is no
sessionstable. Session state is the encryptedsb-sessioncookie — no DB lookups per request, noSession.find_by(...)joins to introspect signed-in users. Current.useris aSupabase::Rails::Uservalue object by default. To keep an ARUserrow (soPost belongs_to :userstill resolves), opt into the shadow user model. The shadow table is UUID-keyed to match Supabase'sauth.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:tokenURL segment inedit_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 installSet 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:installThis 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.rb→ overwrite. The gem's concern isinclude Supabase::Rails::Authentication; host-specific overrides go in after the include (see Step 5).app/controllers/sessions_controller.rb→ overwrite (it'll be a 3-line subclass). If you had customisation increate(analytics, audit logs), move it into the new subclass and callsuper. See SessionsController.app/controllers/passwords_controller.rb→ overwrite. Same shape as the Rails 8 version, but the upstream call goes to Supabase. YourPasswordsMaileris no longer in the loop — Supabase sends the recovery email. To keep custom mailer behaviour, overridecreateand skip callingsuper, then callsupabase_reset_password(email:, redirect_to:)yourself.app/models/current.rb→ skip if you already have one with the sameattribute :user, :sessionshape. The gem's version is identical.config/initializers/supabase.rb→ overwrite (it shouldn't exist yet). Editallowed_redirect_origins,oauth_providers, andsession.secureas 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— addssupabase_authentication_routesafterRails.application.routes.draw do. This adds the gem's full route set. Delete your old hand-writtenresource :sessionandresources :passwordslines to avoid duplicates. Keep yourroot to: "..."and any other host routes.app/controllers/application_controller.rb— addsinclude 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_modelThe 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
endEdit 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
endThen write a one-off rake task to:
- For each existing AR
User, create a corresponding Supabase user via the admin API (server-side, usingSUPABASE_SECRET_KEY) with the same email. - Backfill the Supabase user
id(UUID) onto the AR row assupabase_id(or as the new PK, depending on the schema choice above). - 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
endIf 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
endOption 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
endDrop 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
endDelete 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
endrm app/models/session.rbDelete 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
endIf 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
endclass PasswordsController < Supabase::Rails::PasswordsController
def new
render template: "passwords/new"
end
def edit
render template: "passwords/edit"
end
endEither path: confirm your forms preserve the view customisation contract — form_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" }
endThis 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),
),
)
endThis 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 shadowUsermigration (Option A) or domain-model edits (Option B), the new concern + controllers + views, and the test-suite changes. - Keep the
password_digestcolumn. Don't runRemovePasswordDigestFromUsersyet. - 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
RemovePasswordDigestFromUsersandDropSessions. - Remove the
bcryptgem fromGemfileif nothing else depends on it. - Delete
PasswordsMailerandapp/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:
| Gotcha | Where it bites | Mitigation |
|---|---|---|
| Passwords cannot be migrated | Every existing user at first sign-in after cutover | Communicate the password-reset requirement; queue recovery emails as part of Release 1 (callout near the top of this page). |
password_digest removal is one-way | Release 2 cleanup; no recovery without a DB snapshot | Stagger removals (Release 1 keeps the column; Release 2 drops it after ≥ 1 week). |
| Session token incompatibility | First request after deploy — every signed-in user gets a 302 to /session/new | Notify users; document the expected sign-out as part of the upgrade messaging (Step 8). |
Current.session is not an AR record | Any helper reading Current.session.user_agent / .id / .user_id | Rewrite 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 rotation | Verify your AR encryption deterministic-key + key-derivation-salt rotation policy preserves historical keys before backfilling. |
params[:token] is opaque on edit | Customised passwords#edit reading params[:token] for user lookup | Remove the lookup; the recovery session cookie carries the authentication, Current.user.email is populated. |
users table PK type mismatch | t.references :user defaults to bigint; UUID-keyed users requires type: :uuid | Update child-table migrations to t.references :user, type: :uuid, foreign_key: true. Documented in the user_model generator. |
| Fixtures inlining passwords | ActiveRecord::Fixture::FixtureError on test load after password_digest removal | Remove password / password_digest keys from test/fixtures/users.yml (or spec/fixtures/). |
PasswordsMailer deletion | Other mailers inheriting from PasswordsMailer instead of ApplicationMailer | Audit ActionMailer::Base subclasses before rm; rename to ApplicationMailer if it's the only parent. |
| Duplicate routes | Forgetting to delete resource :session after running supabase:install | Route 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/newwith the email + password they were issued. - The
sb-sessioncookie is set after sign-in (visible in DevTools → Application → Cookies), ishttponly,same_site=lax, andsecurein production. -
terminate_sessionclears the cookie andCurrent.userisnilon the next request — verified by clicking the sign-out button rendered from your layout. - A subsequent request to a
before_action :require_authenticationaction without the cookie redirects tonew_session_path(HTTP 302).
Sign-up
-
/registration/newform renders andPOST /registrationcreates a Supabase user (visible in Supabase dashboard → Authentication → Users). - With the project's "Confirm email" toggle off, the response sets
sb-sessionand signs the user in immediately. - With "Confirm email" on, the user is emailed a confirmation link and
Current.userremainsniluntil the link is clicked. - (Option A only) An AR
Userrow is created viafrom_supabaseon the first sign-in, withid== the Supabaseauth.users.id(orsupabase_idif you kept the bigint PK).
Password reset
-
/passwords/newform submits toPOST /passwordsand Supabase emails the recovery link (check Supabase dashboard → Authentication → Logs → "Password Recovery"). - Clicking the recovery link lands on
/passwords/<token>/edit(youredit_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 :userreflections resolve in production. Pick the most-trafficked one (Current.user.posts.first, etc.) and verify it returns the expected rows. - Encrypted attributes (
encrypts :emailetc.) 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/passwordsroutes 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(orbundle exec rspec) is green. -
sign_in_as(user)helper signs the test in cleanly — assertauthenticated?istruein the next controller action. - System tests that drive
/session/newend-to-end pass against the real (or stubbed) Supabase context.
Operational
-
Supabase::Rails.loggeris wired and emits one log line per sign-in / sign-out attempt (visible inlog/production.log). See the logging configuration for the format. - The middleware is inserted into the stack (verified by
bin/rails middleware | grep Supabase). - (
:webmode only)secret_key_baseis 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
mainbranch, named explicitly (e.g.pre-supabase-migration-2026-06-14). - You have a database snapshot taken immediately before Release 2 (the
password_digestdrop). 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.
- Generators —
supabase:install,supabase:user_model,supabase:views. - Authentication — the
Authenticationconcern, 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.
- Configuration —
config.supabase.*keys, env-var resolution, OAuth providers.