Migrate auth users

Move Supabase auth users to your own project with UUIDs and bcrypt password hashes preserved, so nobody has to reset their password after the migration.

Why not the Admin API

The obvious path is auth.admin.createUser(). It does not work for a migration. The API accepts a plaintext password and hashes it itself — it has no field for an existing bcrypt hash. If you feed it a hash, it treats that string as the plaintext password. Every user would then need a password reset.

It also mints a fresh UUID per user. Any table with a user_id foreign key into auth.users would break. See foreign-key for why preserving IDs matters.

Insert directly into auth.users

Read from the source with a direct Postgres connection and insert the same rows into the destination. The columns that must survive intact are id (the UUID), encrypted_password (the bcrypt hash), email, and the timestamps.

insert into auth.users (
  id, aud, role, email, encrypted_password,
  email_confirmed_at, created_at, updated_at,
  raw_app_meta_data, raw_user_meta_data
)
values (
  '9f8c...-uuid', 'authenticated', 'authenticated',
  'dev@example.com',
  '$2a$10$...bcrypt...',
  '2025-03-01 12:00:00+00', now(), now(),
  '{"provider":"email","providers":["email"]}', '{}'
)
on conflict (id) do nothing;

encrypted_password is a standard bcrypt string ($2a$...). GoTrue verifies against it unchanged, so passwords keep working. on conflict (id) do nothing makes the step idempotent — safe to re-run.

Do not forget auth.identities

Each user has one or more rows in auth.identities linking them to a provider (email, Google, GitHub). If you copy auth.users but not auth.identities, email/password sign-in still works but login lookups and OAuth links are broken.

insert into auth.identities (
  provider_id, user_id, identity_data, provider,
  last_sign_in_at, created_at, updated_at
)
select provider_id, user_id, identity_data, provider,
       last_sign_in_at, created_at, updated_at
from source_identities
on conflict (provider, provider_id) do nothing;

For older Supabase versions the identities conflict target is (provider, id) — check your destination's constraint before running.

After the insert

Existing JWT tokens issued by the source project will not validate against the destination, because the signing secret differs. Users stay logged in on the source until their session expires; on the destination they authenticate fresh. That is expected — the password hashes are what carry over, not the active sessions.

If an insert fails on a user_id reference, see foreign-key-violation. Next, move file storage: migrate storage.

Edit this page on GitHubLast updated July 13, 2026