Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b2e1d30
feat(migrate): add export auth0 and Auth0 imports
royanger Oct 6, 2026
2794b80
docs(changeset): Add clerk migrate export auth0 and Auth0 imports
royanger Oct 6, 2026
81b6063
fix(migrate): never prompt for Auth0 credentials under --json
royanger Oct 7, 2026
2743adc
fix(migrate): accept only a bare host name as the Auth0 domain
royanger Oct 7, 2026
f28823b
fix(migrate): never follow a redirect with Auth0 credentials
royanger Oct 7, 2026
d84c2e6
fix(migrate): warn whenever an Auth0 export reaches 1000 users
royanger Oct 7, 2026
60f8c81
fix(migrate): do not ask for Auth0 credentials under -y
royanger Oct 8, 2026
ccede64
fix(migrate): page an Auth0 export to its total, not to the first sho…
royanger Oct 9, 2026
3f1412e
docs(migrate): say only clerk's export counts the passwords it can't …
royanger Oct 9, 2026
ae084e6
docs(migrate): name the Auth0 API the exporter calls as the Managemen…
royanger Oct 10, 2026
58c9158
fix(migrate): page an Auth0 export so it never passes short for compl…
royanger Oct 14, 2026
d4444ab
fix(migrate): ask again for Auth0 credentials whose token lacks read:…
royanger Oct 14, 2026
d108e41
docs(migrate): say the Clerk and Auth0 exports warn about passwords w…
royanger Oct 14, 2026
a85ec35
fix(migrate): let a blank Auth0 flag fall back to its environment var…
royanger Oct 16, 2026
7ea4d11
docs(migrate): keep the Auth0 secret out of the example, and narrow t…
royanger Oct 16, 2026
7382de0
docs(migrate): keep the Auth0 secret out of the export help examples
royanger Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrate-auth0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add `clerk migrate export auth0`, and let `clerk migrate import` read Auth0 exports, including the NDJSON file from Auth0's bulk export job. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
90 changes: 67 additions & 23 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,18 +214,20 @@ Gets users **out** of a source platform, so there is something to feed
```sh
clerk migrate export # pick a platform
clerk migrate export clerk --output users.json
clerk migrate export auth0 --domain my-tenant.us.auth0.com \
--client-id … # asks for the secret, masked
clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres"
clerk migrate export firebase --service-account ./service-account.json
```

The platform is an optional positional. Omitted, you get a picker built from
the registry; given, it runs directly. Each platform resolves its own flags —
what a Clerk export needs (a secret key) has nothing in common with what a
database export needs.
what Auth0 needs (a tenant domain and M2M credentials) has nothing in common
with what a database export needs.

**A credential the far end rejects is asked for again.** Connection strings and
Firebase service account keys are long, pasted by hand, masked as they are
typed, and wrong in ways nothing local can check: a typo'd host, a revoked key,
**A credential the far end rejects is asked for again.** Connection strings,
Firebase service account keys and Auth0 client secrets are all long, pasted by
hand, masked as they are typed, and wrong in ways nothing local can check: a typo'd host, a revoked key,
an expired token, the right server but the wrong database. Only the connection
or the token exchange can say, and by then the operator has answered every
other question the command asked. So that step — and only that step,
Expand All @@ -238,6 +240,7 @@ told not to.
| Platform | Source | Feeds |
| ---------- | -------------------------------- | ------------------- |
| `clerk` | Clerk Backend API | `--source clerk` |
| `auth0` | Auth0 Management API | `--source auth0` |
| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` |
| `firebase` | Firebase Identity Toolkit | `--source firebase` |

Expand Down Expand Up @@ -269,14 +272,17 @@ skipped, coverage, next }` — and never prompts, so a missing credential exits
naming the flag to pass. `next`, and the commands printed after a human run,
keep `--runs-dir`: the import looks the run ID up there.

