Back to guides

Migrate from Better Auth to Own Auth


This guide covers database-backed Better Auth installations. Export the core user and account tables plus every plugin table the application uses. Create Own Auth users without passwords, map each Better Auth user ID to its Own Auth user ID, and verify each account by magic link. Better Auth password digests and sessions are not imported.

Export Better Auth users

  1. Record the configured modelName, field mappings, additional fields, and enabled plugins before reading the database.
  2. Export each core user ID, email, verification state, name, image, and application-defined user field.
  3. Export account provider records, but do not copy passwords, access tokens, refresh tokens, or ID tokens into Own Auth.
  4. Export the organisation, member, invitation, passkey, two-factor, and admin-plugin records used by the application.

Map Better Auth records

  • Use the Better Auth user ID as the migration key. Normalize image to imageUrl, and pass email, name, and image to auth.createUser. Keep application-defined user fields in the application profile.
  • Map Better Auth organisations and memberships to Own Auth organisations and roles. Map custom roles and permissions to the application rules that replace them.
  • 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 the first Own Auth sign-in.
  • Set the import row's disabled flag when the Better Auth admin plugin reports an active ban.

Map Better Auth user IDs to Own Auth users

Transform each source record into the importer fields below. This Postgres table keeps the Better Auth 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 = "better-auth";

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 Better Auth user IDs in application records

Update application records from Better Auth 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

Better Auth session cookies, database rows, secondary-storage entries, and stateless sessions cannot be reused by Own Auth. After auth.verifyMagicLink returns complete, set the Own Auth session and stop accepting Better Auth sessions for that account.