Migrate from Auth0 to Own Auth
Auth0's standard bulk export contains user profiles, not password hashes or MFA secrets. Auth0 can provide a separate encrypted secrets export, but auth.createUser does not import those values. Create users without passwords and verify them by magic link.
Export Auth0 users
- Create a JSON-compatible bulk export. Auth0 returns NDJSON, and omitting
connection_idexports users from every tenant connection. - Export
user_id,email,email_verified,phone_number,phone_verified,name,picture,blocked,identities,multifactor,app_metadata, anduser_metadata. - Export tenant roles from the user roles endpoint. Export organisations, memberships, and organisation-scoped roles through the organisation endpoints.
- Normalize
user_idtoid,picturetoimageUrl, andblockedtodisabledbefore calling the importer.
Map Auth0 records
- Keep the complete Auth0
user_idas the migration key. - Move profile fields from
user_metadatainto application profile data. Do not useuser_metadatafor authorization because Auth0 users can edit it. - Map access fields from
app_metadata, tenant roles, organisation memberships, and organisation-scoped roles to Own Auth organisations, roles, or application authorization rules. - Treat
identitiesas an inventory of sign-in methods. 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 MFA, recovery-code, and passkey enrolment after account verification.
- For an email-less account, complete Own Auth phone or supported-provider sign-in first, then add the new Own Auth user ID to the Auth0 user ID mapping.
Map Auth0 user IDs to Own Auth users
Transform each source record into the importer fields below. This Postgres table keeps the Auth0 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 = "auth0";
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 Auth0 user IDs in application records
Update application records from Auth0 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
Auth0 session cookies, access tokens, and refresh tokens do not become Own Auth sessions. After the user completes Own Auth sign-in, set the Own Auth session and reject Auth0 credentials for that user. Revoke Auth0 refresh tokens or grants. Any Auth0 JWT access token still accepted by an API remains valid until its exp.