| Flag | Platforms | Description |
| -------------------------- | ---------- | --------------------------------------------------------- |
| `-o, --output <path>` | all | Write the export here instead of the run folder |
| `-y, --yes` | all | Do not prompt: fail on a bad credential |
| `--json` | all | Print the result as JSON; never prompts |
| `--runs-dir <path>` | all | Where runs are kept (see [the run store](#the-run-store)) |
| `--db-url <url>` | `supabase` | Postgres connection string |
| `--service-account <path>` | `firebase` | Path to a service account key JSON file |
| Flag | Platforms | Description |
| -------------------------- | ---------- | ----------------------------------------------------------------- |
| `-o, --output <path>` | all | Write the export here instead of the run folder |
| `-y, --yes` | all | Do not prompt: fail on a bad credential |
| `--json` | all | Print the result as JSON; never prompts |
| `--runs-dir <path>` | all | Where runs are kept (see [the run store](#the-run-store)) |
| `--db-url <url>` | `supabase` | Postgres connection string |
| `--service-account <path>` | `firebase` | Path to a service account key JSON file |
| `--domain <domain>` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain |
| `--client-id <id>` | `auth0` | Machine-to-machine application client ID |
| `--client-secret <secret>` | `auth0` | Machine-to-machine application client secret |

`export clerk` also takes the targeting flags — it reads from a Clerk instance,
so it resolves a key the same way `clerk migrate import` does, with one extra
Expand Down Expand Up @@ -350,11 +356,16 @@ profile in that order.
The export run has one line per exported user, so `clerk migrate runs` lists it
alongside imports.

#### Clerk exports no passwords
#### Two platforms export no passwords

Clerk never returns password digests, TOTP secrets or backup codes over the API
— only the `*_enabled` booleans. Migrated users must reset their password in
the destination instance. The export says so on every run, and the coverage row
- **Clerk** never returns password digests, TOTP secrets or backup codes over
the API — only the `*_enabled` booleans. Migrated users must reset their
password in the destination instance.
- **Auth0**'s Management API does not return password hashes either; they come
only from a support request. Add a `passwordHash` field to each user before
importing, or migrate without passwords.

Both print a warning whenever they export anyone. `clerk`'s coverage also
counts users who _have_ a password, so the size of the gap is visible up front.

#### `supabase` reads the database
Expand Down Expand Up @@ -478,6 +489,35 @@ for an access token, then two Identity Toolkit endpoints, `accounts:batchGet`
(paged, 1000 users a request) and the project config, and Bun's Web Crypto
signs RS256 with no dependency at all.

#### Auth0 credentials

Needs a machine-to-machine application with the `read:users` scope
(Applications → APIs → Auth0 Management API → Machine to Machine
Applications). Resolved from flags, then `AUTH0_DOMAIN` / `AUTH0_CLIENT_ID` /
`AUTH0_CLIENT_SECRET`, then a prompt. In agent mode a prompt is impossible, so
it exits naming **every** missing credential at once rather than one per run.

Auth0 pages this endpoint only through the first **1000** users. Past that the
export stops and says so, pointing at Auth0's bulk export job — silently
returning the first thousand would read as "that is everyone". A tenant of
exactly 1000 gets the warning too: Auth0 reports a larger tenant's total as
1000, so the two look the same. It also says so when fewer users come back
than Auth0 counted. Either way the file holds what came back, `--json` carries
`truncated: true`, and the export exits 1. The bulk job's NDJSON file imports
as it is.

Pages are read in `created_at` order, and a user a shifted page repeats is
exported once. A `429` backs off, honouring `Retry-After`, as the import does;
a free or development tenant allows only a few Management API requests a
second.

A credential the platform rejects (400, 401 or 403) is asked for again at a
terminal, and so is an Auth0 token whose granted scopes leave out `read:users`.
When Auth0's token response lists no scopes, the first users request finds the
missing permission instead, and exits. An outage, a refused connection, or a
`429` that outlasts its retries is not asked for again: another credential would
not fix it, and it exits 1.

### `clerk migrate import`

Reads an exported user file, maps it onto Clerk's user schema, checks every
Expand Down Expand Up @@ -931,6 +971,7 @@ brings across. Adding a platform is one file in `sources/` plus one line in
| Key | Reads | Passwords | MFA | Metadata |
| ---------- | ---------------------------- | --------- | ------- | -------- |
| `clerk` | Clerk Dashboard export | partial | partial | partial |
| `auth0` | Auth0 Management API | partial | no | yes |
| `firebase` | `firebase auth:export` | yes | no | no |
| `supabase` | Supabase `auth.users` export | yes | no | partial |

Expand All @@ -943,10 +984,11 @@ is linked to their imported account by verified email. See

### Metadata

Supabase's `raw_user_meta_data`, which a user can edit on Supabase, goes to
Clerk's `unsafe_metadata`, which is the user-editable one. Public metadata is
read-only to the user, so putting it there would take away an edit the user
had. A Clerk Dashboard CSV carries no metadata.
Metadata a user can edit on the source platform — Auth0's `user_metadata`,
Supabase's `raw_user_meta_data` — goes to Clerk's `unsafe_metadata`, which is
the user-editable one. Public metadata is read-only to the user, so putting it
there would take away an edit the user had. Auth0's `app_metadata` goes to
`private_metadata`. A Clerk Dashboard CSV carries no metadata.

### Verified vs unverified identifiers

Expand All @@ -956,7 +998,7 @@ which style it uses. An identifier the source never confirmed is routed to
field, because Clerk creates primary identifiers **verified** — sending an
unconfirmed address there would silently promote it.

- **Boolean** (`firebase`): `true`/`false`. A CSV export stringifies these, so `"false"` is
- **Boolean** (`auth0`, `firebase`): `true`/`false`. A CSV export stringifies these, so `"false"` is
read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read
too, as a spreadsheet or psql writes them.
- **Timestamp** (`supabase`): a nullable confirmation time. Any real value
Expand Down Expand Up @@ -1091,10 +1133,12 @@ The checks also read the instance's Frontend API `GET /v1/environment`
attributes and enabled social providers. Nothing in `clerk migrate` writes
instance settings: the checks print the `clerk config patch` to run instead.

`export firebase` talks to Google rather than to Clerk:
Two exports talk to their own platform rather than to Clerk:

| Method | Path | Used by |
| ------ | ---------------------------------------------- | ---------------------------------------------------- |
| `POST` | `https://<tenant>/oauth/token` | `export auth0` — Management API access token |
| `GET` | `https://<tenant>/api/v2/users` | `export auth0` — 100 per page, 1000 users maximum |
| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token |
| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time |
| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters |
Expand Down
Loading
Loading