Skip to main content

Migrating from Clerk

The migration is manual but well-defined: Clerk exports cleanly, the data shape maps directly to LumoAuth, and the steps below walk through it with the Admin API.

1. Export your Clerk users​

From the Clerk Dashboard, go to Users → … → Export users. Pick JSON.

Or via Clerk's Backend API:

curl "https://api.clerk.com/v1/users?limit=500" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
> clerk-users.json

For organizations over 500 users, paginate using ?offset=500, ?offset=1000, etc. and concatenate.

2. Map Clerk fields to LumoAuth​

The LumoAuth Admin user-create endpoint reads these top-level fields: email, username, name, givenName, familyName, phoneNumber, password, isActive, emailVerified, roles and groups. Map Clerk's fields onto those:

Clerk fieldLumoAuth field
email_addresses[0].email_addressemail
email_addresses[0].verification.status == "verified"emailVerified
first_name + " " + last_namename
first_namegivenName
last_namefamilyName
usernameusername
id (e.g. user_2abc...)Your own mapping table (see step 3)
image_urlpicture, set after creation with PATCH /admin/users/{id}
public_metadata, private_metadata, unsafe_metadataYour own database, keyed by the LumoAuth user id
note

The create endpoint also accepts a metadata object, but user responses never return it, so it cannot carry the vendor's user id for later lookups. Keep your own mapping from the vendor id to the LumoAuth user id returned in the create response (data.id); the loop below records it. The endpoint does not accept a caller-supplied creation timestamp either — createdAt is set by LumoAuth at create time, so keep the original timestamp in the same mapping table if your app relies on it.

3. Create the users​

LumoAuth's Admin API creates one user per request at POST /orgs/{orgId}/api/v1/admin/users (there is no bulk array endpoint). First reshape the Clerk export into one JSON object per line, then POST each one in a loop and record the Clerk id next to the LumoAuth id from the response:

# Reshape Clerk users into newline-delimited LumoAuth user objects,
# carrying the Clerk id and image along for the mapping table
jq -c '.data[] | {
clerk_user_id: .id,
picture: .image_url,
user: {
email: .email_addresses[0].email_address,
emailVerified: (.email_addresses[0].verification.status == "verified"),
name: ((.first_name // "") + " " + (.last_name // "")),
givenName: .first_name,
familyName: .last_name,
username: .username
}
}' clerk-users.json > lumo-users.ndjson

# POST each user; an existing-email 409 is expected on re-runs.
# Writes "clerk_user_id,lumo_user_id" lines to mapping.csv and sets the picture.
while read -r row; do
cid=$(echo "$row" | jq -r '.clerk_user_id')
pic=$(echo "$row" | jq -r '.picture // empty')
user=$(echo "$row" | jq -c '.user')
id=$(curl -sS -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/users" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$user" | jq -r '.data.id // empty')
[ -z "$id" ] && continue
echo "$cid,$id" >> mapping.csv
[ -n "$pic" ] && curl -sS -X PATCH "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/users/$id" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"picture\": \"$pic\"}" -o /dev/null
done < lumo-users.ndjson

The loop is idempotent: a 409 Conflict (email already exists) means the user was already created and can be safely skipped on re-runs; for those, GET /admin/users/{email} (or lumo users get <email> -o json) returns the id to backfill the mapping. $LUMO_ADMIN_TOKEN is an access token with the admin scope, or use an organization API key instead (-H "Authorization: ApiKey lmk_…", with the admin:users:write scope). The same requests work as lumo api POST /users --data "$user".

4. Migrate Clerk Organizations​

Create each organization via the Admin API at POST /orgs/{orgId}/api/v1/admin/organizations (accepts name, slug, displayName, description, logoUrl, metadata, isActive), with curl as below or lumo api POST /organizations --data '{…}':

curl "https://api.clerk.com/v1/organizations?limit=500" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
> clerk-orgs.json

jq -c '.data[] | {name: .name, slug: .slug}' clerk-orgs.json | while read -r org; do
curl -sS -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/organizations" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$org"
done

For org memberships:

jq -r '.data[].id' clerk-orgs.json | while read org_id; do
curl "https://api.clerk.com/v1/organizations/$org_id/memberships?limit=500" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
> "memberships-$org_id.json"
done

Then join the membership records to mapping.csv from step 3, and add each member via POST /orgs/{orgId}/api/v1/admin/organizations/{organizationId}/members with {"userId": "<lumo user id>", "roleId": <organization role id>} (list the organization's roles with GET …/organizations/{organizationId}/roles).

5. What you cannot migrate from Clerk​

  • Password hashes. Clerk uses bcrypt; export does not include hashes (deliberate). Users password-reset on first LumoAuth login.
  • Sessions. Clerk session JWTs aren't honored by LumoAuth. Users sign in fresh.
  • OAuth-only users. If a user signed in only via Google/GitHub OAuth in Clerk, they have no password. They re-link the social provider in LumoAuth (no friction — they click "Sign in with Google" again). Signing in with the provider links to the imported account only when it was imported with emailVerified: true and without a password, and the provider asserts a verified email; otherwise the sign-in is refused as "An account with this email already exists".
  • Magic links / email codes. Stateless; no migration needed.

6. Migrate sign-in methods config​

Clerk's "Allowed identifiers" + "Sign-in factors" map to LumoAuth's per-organization settings, mostly under Security → Sign-in settings in the portal:

Clerk settingLumoAuth setting
Email + PasswordAlways available; there is no toggle. Sign-in settings → Login flow → Email-first login only controls whether the email is asked for first
Email code (passwordless)Sign-in settings → Login flow → Magic link login
Phone + Password / SMS codeSMS is a second factor, not a primary sign-in method: Sign-in settings → MFA policy → Allowed factors → Text message (SMS) (needs an SMS provider configured)
UsernameNot supported; users sign in with their email address
Google / GitHub / Microsoft / etc.Settings → Integrations → Social login → Add provider
Web3 walletNot supported on LumoAuth

The Login flow tab of Sign-in settings with the Email-first login and Magic link login switches

7. Cutover​

Use the parallel-run pattern. Clerk-specific notes:

  • Clerk's <SignIn /> React component embeds Clerk's hosted UI. Replace with <SignIn /> from @lumoauth/react (npm install @lumoauth/react) — the API is intentionally similar to ease the cutover.
  • Clerk webhooks (user.created, session.created, …) have LumoAuth equivalents (user.created, user.login, session.revoked, …) — see the event catalogue. Repoint your webhook receiver.
  • Clerk's useUser() / useAuth() hooks have direct equivalents in @lumoauth/react: useUser() and useLumoAuth() (renamed to avoid conflicts during dual-run).

What you gain by migrating​

ClerkLumoAuth
Cost at 50K MAU$1,000/mo+$449/mo (Business)
Custom domainPro tierincluded
RBAC (access by role)includedincluded
ABAC (rules on attributes) / Zanzibar-style relationshipsnot availableincluded
SAML / SCIMenterprise onlyincluded on Business
AI agent identity (AAuth, MCP, JIT)not availableincluded
EU data residencyenterpriseincluded on Business

Full migrations index →