supabase-rb-rb
Generators

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

Expected 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.rb

When 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

PathWhat it is
db/migrate/<timestamp>_create_supabase_users.rbReversible 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.rbclass 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
end

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

Two things to notice:

  • self.primary_key = :id — without this, ActiveRecord assumes an integer id even though the column is uuid. The explicit declaration keeps User.find("abc-uuid") and belongs_to :user, primary_key: :id working.
  • 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 stale claims["email"] will never overwrite a row's email column. 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:

TriggerWhat 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 resumeresume_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 email from 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_supabase to 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 columnMirrored fromNotes
users.id (uuid PK)claims["sub"]The Supabase user UUID. Stable for the lifetime of the user.
users.emailclaims["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
end

t.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
end

Current.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 query

Adding 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:migrate

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

claims.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
end

This 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_template action — re-running prompts for overwrite if db/migrate/<timestamp>_create_supabase_users.rb already exists with a different timestamp. Thor's standard [Ynaqdh] overwrite prompt applies (see supabase:install for the full key reference).
  • The app/models/user.rb template uses Thor's template action — 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_file step entirely, even if you've moved the config.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:

  1. Roll back the migrationbin/rails db:rollback STEP=1 (or whichever step the migration occupies). The migration is reversible because create_table automatically reverses to drop_table.
  2. Delete app/models/user.rb.
  3. Delete (or comment out) the Rails.application.config.supabase.user_model = "User" line in config/initializers/supabase.rb. The gem treats user_model = nil as "no shadow model" and Current.user reverts to the default Supabase::Rails::User value object.
  4. Remove any belongs_to :user reflections you added on the assumption of the shadow AR model, or change them to store the user UUID as a plain string/uuid column without a FK constraint.

See also

  • supabase:install — the prerequisite generator that writes the initializer this one patches.
  • Configuration → user_model — the full config.supabase.user_model reference, including the class-vs-string semantics.
  • Authentication — the Authentication concern, Current.user lifecycle, and the Supabase::Rails::User value object that Current.user falls back to when user_model is unset.
  • Generators — the index page for all three generators.

On this page