Migrate from Clerk to Own Auth
Clerk's CSV export includes password hashes, but Own Auth does not import them. Create each user without a password, retain its Clerk ID, and verify it with an Own Auth magic link.
Export Clerk users
- Download the user CSV from the source Clerk instance.
- Paginate Clerk's
getUserList()for email addresses, metadata, account state, linked identities, and password or MFA enrolment flags. - Paginate Clerk's organisation and membership endpoints for organisation IDs, roles, permissions, and membership metadata.
- Pause Clerk sign-ups, account updates, and organisation changes before the final CSV and API export.
Map Clerk records
- Use
primaryEmailAddressIdto select the email imported into Own Auth. - Use Clerk
User.idas the migration key. PreserveexternalIdseparately only when the application uses it. - Copy only the
publicMetadataandprivateMetadatafields the application still uses. TreatunsafeMetadataas user-supplied. - Create Own Auth organisations first. Translate each Clerk organisation ID, membership, role, and permission into its Own Auth or application authorization equivalent.
- Do not import
externalAccountsorenterpriseAccountsas credentials. Require the user to authenticate with Apple, GitHub, or Google again before linking that provider to the mapped Own Auth account. Verify remaining email accounts by magic link. - Require new passkey, TOTP, and recovery-code enrolment after migration.
- Set the import row's
disabledflag when Clerk reportsbannedorlocked; the importer skips that account. - For an email-less account, complete Own Auth phone, Apple, GitHub, or Google sign-in first, then add the new Own Auth user ID to the Clerk user ID mapping.
Map Clerk user IDs to Own Auth users
Transform each source record into the importer fields below. This Postgres table keeps the Clerk user ID mapping separate from the Own Auth user record. The migration metadata lets a rerun find a previously created user and complete its mapping row.
npm install pg
npm install --save-dev @types/pgCREATE TABLE auth_user_migrations (
source text NOT NULL,
source_user_id text NOT NULL,
own_auth_user_id text NOT NULL,
PRIMARY KEY (source, source_user_id),
UNIQUE (source, own_auth_user_id)
);import { Pool } from "pg";
import { auth } from "../auth";
const databaseUrl = process.env.DATABASE_URL!;
const db = new Pool({
connectionString: databaseUrl,
allowExitOnIdle: true,
});
const source = "clerk";
export type ExportedUser = {
id: string;
email: string | null;
name?: string | null;
imageUrl?: string | null;
disabled?: boolean;
};
export async function importUser(exportedUser: ExportedUser) {
if (exportedUser.disabled) {
return {
status: "skipped",
legacyUserId: exportedUser.id,
reason: "disabled_account",
} as const;
}
if (!exportedUser.email) {
return {
status: "skipped",
legacyUserId: exportedUser.id,
reason: "missing_email",
} as const;
}
const mapped = await db.query<{ own_auth_user_id: string }>(
`SELECT own_auth_user_id
FROM auth_user_migrations
WHERE source = $1 AND source_user_id = $2`,
[source, exportedUser.id],
);
if (mapped.rows[0]) {
return {
status: "imported",
ownAuthUserId: mapped.rows[0].own_auth_user_id,
} as const;
}
let user = await auth.storage.getUserByEmail(exportedUser.email);
if (user) {
const matchesSource =
user.metadata.migrationSource === source &&
user.metadata.legacyUserId === exportedUser.id;
if (!matchesSource) {
throw new Error(`Email collision for ${exportedUser.id}`);
}
} else {
user = await auth.createUser({
email: exportedUser.email,
name: exportedUser.name ?? undefined,
imageUrl: exportedUser.imageUrl ?? undefined,
metadata: {
migrationSource: source,
legacyUserId: exportedUser.id,
},
});
}
await db.query(
`INSERT INTO auth_user_migrations
(source, source_user_id, own_auth_user_id)
VALUES ($1, $2, $3)`,
[source, exportedUser.id, user.id],
);
return {
status: "imported",
ownAuthUserId: user.id,
} as const;
}Verify each imported account
Pass the token from the email link to auth.verifyMagicLink. If it returns mfa_required, complete the challenge. Continue only after complete, which contains the new Own Auth session.
Replace Clerk user IDs in application records
Update application records from Clerk user IDs to Own Auth user IDs only after the user completes an Own Auth sign-in. Never link records by email alone.
Session replacement
Clerk sessions do not become Own Auth sessions. After the user completes Own Auth sign-in, set the Own Auth session and stop accepting Clerk sessions for that account.