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
- Record the configured
modelName, field mappings, additional fields, and enabled plugins before reading the database. - Export each core user ID, email, verification state, name, image, and application-defined user field.
- Export account provider records, but do not copy passwords, access tokens, refresh tokens, or ID tokens into Own Auth.
- 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
imagetoimageUrl, and pass email, name, and image toauth.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
disabledflag 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.
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 = "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.