<!-- https://docs.elchi.dev/migrating | EAuth | Elchi Docs -->

# Migrating from another provider

Bring your users across with their password hashes intact. Nobody resets
anything, and nobody notices the change.

## Why this matters

An export that omits password hashes forces every user to reset. A measurable
fraction never come back, which is why several providers restrict hash export:
it keeps customers by making departure expensive rather than by being better.

We accept the common formats, verify against them on first sign-in, and replace
the stored value with Argon2id at that moment. After one login the foreign hash
is gone.

## Supported formats

| Format | Recognised by | Typical source |
|---|---|---|
| Argon2id | `$argon2id$` | modern frameworks, and us |
| bcrypt | `$2a$`, `$2b$`, `$2y$` | Auth0, Rails, Laravel, most PHP |
| scrypt | `$scrypt$` | Node, Firebase |
| PBKDF2-SHA256 | `pbkdf2_sha256$` | Django, several frameworks |

Detection is by shape, so your export needs one column of hashes and no
metadata about which algorithm produced them.

## The import

In the console, open your application, then **Users** and **Import accounts**.
The owner and admins of an application can import; everybody on its team
sees the accounts.

The file is a JSON array:

```json
[
  {
    "email": "anna@example.com",
    "display_name": "Anna Muster",
    "password_hash": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
    "email_verified": true
  }
]
```

or a CSV with a header row naming `email` and `password_hash`, and optionally
`display_name` and `email_verified` (`true`, `1` or `yes`). A JSON row may
carry `id`, the account's id at an earlier EAuth, kept as its `sub` (see
below). Up to 100,000
accounts and 25 MB per file. An account imported with `email_verified` false
is asked to confirm its address the first time it signs in, before your
application gets it.

The console checks the file first and shows what it found: how many accounts,
in which hash formats. Nothing is written until you confirm. The batch is
validated in full and rejected as a whole if any row fails, including a row
whose address already has an account at your application. A partially
imported user base is worse than a failed import, because you cannot tell
which half is missing without comparing row by row against the source.

The report names the row and the reason:

```
row 4: "not-an-email" is not an email address
row 9: "anna@example.com" already appeared on row 2
row 12: "ben@example.com" already has an account at this application
row 17: unrecognised hash format, supported are argon2id, bcrypt,
  scrypt and pbkdf2-sha256
```

**From another EAuth.** The file the console's Export everything makes,
here or on a self-hosted installation, imports as it is. Its accounts keep
their ids, which your application knows as `sub`, so nothing keyed on `sub`
has to change; an id that already belongs to an account on the receiving
installation is named in the report, and leaving it out gives that account
a new one. Accounts that signed in through single sign-on or with passkeys
only have no password there and come without one; they set one with a
password reset. Organisations, their members and roles, consents and
webhooks in the export are not imported: set them up again in the console.

Imported accounts sign in with their old password from the moment the
import finishes. The list in the console marks the ones still on their
imported hash; the mark goes away with their first sign-in. Nothing is
sent to your webhook for an import; your system already knows these people.

## Getting the file out of the provider you leave

What each provider gives you, as their documentation described it on 28
September 2026, and how it maps onto the import above. Check the page
before you rely on it; export rules change.

**Auth0.** The self-service bulk export (Management API, `users-exports`
job) carries no password hashes. The hashes come from a support case: you
supply a 4096-bit PGP public key, a second tenant administrator confirms,
and a CISO or an executive at vice-president level signs an acknowledgment
("typed names are not accepted"); the download link expires after three
days. The file is JSON with `email`, `email_verified`, `name` and a bcrypt
hash in a field named for the password hash; rename that field to
`password_hash` and `name` to `display_name`, and the import above
accepts it.
([Auth0: export password hashes](https://auth0.com/docs/manage-users/user-migration/export-password-hashes-and-mfa-secrets))

**Clerk.** Self-service: Dashboard, Settings, User exports, a CSV for
admins that includes the hashed passwords (bcrypt). Rename the address
column to `email` and the digest column to `password_hash`, set
`email_verified` from the verification column, and import the CSV.
([Clerk: migrating](https://clerk.com/docs/guides/development/migrating/overview))

**Supabase Auth.** Your database is yours: `select email,
encrypted_password as password_hash, email_confirmed_at is not null as
email_verified from auth.users` gives the CSV; the hashes are bcrypt.
([Supabase discussion](https://github.com/orgs/supabase/discussions/3897))

**Firebase Authentication.** `firebase auth:export` writes the accounts
with their hashes, but Firebase's scrypt is a modified variant with a
salt separator and a signer key, not the standard `$scrypt$` we
recognise. We do not accept it yet; an import from Firebase carries the
accounts without a password, and every person sets one at their first
sign-in through the reset link. We say so rather than import a hash that
would never verify.

**Amazon Cognito.** Cognito does not export password hashes; its
documented path is a migration trigger at each user's next sign-in into
Cognito, not out of it. Leaving means every person sets a new password.
([AWS: migrate user Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html))

**Keycloak, Authentik, Zitadel, FusionAuth, Logto, Hanko, self-hosted.**
Your database. Keycloak stores bcrypt or PBKDF2 depending on the realm's
policy, in a form the import recognises once the algorithm's string is
reconstructed; ask us for the query for your version.

## One note on bcrypt

bcrypt silently truncates at 72 bytes. A user whose password is longer has been
authenticating on its first 72 bytes at your previous provider, and will
continue to until their first sign-in here, at which point the full password is
hashed with Argon2id. That is a small security improvement they get for free.

## Leaving again

The same door works outwards. **Users**, then **Export everything**, gives you
the application as JSON: its settings, its team, its webhooks, its
organisations with their members, roles, open invitations and single sign-on
connection, the consents, and every account with its password hash as
stored. That is Argon2id in the standard PHC string format,
which any provider accepting Argon2id can import, or the hash an account was
imported with if it has not signed in since.

Second-factor secrets and passkeys are not in the export. A passkey is bound
to EAuth's domain and would not work anywhere else, and a second-factor
secret is better re-enrolled than copied.

We would rather you could leave than keep you because you cannot.
