supabase:user_model
Opt-in shadow ActiveRecord User model + UUID-keyed Rails migration that mirrors Supabase auth, so belongs_to :user resolves like any other AR association.
bin/rails generate supabase:user_model adds an opt-in shadow User ActiveRecord model whose primary key is the Supabase user UUID. It emits a users migration (UUID PK, email column, timestamps), writes app/models/user.rb with a User.from_supabase(claims) upsert helper, and patches config/initializers/supabase.rb to set config.supabase.user_model = "User".
Once that config key is set, Current.user is the AR record returned by User.from_supabase(claims) instead of the default Supabase::Rails::User value object — so belongs_to :user reflections on the rest of your domain models Just Work.
bin/rails generate supabase:user_model
bin/rails db:migrateExpected output on a fresh Rails app that has already run supabase:install:
create db/migrate/<timestamp>_create_supabase_users.rb
create app/models/user.rb
append config/initializers/supabase.rbWhen to run this
Run it when your app has per-user domain data (posts, projects, subscriptions, etc.) and you want to keep AR-style belongs_to :user / has_many reflections. Skip it when the only user data your app needs is what Supabase already stores — the default value-object Current.user is faster (no DB row, no find_by) and zero-maintenance.
Files created
| Path | What it is |
|---|---|
db/migrate/<timestamp>_create_supabase_users.rb | Reversible migration: create_table :users, id: :uuid with email:string + timestamps. UUID PK matches Supabase's auth.users.id so JWT sub claims drop straight in. |
app/models/user.rb | class User < ApplicationRecord with self.primary_key = :id and a User.from_supabase(claims) upsert class method. |
Generated file contents
The migration (db/migrate/<timestamp>_create_supabase_users.rb):
# frozen_string_literal: true
class CreateSupabaseUsers < ActiveRecord::Migration[7.1]
def change
create_table :users, id: :uuid do |t|
t.string :email
t.timestamps
end
end
endThe :uuid primary-key type requires pgcrypto (or uuid-ossp) to be enabled on your Postgres database. Supabase projects enable pgcrypto by default, so no extra step is needed there; on a self-hosted Postgres add enable_extension "pgcrypto" to an earlier migration if it isn't already present.
The migration timestamp prefix comes from UserModelGenerator.next_migration_number, which mirrors Rails' built-in Time.now.utc.strftime("%Y%m%d%H%M%S") so the generator works even when ActiveRecord isn't loaded.
The model (app/models/user.rb):
# frozen_string_literal: true
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
endTwo things to notice:
self.primary_key = :id— without this, ActiveRecord assumes an integerideven though the column isuuid. The explicit declaration keepsUser.find("abc-uuid")andbelongs_to :user, primary_key: :idworking.- The block on
find_or_create_by!only fires on the create path. On the resume of an existing user the block is skipped, so a staleclaims["email"]will never overwrite a row'semailcolumn. See Sync model below.
Files modified
One file is patched in place, guarded by a regex so re-running the generator is a no-op.
config/initializers/supabase.rb
A single line is appended to the end of the initializer:
# config/initializers/supabase.rb
Rails.application.config.supabase.mode = :web
# ...existing config...
+
+Rails.application.config.supabase.user_model = "User"The patch is skipped when the file already contains a non-commented config.supabase.user_model = assignment (matched by /^\s*[^#\n]*config\.supabase\.user_model\s*=/), or when config/initializers/supabase.rb does not exist (e.g. you haven't run supabase:install yet).
config.supabase.user_model accepts either a class name string ("User") or the class itself. The string form is what the generator writes so the model can be autoloaded lazily — see Configuration → user_model for the full semantics.
Sync model
The shadow users table mirrors a small slice of Supabase's auth.users. The sync is lazy and read-only from Supabase's side — the gem never writes back to Supabase, and your local row is reconciled with JWT claims only at well-defined moments.
When local records are created
A local users row is inserted the first time User.from_supabase(claims) is called for a Supabase user UUID it hasn't seen before. That happens in two places in the Authentication concern:
| Trigger | What runs |
|---|---|
Sign-in success (start_new_session_for) | The concern calls build_current_user(session) which calls User.from_supabase(jwt_claims_from(session)). On a brand-new user the row is INSERT-ed with id = claims["sub"] and email = claims["email"]. |
| Subsequent request resume | resume_session calls current_user_from_context(ctx) which calls User.from_supabase(ctx.jwt_claims). Same code path — find_or_create_by! is a cheap by-PK lookup on the hot path, and only inserts when an existing row is missing (e.g. you cleared the local DB but the user's encrypted session cookie is still valid). |
find_or_create_by! is racy on its own, but id is the primary key, so a duplicate insert raises a unique-constraint error rather than producing two rows — and the next call resolves to the now-existing row. For high-concurrency sign-in flows you can wrap the upsert in transaction { … } or replace find_or_create_by! with a Postgres INSERT … ON CONFLICT DO NOTHING upsert.
When local records are updated
Never automatically. The generated from_supabase updates the row only via the block on find_or_create_by!, which Rails skips entirely when the record already exists. This is intentional:
- The user's row in Supabase is the source of truth — overwriting your local
emailfrom JWT claims would lose any column you added locally (display name, preferences, etc.) if it got out of sync. - If you want fields to be re-mirrored on every request, override
from_supabaseto do so explicitly. See Customising the User model.
What fields mirror Supabase
The generated users table mirrors only id and email. Everything else available on the verified JWT — role, app_metadata, user_metadata, raw claims — stays on the supabase_context value object and is not copied into the row.
| Local column | Mirrored from | Notes |
|---|---|---|
users.id (uuid PK) | claims["sub"] | The Supabase user UUID. Stable for the lifetime of the user. |
users.email | claims["email"] | Set on create only — see above. May go stale if the user changes their email in Supabase and your code doesn't re-sync. |
users.created_at | (local Rails timestamp) | When the local row was inserted — typically the user's first request after the shadow model was enabled. NOT auth.users.created_at. |
users.updated_at | (local Rails timestamp) | Bumped only if your code explicitly updates the row. |
If you need the full claim set on every request, read it from supabase_context.jwt_claims (the raw verified payload) or supabase_context.current_user (the Supabase::Rails::User value object — still built when user_model is set, just not promoted to Current.user). See Authentication for the full helper surface.
Querying and associating with the local User
With config.supabase.user_model = "User" set, Current.user is the AR record, so all of the usual ActiveRecord patterns work without ceremony.
belongs_to :user on your domain models
The local users.id column is a uuid, so any model with a user_id reflection needs to store the FK as uuid too:
# db/migrate/<later>_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.1]
def change
create_table :posts, id: :uuid do |t|
t.references :user, type: :uuid, foreign_key: true, null: false
t.string :title, null: false
t.text :body
t.timestamps
end
end
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
endt.references :user, type: :uuid, foreign_key: true is the important detail — the default t.references :user creates a bigint FK column and the FK constraint to users(id) will fail to build because the parent column is uuid.
Reading and writing with Current.user
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def create
post = Current.user.posts.create!(post_params)
redirect_to post
end
def index
@posts = Current.user.posts.order(created_at: :desc)
end
private
def post_params
params.require(:post).permit(:title, :body)
end
endCurrent.user is the same User AR record Post.belongs_to :user resolves to, so the associations chain like any other AR setup.
Querying directly
User.find(Current.user.id) # PK lookup, hits the unique uuid index
User.where(email: "alice@example.com").first
User.includes(:posts).find(uuid) # eager-load like any AR queryAdding scopes and validations
Because User < ApplicationRecord, scopes, validations, and callbacks behave normally — there are no magic methods the gem injects. Add a validates :email, presence: true if you want to enforce the email column non-null at the AR layer (the migration leaves it nullable).
Customising the User model
The generated User is intentionally minimal so hosts can add columns and associations.
Adding columns
Generate a follow-up migration in the usual way:
bin/rails generate migration AddProfileToUsers display_name:string avatar_url:string
bin/rails db:migrateThen teach from_supabase to mirror the new fields on the create path (and decide whether you also want to re-sync them on each request — see below):
class User < ApplicationRecord
self.primary_key = :id
has_many :posts, dependent: :destroy
def self.from_supabase(claims)
find_or_create_by!(id: claims["sub"]) do |u|
u.email = claims["email"]
u.display_name = claims.dig("user_metadata", "full_name")
u.avatar_url = claims.dig("user_metadata", "avatar_url")
end
end
endclaims.dig("user_metadata", "full_name") is the safe pattern — user_metadata is nil for users who haven't set anything, and claims is just the raw verified JWT payload, so dig short-circuits gracefully.
Re-syncing fields on every request
If you want a field to track changes in Supabase, do the update explicitly outside the find_or_create_by! block. The generated from_supabase is the override point:
def self.from_supabase(claims)
user = find_or_create_by!(id: claims["sub"]) do |u|
u.email = claims["email"]
end
# Re-sync email on every request — overwrites local changes.
user.update!(email: claims["email"]) if user.email != claims["email"]
user
endThis adds a write on every request when claims drift, so use it sparingly. If you need full Supabase parity (not just claims parity), call supabase_context.supabase.auth.get_user for the live Supabase::Auth::Types::User instead.
Re-running and rolling back
The generator is safe to re-run:
- The migration step uses Rails'
migration_templateaction — re-running prompts for overwrite ifdb/migrate/<timestamp>_create_supabase_users.rbalready exists with a different timestamp. Thor's standard[Ynaqdh]overwrite prompt applies (seesupabase:installfor the full key reference). - The
app/models/user.rbtemplate uses Thor'stemplateaction — prompts on overwrite if the file has diverged from the template. - The initializer patch's regex guard means a second run skips the
append_to_filestep entirely, even if you've moved theconfig.supabase.user_model = "User"line elsewhere in the file (the regex matches any non-commented assignment).
There is no supabase:user_model uninstall. To roll back manually:
- Roll back the migration —
bin/rails db:rollback STEP=1(or whichever step the migration occupies). The migration is reversible becausecreate_tableautomatically reverses todrop_table. - Delete
app/models/user.rb. - Delete (or comment out) the
Rails.application.config.supabase.user_model = "User"line inconfig/initializers/supabase.rb. The gem treatsuser_model = nilas "no shadow model" andCurrent.userreverts to the defaultSupabase::Rails::Uservalue object. - Remove any
belongs_to :userreflections you added on the assumption of the shadow AR model, or change them to store the user UUID as a plainstring/uuidcolumn without a FK constraint.
See also
supabase:install— the prerequisite generator that writes the initializer this one patches.- Configuration →
user_model— the fullconfig.supabase.user_modelreference, including the class-vs-string semantics. - Authentication — the
Authenticationconcern,Current.userlifecycle, and theSupabase::Rails::Uservalue object thatCurrent.userfalls back to whenuser_modelis unset. - Generators — the index page for all three generators.
supabase:install
The main supabase-rails generator — what it writes, what it patches, the options it accepts, and what to do next.
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.