Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56b4f79
feat(migrate): make clerk migrate generally available
royanger Oct 6, 2026
b2f2c8c
docs: list clerk migrate in the README and match its help
royanger Oct 6, 2026
fd958c1
docs(changeset): Make clerk migrate available without CLERK_EXPERIMENTAL
royanger Oct 6, 2026
5b50dd2
feat(migrate): opt in to creating unverified identifiers reserved
royanger Oct 7, 2026
dbe9ca3
fix(migrate): attach what an adopted user's create left out, in its o…
royanger Oct 7, 2026
5013474
fix(migrate): keep prompt answers in the commands a run suggests
royanger Oct 7, 2026
cd23044
fix(migrate): offer no email fix that --reserve-unverified already co…
royanger Oct 7, 2026
069ab49
fix(migrate): keep the verified phone when Clerk refuses a reserved one
royanger Oct 9, 2026
408174d
fix(migrate): check reserved identifiers for duplicates too
royanger Oct 9, 2026
0dff2ac
fix(migrate): count an adopted user's reserved email toward a require…
royanger Oct 9, 2026
cd43848
fix(migrate): check an adopted user's identifiers in its own create's…
royanger Oct 9, 2026
6904b24
fix(migrate): count a reserved email or phone as a way in past a disa…
royanger Oct 9, 2026
806a6d7
fix(migrate): attach the reserved phones a refusal kept off the creat…
royanger Oct 9, 2026
b983593
docs(migrate): name the phone endpoint too, and say an adopted user k…
royanger Oct 10, 2026
c001acb
fix(migrate): count no email Clerk refuses as a way in for a user on …
royanger Oct 14, 2026
e205b67
docs(changeset): stop calling clerk migrate experimental in the earli…
royanger Oct 14, 2026
8ead33c
fix(migrate): keep a run's reserve choice for its continue, and say a…
royanger Oct 14, 2026
e1f4128
fix(migrate): attach the reserved phones a cut-off retry left out whe…
royanger Oct 15, 2026
c7ee6e7
fix(migrate): drop loose phones before counting a way in past a disab…
royanger Oct 16, 2026
c2bff71
fix(migrate): continue an earlier run in its own reservation mode fro…
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
2 changes: 1 addition & 1 deletion .changeset/migrate-auth0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"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`.
Add `clerk migrate export auth0`, and let `clerk migrate import` read Auth0 exports, including the NDJSON file from Auth0's bulk export job.
2 changes: 1 addition & 1 deletion .changeset/migrate-authjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate export authjs`, and let `clerk migrate import` read Auth.js exports. The export reads an Auth.js database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), finding the user table whether Prisma or Drizzle named it. Auth.js stores no passwords, so imported users sign in with OAuth or an email link, or set a password. The export reads each user's OAuth providers, so the import can offer to turn them on, and warns that Auth.js leaves OAuth users' emails unverified; `--trust-oauth-emails` marks them verified. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate export authjs`, and let `clerk migrate import` read Auth.js exports. The export reads an Auth.js database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), finding the user table whether Prisma or Drizzle named it. Auth.js stores no passwords, so imported users sign in with OAuth or an email link, or set a password. The export reads each user's OAuth providers, so the import can offer to turn them on, and warns that Auth.js leaves OAuth users' emails unverified; `--trust-oauth-emails` marks them verified.
2 changes: 1 addition & 1 deletion .changeset/migrate-betterauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate export betterauth`, and let `clerk migrate import` read Better Auth exports. The export reads a Better Auth database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), including schemas Drizzle generated with snake_case columns or plural tables, and detects plugin columns. Better Auth's scrypt, bcrypt and argon2 passwords carry across, and anonymous users are skipped. A user any source skips, such as Supabase's soft-deleted users, no longer stops the import: it's recorded as skipped, and `--allow-partial` is only for real rejects. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate export betterauth`, and let `clerk migrate import` read Better Auth exports. The export reads a Better Auth database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), including schemas Drizzle generated with snake_case columns or plural tables, and detects plugin columns. Better Auth's scrypt, bcrypt and argon2 passwords carry across, and anonymous users are skipped. A user any source skips, such as Supabase's soft-deleted users, no longer stops the import: it's recorded as skipped, and `--allow-partial` is only for real rejects.
2 changes: 1 addition & 1 deletion .changeset/migrate-custom-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate sources`, and let `clerk migrate import --source` take the path to a source you wrote. `clerk migrate sources` lists the built-in sources with the passwords, MFA and metadata each brings across, and `clerk migrate sources <source>` shows where each field lands, how to export, and any caveats. A source you wrote is a `.ts` or `.js` file in your project, checked before use, so a platform with no built-in source can be imported without rebuilding the CLI. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate sources`, and let `clerk migrate import --source` take the path to a source you wrote. `clerk migrate sources` lists the built-in sources with the passwords, MFA and metadata each brings across, and `clerk migrate sources <source>` shows where each field lands, how to export, and any caveats. A source you wrote is a `.ts` or `.js` file in your project, checked before use, so a platform with no built-in source can be imported without rebuilding the CLI.
2 changes: 1 addition & 1 deletion .changeset/migrate-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate export clerk` and `clerk migrate export supabase`, and let `clerk migrate import` take the export's run ID in place of a file and `--source`. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate export clerk` and `clerk migrate export supabase`, and let `clerk migrate import` take the export's run ID in place of a file and `--source`.
2 changes: 1 addition & 1 deletion .changeset/migrate-firebase.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate export firebase`, and let `clerk migrate import` read Firebase exports with their password hashes. The project's hash parameters come from the export, the `--firebase-*` flags, or `CLERK_FIREBASE_SIGNER_KEY`, `CLERK_FIREBASE_SALT_SEPARATOR`, `CLERK_FIREBASE_ROUNDS` and `CLERK_FIREBASE_MEM_COST`. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate export firebase`, and let `clerk migrate import` read Firebase exports with their password hashes. The project's hash parameters come from the export, the `--firebase-*` flags, or `CLERK_FIREBASE_SIGNER_KEY`, `CLERK_FIREBASE_SALT_SEPARATOR`, `CLERK_FIREBASE_ROUNDS` and `CLERK_FIREBASE_MEM_COST`.
2 changes: 1 addition & 1 deletion .changeset/migrate-runs-undo.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"clerk": minor
---

