From Supabase

Supabase is the one source that is users only. It has no client catalogue and no role catalogue to read, so an import brings the accounts across and nothing else — you create the clients and the realm settings yourself afterwards.

ferris-ctl realm import --from supabase \
  --source-url https://abcdefgh.supabase.co \
  --source-token "$SUPABASE_SERVICE_ROLE_KEY" \
  --target-realm acme

Accounts are read through the project’s Auth (GoTrue) Admin API, which is why --source-token wants the project’s service_role key rather than the anon key.

What gets carried over

FerrisKey fieldComes from
usernameThe email. Falling back to the phone number, then to the Supabase id
emailThe email, when there is one
email_verifiedWhether email_confirmed_at is set — only computed for users who have an email
firstname / lastnameuser_metadata, trying first_name, firstName, given_name and the matching last-name keys; failing that, full_name or name split in two
Rolesapp_metadata.roles (an array) and app_metadata.role (a single string), merged and de-duplicated
PasswordOnly from --source-passwords, below

Roles are created in the target realm as they are encountered, so a roles array in app_metadata is enough to rebuild a role catalogue Supabase never had.

A role name cannot contain a colon

FerrisKey reserves : to separate a client from its role, so a Supabase role like billing:admin is refused rather than silently reinterpreted. The import stops and names both the role and the user carrying it. Rename it in app_metadata before re-running.

Three kinds of row are dropped by default

Supabase’s auth.users holds rows that are not really accounts you want. All three filters are off by default, and turning one on is a deliberate act:

FlagWhat it lets inWhy it is off by default
--source-include-deletedUsers an operator soft-deletedTheir deleted_at is set but the row survives. A plain import would resurrect accounts somebody removed on purpose
--source-include-anonymousAnonymous sign-in sessionsThey are real rows with neither an email nor a phone number
--source-include-unconfirmedUsers who never confirmed an email or a phoneNothing proves the address or the number belongs to them

A user counts as confirmed when either email_confirmed_at or phone_confirmed_at is set.

Count before you commit

Run the import with --dry-run first, then again with a filter added, and compare the user counts. That difference is exactly how many soft-deleted, anonymous or unconfirmed rows your project is carrying — usually more than anyone expects.

Carrying the passwords over

The Auth Admin API never serves password hashes. Exporting the table is the only way to get them, so --source-passwords takes a CSV file rather than reading the API:

ferris-ctl realm import --from supabase \
  --source-url https://abcdefgh.supabase.co \
  --source-token "$SUPABASE_SERVICE_ROLE_KEY" \
  --source-passwords auth_users.csv \
  --target-realm acme

Producing the export

The connection string is behind the project’s Connect button:

psql "<connection string>" -c \
  "\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)"

The backslash on \\copy matters

\copy runs client-side and writes the file on your machine. A plain COPY TO would try to write on the database server, where you have no filesystem access.

Or run select id, encrypted_password from auth.users; in the dashboard’s SQL editor and use the download button above the results.

Only id and encrypted_password are read and extra columns are ignored, so a plain select * export works too. Rows join onto users by auth.users.id, never by email — which is what makes the join safe when an address has changed on one side.

That file holds every password hash in your directory

Keep it to yourself, never commit it, and delete it once the import has run. It is the single most sensitive artefact a migration produces.

What survives, and what does not

Only bcrypt hashes that FerrisKey accepts are imported. Every other account arrives without credentials and needs a password reset — so plan the reset emails for that subset rather than for everyone.

An imported hash is re-encoded as argon2id the first time that user signs in successfully. See Export & Import for what that means on the FerrisKey side, including the password_imported audit event that lets you measure migration progress.

Keeping the user ids

ferris-ctl realm import --from supabase … --source-preserve-ids

Creates each user with the id it already has in Supabase, so the sub claim of every token survives the migration. Supabase only.

Reach for it when anything downstream stores the Supabase user id as a foreign key — your own tables, an analytics warehouse, a billing system. Without it those references all break and you are writing a mapping table.

A realistic migration

Preview, and read the counts

ferris-ctl realm import --from supabase \
  --source-url https://abcdefgh.supabase.co \
  --source-token "$SUPABASE_SERVICE_ROLE_KEY" \
  --target-realm acme --dry-run -o yaml

No FerrisKey call is made. Check the user count and the roles that were derived from app_metadata.

Decide on the filtered rows

Re-run the preview with each --source-include-* flag to see what you are leaving behind, and make that a decision rather than a default.

Export the passwords

Only once you are about to run the real import — the file should exist for as short a time as possible.

Import

Add --source-passwords and, if anything references the Supabase ids, --source-preserve-ids. Delete the CSV afterwards.

Build what Supabase did not have

Clients, redirect URIs, scopes and realm settings. Connect an Application with SSO is the path for each application.

Re-running is safe

Like every source, a Supabase import is idempotent: accounts that already exist are skipped with a warning, so you can fix the source and run it again.

Storing the connection

Typing the service_role key on every run is how it ends up in a shell history. Store the source once:

ferris-ctl source add supabase-prod --kind supabase \
  --url https://abcdefgh.supabase.co \
  --token "$SUPABASE_SERVICE_ROLE_KEY"

ferris-ctl realm import --source-ref supabase-prod --target-realm acme

Inline --source-* flags still override individual fields of a stored source, so the stored entry can hold the connection while the run decides the filters. See source.