Back to guides

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

  1. Create a JSON-compatible bulk export. Auth0 returns NDJSON, and omitting connection_id exports users from every tenant connection.
  2. Export user_id, email, email_verified, phone_number, phone_verified, name, picture, blocked, identities, multifactor, app_metadata, and user_metadata.
  3. Export tenant roles from the user roles endpoint. Export organisations, memberships, and organisation-scoped roles through the organisation endpoints.
  4. Normalize user_id to id, picture to imageUrl, and blocked to disabled before calling the importer.

Map Auth0 records

  • Keep the complete Auth0 user_id as the migration key.
  • Move profile fields from user_metadata into application profile data. Do not use user_metadata for 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 identities as 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.

Terminal
npm install pg
npm install --save-dev @types/pg
migration-map.sql
CREATE 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)
);
scripts/import-users.ts
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.