Add `clerk migrate runs` and `clerk migrate undo`, and let a re-run of `clerk migrate import` continue where a stopped one left off. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate runs` and `clerk migrate undo`, and let a re-run of `clerk migrate import` continue where a stopped one left off.

Every user an import creates now carries `clerkMigrateRun` (the run's ID) in its `private_metadata`, replacing any value the source had under that key. A re-run reads it to adopt a user whose create was cut off. Nothing removes it afterwards, and your backend can read it.
2 changes: 1 addition & 1 deletion .changeset/migrate-workos.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"clerk": minor
---

Add `clerk migrate export workos`, and let `clerk migrate import` read WorkOS exports. WorkOS returns no password hashes, so imported users reset their password or sign in with SSO. `clerk init` now also flags a WorkOS install and links the migration guide. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Add `clerk migrate export workos`, and let `clerk migrate import` read WorkOS exports. WorkOS returns no password hashes, so imported users reset their password or sign in with SSO. `clerk init` now also flags a WorkOS install and links the migration guide.
9 changes: 9 additions & 0 deletions .changeset/migrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"clerk": minor
---

Make `clerk migrate` available without `CLERK_EXPERIMENTAL=migrate`: it now shows in `clerk --help` and shell completion.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW]

None of the stack's changesets have shipped yet (.changeset/ on main is empty). Eight of them (migrate-import, migrate-export, migrate-runs-undo, migrate-firebase, migrate-auth0, migrate-workos, migrate-betterauth, migrate-authjs, migrate-custom-sources) still say clerk migrate is experimental and needs CLERK_EXPERIMENTAL=migrate, or that it is hidden and refuses to run. If the stack releases together, the changelog will say that next to this entry announcing it as generally available.

Recommended fix

Remove the "still experimental / needs CLERK_EXPERIMENTAL" sentences from the earlier changesets in this PR, or merge them into this one.

Sent from Claude

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ba72181. The earlier changesets no longer say migrate is experimental, and migrate-import.md no longer says it's hidden. The edit lands only with this slice, so no release says both.


- `clerk migrate export` gets users out of Clerk, Supabase, Firebase, Auth0, WorkOS, Better Auth or Auth.js into a run, and `clerk migrate import` checks every user against your instance, asks before it writes, and continues a run that stopped partway.
- `clerk migrate runs` shows what each run did, `clerk migrate undo` deletes the users an import created, and `clerk migrate sources` shows what each source brings across. `--source ./my-source.ts` imports with a source you wrote.
- `clerk migrate import --reserve-unverified` creates the emails and phones a source never verified as reserved (usable for sign-in, locked to the user) instead of unverified. At a terminal, the import asks.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Commands:
init [options] Initialize Clerk in your project
link [options] Link this project to a Clerk application
mcp Manage the Clerk remote MCP server connection for AI editors and CLIs
migrate Migrate users into Clerk from another auth provider or another Clerk instance
open Open Clerk resources in your browser
telemetry Control CLI usage telemetry (status, disable, enable)
unlink [options] Unlink this project from its Clerk application
Expand Down
77 changes: 53 additions & 24 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
# `clerk migrate`

> **Experimental.** `clerk migrate` is hidden from help and completion, and
> refuses to run (exit 2, `experiment_disabled`), unless `CLERK_EXPERIMENTAL`
> includes `migrate`:
>
> ```sh
> CLERK_EXPERIMENTAL=migrate clerk migrate import --help
> ```
>
> `CLERK_EXPERIMENTAL` is a comma-separated list. Names are trimmed and
> case-insensitive, and unknown names are ignored.

Migrate users into a Clerk instance from another auth provider, or from another
Clerk instance.

```
clerk migrate export [source] [-o <path>] [--json]
clerk migrate import <file|export-run-id> [--source <source>] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json]
clerk migrate export [platform] [-o <path>] [--json]
clerk migrate import [file|export-run-id] [--source <source>] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json]
clerk migrate runs [run-id] [--json]
clerk migrate undo <run-id> [--dry-run] [--yes] [--include-signed-in] [--json]
clerk migrate sources [source] [--json]
Expand All @@ -32,8 +21,8 @@ clerk migrate import 20260929-141502-a1b2 --yes # 3. import it
```

`clerk migrate undo <run-id>` takes an import back out, and `clerk migrate runs`
shows what every run did. Every subcommand takes `--runs-dir <path>` (or
`CLERK_MIGRATE_DIR`) to keep its runs somewhere else.
shows what every run did. Every subcommand but `sources` takes `--runs-dir <path>`
(or `CLERK_MIGRATE_DIR`) to keep its runs somewhere else.
`clerk migrate` on its own is a group name, not a command: it prints its help.

## The rules
Expand Down Expand Up @@ -310,7 +299,7 @@ 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 |
| `-y, --yes` | all | Do not prompt: fail on a rejected 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`, `authjs`, `betterauth` | Connection string (Postgres only for `supabase`) |
Expand Down Expand Up @@ -436,7 +425,9 @@ only opens local files and `@libsql/client` ships native optional dependencies.
Nothing native ships in the binary — that is the whole reason the `engines.bun`
floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / `AUTHJS_DB_URL`
/ `BETTERAUTH_DB_URL`, then a masked prompt, since a connection string carries the password inline. A password
pasted unencoded (`#`, `@`, `/` and the like) is percent-encoded for you.
pasted unencoded (`#`, `@`, `/` and the like) is percent-encoded for you. A libsql
token comes from `?authToken=` on the URL, or from `TURSO_AUTH_TOKEN` /
`LIBSQL_AUTH_TOKEN`, and is redacted like a password.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Connection strings are redacted everywhere.** Errors show
`postgres://***@host/db`, including when the password itself contains an
Expand Down Expand Up @@ -697,6 +688,7 @@ clerk migrate import users.json --source clerk --new-run --yes
clerk migrate import users.json --source clerk --json --yes
clerk migrate import users.json --source clerk --require-password --yes
clerk migrate import users.json --source clerk --skip-legal-checks --yes
clerk migrate import users.json --source workos --reserve-unverified --yes
clerk migrate import users.json --source firebase --firebase-signer-key SIGNER_KEY \
--firebase-salt-separator SALT_SEPARATOR --firebase-rounds 8 --firebase-mem-cost 14 --yes
clerk migrate import users.json --source clerk --runs-dir ./runs --yes
Expand All @@ -714,6 +706,7 @@ clerk migrate import # a human is asked
| `--new-run` | Start a new run instead of [continuing](#re-running) an earlier one |
| `--require-password` | Import only users that carry a password digest |
| `--skip-legal-checks` | Import users with no legal acceptance into an instance requiring it |
| `--reserve-unverified` | Create [unverified identifiers](#verified-vs-unverified-identifiers) reserved, not unverified |
| `--firebase-signer-key <key>` | Firebase base64 signer key (overrides the export file), or `CLERK_FIREBASE_SIGNER_KEY` (does not) |
| `--firebase-salt-separator <separator>` | Firebase base64 salt separator, or `CLERK_FIREBASE_SALT_SEPARATOR` |
| `--firebase-rounds <n>` | Firebase scrypt rounds, or `CLERK_FIREBASE_ROUNDS` |
Expand Down Expand Up @@ -843,10 +836,11 @@ after them. They sort the users four ways:
any other user, with a warning. Spaces around a phone don't count, as Clerk
trims them. Phones the instance has off are stripped anyway, so they are
not checked
- it lacks an identifier the instance requires. An email or phone counts
only when it is verified, because an unverified one is attached after the
user exists. A required phone dropped for not being in E.164 form is named
as such
- it lacks an identifier the instance requires. An email or phone counts when
it is verified, or when it is created reserved (`--reserve-unverified`, or
an adopted user's recorded mode). An unverified one attached after the user
exists does not. A required phone dropped for not being in E.164 form is
named as such
- it has no identifier left once the emails and phones Clerk would refuse
are stripped: those of an instance that neither has them on nor signs in
or does MFA with them. A username is kept, as Clerk stores it with
Expand All @@ -871,9 +865,9 @@ after them. They sort the users four ways:
to 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are
checked; other hashers are not, and Clerk refuses a bad one at create)
- Supabase: its only providers are ones Clerk has off, or doesn't offer at all
(Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified email or
phone the instance signs in with by code or link. The checks offer to turn
on the first kind; nothing can turn on the second
(Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified or
reserved email or phone the instance signs in with by code or link. The
checks offer to turn on the first kind; nothing can turn on the second
- its source ID, primary email, primary phone or username repeats an
earlier user in the file that passes the checks above. Only what the
create sends counts: an extra email is attached after it, and a stripped
Expand Down Expand Up @@ -954,6 +948,12 @@ email: the create is retried without the phone. The user counts as imported,
and the summary lists them under "Imported without their phone", by Clerk's
reason (`result.warnings` in `--json`).

With [reserved](#verified-vs-unverified-identifiers) phones on the create, the
refused one may be any of them, so the verified phone is retried alone first.
Reserved phones the refusal kept off, when Clerk's answer can't say which was
refused, are attached one by one once the user exists, unverified: only the
refused one is lost.

#### Throughput

Defaults follow Clerk's documented `POST /v1/users` limits: 100 req/s for
Expand Down Expand Up @@ -1326,6 +1326,35 @@ unconfirmed address there would silently promote it.

A Clerk export keeps an unverified primary email or phone unverified.

**Unverified or reserved.** By default an unverified identifier is attached
after the user exists (`POST /v1/email_addresses` or `POST /v1/phone_numbers`,
`verified: false`). The user
cannot sign in with it, and another user can claim it by verifying it first.
`--reserve-unverified`, or a yes at the prompt a human gets when the file has
any, creates them **reserved** instead, on `POST /v1/users` through
`email_address_identification_status` / `phone_number_identification_status`.
A reserved identifier is unverified, but usable for sign-in and locked to the
user, and becomes verified the first time the user signs in with it. That is
Comment thread
coderabbitai[bot] marked this conversation as resolved.
how most source platforms treat an unconfirmed address, but it lets the user
sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and
agent mode never ask, and keep them unverified without the flag.

A run records its choice (`reserveUnverified` in `run.json`), and a continued
run reuses it without asking, so every user of one run is created the same way.
A run recorded before the choice existed asks once, and records the answer.
`--reserve-unverified` on a continue of a run that kept them unverified is
refused; pass `--new-run` to change modes. A user a run adopts keeps the mode
its first create recorded (`reserved` on its `creating` line). When Clerk
refused a phone and the retry went out without the reserved phones, that line
lists them as `pending`, and a continue that adopts the user attaches them,
unverified (one Clerk refuses again fails with a note).

Reserved, an unverified identifier is checked for duplicates like a verified
one, because it goes on the user's create: one that an instance user or an
earlier record in the file that passes the checks already holds rejects the
whole user, where without reserving only that address would fail to attach, with
a note. If that rejects users you want, import without reserving.

### Firebase hash parameters

Firebase uses a modified scrypt, so Clerk needs the project's four parameters
Expand Down
50 changes: 0 additions & 50 deletions packages/cli-core/src/commands/migrate/gate.test.ts

This file was deleted.

Loading
Loading