Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aa8401e
feat(migrate): add export workos and WorkOS imports
royanger Oct 6, 2026
596028c
docs(changeset): Add clerk migrate export workos and WorkOS imports
royanger Oct 6, 2026
9aab4d9
fix(migrate): never prompt for a WorkOS key or identities under --json
royanger Oct 7, 2026
d4c121c
fix(migrate): stop paging WorkOS users when the cursor does not move
royanger Oct 7, 2026
438b046
fix(migrate): stop the WorkOS identity fan-out on Ctrl-C or a rejecte…
royanger Oct 7, 2026
b1d4c4d
fix(migrate): encode the WorkOS user ID in the identities path
royanger Oct 7, 2026
852ad37
test(migrate): check the email each platform's import sends, not just…
royanger Oct 9, 2026
c069f80
fix(migrate): do not prompt for a missing WorkOS key under -y
royanger Oct 10, 2026
8fc2953
fix(migrate): send WorkOS metadata to private metadata, not the user-…
royanger Oct 14, 2026
284a1e0
fix(migrate): stop the WorkOS identity fan-out at once on a rejected …
royanger Oct 14, 2026
6eb3fc1
fix(init): detect WorkOS by its AuthKit packages, and link the curren…
royanger Oct 14, 2026
084b83f
fix(migrate): let a blank --api-key fall back to WORKOS_API_KEY
royanger Oct 16, 2026
45cca2b
docs(migrate): say only an interactive terminal prompts for the WorkO…
royanger Oct 16, 2026
aae72da
feat(init): flag AuthKit used through the WorkOS Node SDK
royanger Oct 16, 2026
e63bd0c
fix(init): call the WorkOS Node SDK finding User Management, not AuthKit
royanger Oct 17, 2026
3c63c5a
fix(migrate): back off on a WorkOS 429 instead of failing the export
royanger Oct 20, 2026
60529d5
fix(migrate): give --json an export section's values, not its termina…
royanger Oct 20, 2026
ee46a5c
fix(migrate): scope the WorkOS export warning to what the API and imp…
royanger Oct 20, 2026
48dcc04
docs(migrate): state the WorkOS fan-out's rate rather than a duration
royanger Oct 20, 2026
c5e2a1e
fix(init): drop the doubled word from the WorkOS scan message
royanger Oct 20, 2026
b09f92f
docs(migrate): scope WorkOS's missing password hashes to its API
royanger Oct 20, 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-workos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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`.
38 changes: 38 additions & 0 deletions packages/cli-core/src/commands/init/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ describe("detectAuthLibraries", () => {
expect(output).toContain("Kinde");
});

test.each(["@workos-inc/authkit-nextjs", "@workos-inc/authkit-react"])(
"detects WorkOS via %s",
(pkg) => {
runDetectAuthLibraries({ [pkg]: "1.0.0" });
expect(captured.out).toContain("WorkOS");
},
);

// The Node SDK also serves WorkOS SSO and Directory Sync alone.
test("does not call @workos-inc/node alone WorkOS auth", () => {
runDetectAuthLibraries({ "@workos-inc/node": "7.0.0" });
expect(captured.out).not.toContain("WorkOS");
});

test("detects multiple auth libraries", () => {
runDetectAuthLibraries({ "next-auth": "5.0.0", firebase: "11.0.0" });
const output = captured.out;
Expand Down Expand Up @@ -183,6 +197,30 @@ describe("scanForIssues", () => {
expect(findings.some((f) => f.message.includes("Passport"))).toBe(true);
});

// `@workos-inc/node` alone also serves SSO and Directory Sync, so the code
// names User Management, not the package.
test("detects User Management through the WorkOS Node SDK", async () => {
await mkdir(join(tempDir, "src"), { recursive: true });
await Bun.write(
join(tempDir, "src/auth.ts"),
"const user = await workos.userManagement.authenticateWithCode({ code });\n",
);

const findings = await scanForIssues(tempDir, "next");
expect(findings.map((f) => f.message)).toContain("WorkOS User Management still in use");
});

test("does not flag WorkOS SSO alone", async () => {
await mkdir(join(tempDir, "src"), { recursive: true });
await Bun.write(
join(tempDir, "src/auth.ts"),
"const { profile } = await workos.sso.getProfileAndToken({ code, clientId });\n",
);

const findings = await scanForIssues(tempDir, "next");
expect(findings.some((f) => f.message.includes("WorkOS"))).toBe(false);
});

test("returns correct line number", async () => {
await mkdir(join(tempDir, "src"), { recursive: true });
await Bun.write(
Expand Down
20 changes: 20 additions & 0 deletions packages/cli-core/src/commands/init/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [
name: "Better Auth",
docsUrl: "https://clerk.com/docs/migrations/overview",
},
{
// AuthKit only: `@workos-inc/node` also serves SSO, Directory Sync and
// Audit Logs for apps that authenticate with something else.
packages: [
"@workos-inc/authkit-nextjs",
"@workos-inc/authkit-react",
"@workos-inc/authkit-react-router",
"@workos-inc/authkit-remix",
"@workos-inc/authkit-js",
],
name: "WorkOS",
docsUrl: "https://clerk.com/docs/guides/development/migrating/overview",
},
{
packages: ["@kinde-oss/kinde-auth-nextjs"],
name: "Kinde",
Expand Down Expand Up @@ -111,6 +124,13 @@ const CODE_SCANS: CodeScan[] = [
pattern: "import\\s.*from\\s+['\"]passport['\"]",
message: "Passport import still in use",
},
{
// WorkOS users through the general Node SDK, which the package scan leaves
// out. The `sso` and `directorySync` modules alone hold no users.
pattern: "\\bworkos\\.userManagement\\b",
message: "WorkOS User Management still in use",
docsUrl: "https://clerk.com/docs/guides/development/migrating/overview",
},
{
pattern: "getServerSession\\s*\\(",
message: "NextAuth getServerSession() call still in use",
Expand Down
109 changes: 94 additions & 15 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ 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
clerk migrate export workos --api-key sk_…
```

