Official CLI for FerrisKey IAM.
cargo install ferris-ctl
ferris-ctl realm list ferris-ctl realm create myrealm
ferris-ctl realm import pulls a realm description out of an external system
and replays it against FerrisKey. Available sources: config (a YAML or TOML
file, see examples/realm.yaml), keycloak, zitadel, and supabase.
Add --dry-run to any import to resolve the source and print what would be
created without calling FerrisKey. A dry run needs neither a configured context
nor authentication, so it is the cheapest way to check a mapping.
An import converges: re-running it skips what the realm already has rather than failing or duplicating.
Reads a project through its Auth (GoTrue) Admin API, authenticating with the
project's service_role key.
ferris-ctl realm import \
--from supabase \
--source-url https://<project>.supabase.co \
--source-token <service_role key> \
--target-realm my-realm
Supabase has no realm and no OIDC client, so the import carries users and
their roles, and nothing else. The realm name comes from --target-realm (or
--source-realm) and defaults to supabase.
Usernames are derived from the full email address, falling back to the phone number and then to the Supabase user id, since Supabase users have no username of their own.
Passwords are carried over when --source-passwords points at a CSV export of
the auth.users table:
ferris-ctl realm import \
--from supabase \
--source-url https://<project>.supabase.co \
--source-token <service_role key> \
--source-passwords ./auth_users.csv \
--target-realm my-realm
The export is needed because the Auth Admin API never serves password hashes:
they live only in auth.users.encrypted_password.
From the dashboard SQL editor, run the query and use the download button above the results:
select id, encrypted_password from auth.users;
Or with psql, which is the better option on a large directory:
psql "<connection string>" -c \
"\copy (select id, encrypted_password from auth.users) to 'auth_users.csv' with (format csv, header)"
The connection string sits behind the project's Connect button. Pick the
Session pooler one if your machine has no IPv6: the direct connection
(db.<project-ref>.supabase.co:5432) is IPv6-only unless the project has the
IPv4 add-on, while the pooler is IPv4 on every plan.
The backslash in \copy is not cosmetic. \copy is a psql command and writes
the file on your machine; a plain COPY … TO is a server-side statement that
a managed Supabase instance will not let you run.
If neither route is open to you — permissions, or a directory too large to pull
yourself — Supabase support can produce the auth.users export on request.
Only id and encrypted_password, and extra columns are ignored — a plain
select * export works as-is, so an export you already have need not be redone.
A UTF-8 BOM on the header line is tolerated, which the SQL editor's download
emits.
Rows are joined onto users by auth.users.id, never by email: an email is
nullable in Supabase and is therefore not a key.
The file holds every password hash in the directory. bcrypt is slow to attack, but this is still authentication material: keep the file to yourself, and delete it once the import has run.
FerrisKey stores the bcrypt hash verbatim and re-encodes it as argon2id on the user's first successful login, so the migration is invisible to the end user and leaves nothing legacy behind.
A hash FerrisKey would refuse never leaves the CLI. It is skipped with a note naming the account, and the import carries on:
| Skipped | Why |
|---|---|
A prefix other than $2a$, $2b$, $2y$ |
FerrisKey accepts no other bcrypt variant, and $2x$ is a known-broken one |
A cost outside 4..=14 |
Outside the window FerrisKey accepts on import |
| A hash body that is not 53 characters | Truncated in the export |
An empty encrypted_password |
Not an error: the account signs in through a federated provider and has no password |
Argon2 and Firebase-scrypt hashes — which a project that itself imported users into Supabase may hold — are not carried over yet, even though FerrisKey accepts argon2. Those accounts need a password reset.
A user who already has a password in FerrisKey keeps it: the import reports the
clash in already present rather than overwriting a credential somebody set
deliberately.
--dry-run prints the password count and replaces every hash with
<redacted> in its -o json / -o yaml output, so a preview can be pasted into
a ticket without leaking the directory's credentials.
--source-passwords only applies to --from supabase; passing it to another
source is an error rather than a silently ignored flag. Like the account filters
below, it is never stored in a saved source — carrying passwords is a per-run
decision.
--source-preserve-ids creates each user with the id it already has in
Supabase, instead of letting FerrisKey mint a new one:
ferris-ctl realm import \
--from supabase \
--source-url https://<project>.supabase.co \
--source-token <service_role key> \
--source-preserve-ids \
--target-realm my-realm
That id becomes the sub claim of every token FerrisKey issues. A business
database that stores auth.users.id as a foreign key therefore keeps working
across the migration; without the flag, every one of those keys points at an
account that no longer exists under that id.
Reusing a Supabase id is not a reassignment: OpenID Connect scopes sub
uniqueness to the issuer, and the issuer changes from
https://<ref>.supabase.co/auth/v1 to .../realms/<realm>. The same rule is
why the flag only affects accounts the import creates — a sub is never
reassigned, so a user the target realm already holds keeps the id it was given,
and the import reports it under already present.
The server has to accept a supplied id. One that does not silently mints its own, so the import compares what came back against what it asked for and stops on the first account that does not match, rather than migrating a whole directory onto new subjects. An id already taken elsewhere in the instance — possibly in a realm the token cannot see — stops it the same way, naming the account.
Like --source-passwords, the flag is Supabase-only and is never stored in a
saved source.
Supabase has no role catalogue. Roles are read from each user's app_metadata,
which is where applications conventionally keep them — either as a roles array
or as a single role string. Both are read and merged:
"app_metadata": { "provider": "email", "roles": ["admin", "billing"] }
"app_metadata": { "provider": "email", "role": "admin" }
Every distinct role named by an imported user becomes a realm role. The catalogue is built from the users that survived the filters above, so a role held only by a soft-deleted or unconfirmed account is not created.
Supabase attaches no description or permission to a role, so imported roles carry a name and nothing else. Permissions have to be granted in FerrisKey afterwards.
The top-level user.role field is not imported. It is the Postgres RLS
role, authenticated for virtually every account, and importing it would create
a single realm role held by the entire directory.
A role name containing : is rejected with an error naming the role and the
user. A realm blueprint reserves that character for client-scoped roles
(client_id:role_name), and Supabase defines no clients, so such a name cannot
be expressed. Rename it in app_metadata before importing.
Three kinds of account are dropped by default, each re-enabled by its own flag:
| Flag | Keeps |
|---|---|
--source-include-deleted |
Accounts an operator soft-deleted (deleted_at set, row still present) |
--source-include-anonymous |
Anonymous sign-in sessions, which have neither email nor phone |
--source-include-unconfirmed |
Accounts that never confirmed an email or a phone number |
--source-include-anonymous is not undone by the confirmation filter: an
anonymous account has nothing to confirm, so it is governed by that flag alone.
Connection details can be stored once and referenced by name:
ferris-ctl source add supa --kind supabase \
--url https://<project>.supabase.co --token <service_role key>
ferris-ctl realm import --source-ref supa --target-realm my-realm
Inline --source-* flags override individual fields of a stored source. The
Supabase account filters above are deliberately not stored: they are per-run
choices, so a saved source can never silently widen a later import.