The platform is an optional positional. Omitted, you get a picker built from
Expand All @@ -243,6 +244,7 @@ told not to.
| `auth0` | Auth0 Management API | `--source auth0` |
| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` |
| `firebase` | Firebase Identity Toolkit | `--source firebase` |
| `workos` | WorkOS User Management API | `--source workos` |

Every export is a [run](#the-run-store), and the file lands in the run
folder as `export.json`. `--output` writes it somewhere else instead,
Expand Down Expand Up @@ -283,6 +285,9 @@ keep `--runs-dir`: the import looks the run ID up there.
| `--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 |
| `--api-key <key>` | `workos` | WorkOS secret API key, the one starting `sk_` |
| `--with-identities` | `workos` | Also record each user's OAuth providers |
| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked |

`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 @@ -356,7 +361,7 @@ profile in that order.
The export run has one line per exported user, so `clerk migrate runs` lists it
alongside imports.

#### Two platforms export no passwords
#### Three 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
Expand All @@ -366,9 +371,14 @@ alongside imports.
`auth0|` plus `alt_id`, or `_id` when there's no `alt_id`. Copy each
`passwordHash` onto the matching user before importing, or migrate without
passwords.
- **WorkOS** returns neither password hashes nor TOTP secrets over the API:
hashes go in on import and never come back, and `totp.secret` is returned on
enrol only. WorkOS documents no other way to get them out.

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.
All three print a warning whenever they export anyone. `clerk`'s and `workos`'s
coverage also counts users who _have_ a password, so the size of the gap is
visible up front — `workos` prints that row at zero unconditionally, because
zero is the only value it can take.

#### `supabase` reads the database

Expand Down Expand Up @@ -523,6 +533,65 @@ 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.

#### WorkOS credentials

Needs a secret API key — the one starting `sk_`, from the WorkOS dashboard
under API Keys. Resolved from `--api-key`, then `WORKOS_API_KEY`, then a
prompt at an interactive terminal; `--json`, `-y` and agent mode exit naming
both instead.

There is no `--db-url` sibling because there is no database to point it at.
WorkOS is API-only: apps commonly mirror users into their own store through
webhooks, but that mirror is a derived copy holding no credentials, so the
User Management API is the only source. Pagination is cursor-based, so unlike
Auth0 there is no record ceiling — `after` runs to the end of the tenant.

**`--with-identities` is off by default, and it is not free.** WorkOS has no
bulk endpoint for OAuth identities, so it is one request per user: ten requests
becomes 1,010 for a thousand users. Nothing it returns can be imported —
`POST /v1/users` has no external-accounts field — so it buys a provider
breakdown in the coverage report, and an `identities` array kept in the export
file for whoever runs the migration. The interactive path asks once, after the
user count is known and only if it isn't zero, defaulting to no; agent mode
takes the flag's answer and asks nothing. So does `-y`, which takes the
prompt's default, no: pass `--with-identities` to fetch them without being
asked.

The breakdown prints as its own **OAuth providers** block under the coverage
table, not as extra coverage rows:

```
Field coverage
✓ 6/6 have an email address
✗ 0/6 have a password (WorkOS returns none)

OAuth providers
GoogleOAuth 2 users
MicrosoftOAuth 1 user
no OAuth provider 2 users
not readable 1 user
Those users have no `identities` field in the export, rather than an empty one.
```

Separate because the two kinds of row do not mean the same thing. A coverage
row is "N of the M users have this field"; a provider row has no such
denominator — one user holding two providers is counted under both, so the
counts can sum past the user count, and `not readable` is not a property of the
user at all.

A lookup that fails is counted on its own row rather than folded into
`no OAuth provider`. "Lookup failed" and "has no providers" are different
facts, and flattening the first into the second would understate social
sign-in.
A lookup is retried on a `429`, honouring `Retry-After`, and counted as not
readable only once its retries run out. `--json` carries the block as
`sections: [{ title, items: [{ label, count }], note? }]`.

Non-interactive runs get progress on stderr every 500 users during the fan-out,
and every 10 pages during the user fetch. `withSpinner` hands a no-op to
anything that is not a TTY, so without this an agent exporting a large tenant
would see nothing at all until the run finished.

### `clerk migrate import`

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

An unknown `--source` exits 2 with the list of valid keys.

Expand All @@ -996,8 +1066,15 @@ to enable any that are off. Auth0's come from each `user_id`'s prefix
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.
there would take away an edit the user had. Metadata only the source's backend
can set — Auth0's `app_metadata`, WorkOS's `metadata` — goes to
`private_metadata`, which the client cannot read either. Apps often trust these
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for a plan or a role, and `unsafe_metadata` would let the user change them. A
WorkOS user's `id` becomes Clerk's `external_id`, and its own `external_id`, if
it has one, is kept as `private_metadata.workosExternalId`. A
key an app put in its tokens (an Auth0 Action claim, a WorkOS JWT template)
belongs in `public_metadata`, and in the Clerk session token: move it after the
import. A Clerk Dashboard CSV carries no metadata.

### Verified vs unverified identifiers

Expand All @@ -1007,7 +1084,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** (`auth0`, `firebase`): `true`/`false`. A CSV export stringifies these, so `"false"` is
- **Boolean** (`auth0`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 @@ -1142,20 +1219,22 @@ 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.

Two exports talk to their own platform rather than to Clerk:
Three 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 |
| 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 |
| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated |
| `GET` | `…/user_management/users/{id}/identities` | `export workos` — when identity lookups are enabled, one per user |

The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on
`FIREBASE_AUTH_EMULATOR_HOST` when that is set. The emulator needs no token, so
the export skips that request, and it may not carry the hash parameters: pass
them to the import.
them to the import. The two WorkOS paths are on `api.workos.com`.

## Notes

Expand Down
32 changes: 32 additions & 0 deletions packages/cli-core/src/commands/migrate/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { exportAuth0 } from "./auth0.ts";
import { exportClerk } from "./clerk.ts";
import { exportFirebase } from "./firebase.ts";
import { exportSupabase } from "./supabase.ts";
import { exportWorkOs } from "./workos.ts";
import type { DbExportOptions } from "./db-options.ts";
import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "../lib/run-store.ts";
import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./registry.ts";
Expand Down Expand Up @@ -53,6 +54,7 @@ const handlers = {
auth0: exportAuth0,
supabase: exportSupabase,
firebase: exportFirebase,
workos: exportWorkOs,
};

/** The platforms that read a database, which share `--db-url`. */
Expand Down Expand Up @@ -170,6 +172,36 @@ export function registerMigrateExport(migrateCommand: Command<[], Record<string,
handlers.firebase(cmd.optsWithGlobals() as Parameters<typeof handlers.firebase>[0]),
);

exportCommand
.command("workos")
.description("Export users from a WorkOS tenant")
.option("--api-key <key>", "WorkOS secret API key, the one starting `sk_`")
.option(
"--with-identities",
"Also record each user's OAuth providers — one extra request per user",
)
.option("--no-with-identities", "Skip the OAuth provider fan-out without being asked")
.option("-o, --output <path>", "Write the export here instead of the run folder")
.option(
"-y, --yes",
"Do not prompt: fail on a rejected credential, and skip the OAuth provider fan-out unless --with-identities",
)
.option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION)
.option("--json", "Print the result as JSON; never prompts")
.setExamples([
{
command: "clerk migrate export workos --api-key sk_…",
description: "Export with an explicit API key",
},
{
command: "clerk migrate export workos",
description: "Read WORKOS_API_KEY, or prompt",
},
])
.action(async (_opts, cmd) =>
handlers.workos(cmd.optsWithGlobals() as Parameters<typeof handlers.workos>[0]),
);

// Each takes exactly one connection string, so they are registered from a
// table rather than near-identical blocks.
for (const platform of DB_PLATFORMS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./regist

describe("export registry", () => {
test("registers every source platform", () => {
expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase"]);
expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase", "workos"]);
});

test.each([...exportPlatforms])("$key carries a label and description", (entry) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/migrate/export/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { exportAuth0 } from "./auth0.ts";
import { exportClerk } from "./clerk.ts";
import { exportFirebase } from "./firebase.ts";
import { exportSupabase } from "./supabase.ts";
import { exportWorkOs } from "./workos.ts";

export type ExportRegistryEntry = {
key: string;
Expand Down Expand Up @@ -53,6 +54,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [
sourceKey: "firebase",
run: async (options) => exportFirebase(options),
},
{
key: "workos",
label: "WorkOS",
description: "A WorkOS tenant, via the User Management API — no password hashes",
sourceKey: "workos",
run: async (options) => exportWorkOs(options),
},
];

export function exportPlatformKeys(): string[] {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli-core/src/commands/migrate/export/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ describe("finishExport", () => {
expect(readRun(runsDir, record.id)).toMatchObject({ status: "partial", truncated: true });
});

// Padding and color codes belong to the terminal, not to --json.
test("carries a section's items into --json as plain values", async () => {
const run = await startExportRun({ runsDir }, { platform: "workos" });
const sections = [{ title: "OAuth providers", items: [{ label: "GoogleOAuth", count: 2 }] }];

finishExport({ run, options: { json: true }, users, coverage, sections });

expect(JSON.parse(captured.out).sections).toEqual(sections);
expect(captured.out).not.toContain("\u001b");
});

// The file holds password hashes and PII, so no other local account reads it.
test("writes the file owner-only, in an owner-only run folder", async () => {
const run = await startExportRun({ runsDir }, { platform: "supabase" });
Expand Down
Loading
Loading