docs/content/docs/guides/supabase-migration-guide.mdx
In this guide, we'll walk through the steps to migrate a project from Supabase Auth to Better Auth.
<Callout type="warn"> This migration will invalidate all active sessions. While this guide doesn't currently cover migrating two-factor (2FA) or Row Level Security (RLS) configurations, both should be possible with additional steps. </Callout> <Callout type="error"> **Back up your database before running any migration scripts.** This guide modifies production data. Create a full backup of both your Supabase database and target database before proceeding. </Callout>Before starting the migration process, set up Better Auth in your project. Follow the installation guide to get started.
<Steps> <Step> ### Connect to your databaseYou'll need to connect to your database to migrate the users and accounts. Copy your `DATABASE_URL` from your Supabase project and use it to connect to your database. And for this example, we'll need to install `pg` to connect to the database.
```package-install
npm install pg
```
And then you can use the following code to connect to your database.
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
})
```
Enable the email and password in your auth config.
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: { // [!code highlight]
enabled: true, // [!code highlight]
} // [!code highlight]
})
```
<Callout type="info">
If you want to require email verification, add the `emailVerification` config separately. See the [Email Verification docs](/docs/authentication/email-password#email-verification) for details.
</Callout>
Add all the social providers used in Supabase to the auth config. Missing any may cause user data loss during migration.
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
socialProviders: { // [!code highlight]
github: { // [!code highlight]
clientId: process.env.GITHUB_CLIENT_ID!, // [!code highlight]
clientSecret: process.env.GITHUB_CLIENT_SECRET!, // [!code highlight]
} // [!code highlight]
} // [!code highlight]
})
```
Add plugins that match the features you used in Supabase. Include only what you need:
* **[admin](/docs/plugins/admin)**: If you have users with `is_super_admin` or `banned_until` fields
* **[anonymous](/docs/plugins/anonymous)**: If you used anonymous authentication (`signInAnonymously`)
* **[phoneNumber](/docs/plugins/phone-number)**: If you have users who signed up with phone numbers
<Callout type="info">
Only include plugins for features you actually used in Supabase. The migration script will automatically detect which plugins are enabled and migrate data accordingly.
</Callout>
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
},
plugins: [admin(), anonymous(), phoneNumber()], // [!code highlight]
})
```
To minimize data loss from Supabase Auth, the following additional fields are required. You can adjust them as needed after the migration is complete.
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
},
plugins: [admin(), anonymous(), phoneNumber()],
user: { // [!code highlight]
additionalFields: { // [!code highlight]
userMetadata: { // [!code highlight]
type: 'json', // [!code highlight]
required: false, // [!code highlight]
input: false, // [!code highlight]
}, // [!code highlight]
appMetadata: { // [!code highlight]
type: 'json', // [!code highlight]
required: false, // [!code highlight]
input: false, // [!code highlight]
}, // [!code highlight]
invitedAt: { // [!code highlight]
type: 'date', // [!code highlight]
required: false, // [!code highlight]
input: false, // [!code highlight]
}, // [!code highlight]
lastSignInAt: { // [!code highlight]
type: 'date', // [!code highlight]
required: false, // [!code highlight]
input: false, // [!code highlight]
}, // [!code highlight]
}, // [!code highlight]
}, // [!code highlight]
})
```
Run the migration to create the necessary tables in your database.
```bash title="Terminal"
npx auth migrate
```
This will create the necessary tables in the `public` schema of your Better Auth instance.
Now that we have the necessary tables in our database, we can run the migration script to migrate the users and accounts from Supabase to Better Auth.
First, set up the environment variables used by the script.
```dotenv title=".env"
FROM_DATABASE_URL= # Supabase database connection string (reads from auth.users)
TO_DATABASE_URL= # Target Postgres database connection string (writes to public.user)
```
<Callout type="info">
**Same database?** If you're migrating within the same Supabase Postgres database (from `auth` schema to `public` schema), both URLs can be the same `DATABASE_URL`. If you're migrating to a different database (e.g., PlanetScale, separate Postgres), use different URLs.
</Callout>
And then copy and paste the following code into the file.
```ts title="migration.ts"
import { generateId } from 'better-auth';
import { DBFieldAttribute } from 'better-auth/db';
import { Pool } from 'pg';
import { auth } from './auth'; // <- Your Better Auth Instance
// ============================================================================
// CONFIGURATION
// ============================================================================
const CONFIG = {
/**
* Number of users to process in each batch
* Higher values = faster migration but more memory usage
* Recommended: 5000-10000 for most cases
*/
batchSize: 5000,
/**
* Resume from a specific user ID (cursor-based pagination)
* Useful for resuming interrupted migrations
* Set to null to start from the beginning
*/
resumeFromId: null as string | null,
/**
* Temporary email domain for phone-only users
* Phone-only users need an email for Better Auth
* Format: {phone_number}@{tempEmailDomain}
*/
tempEmailDomain: 'temp.better-auth.com',
};
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
type MigrationStatus = 'idle' | 'running' | 'paused' | 'completed' | 'failed';
type MigrationState = {
status: MigrationStatus;
totalUsers: number;
processedUsers: number;
successCount: number;
failureCount: number;
skipCount: number;
currentBatch: number;
totalBatches: number;
startedAt: Date | null;
completedAt: Date | null;
lastProcessedId: string | null;
errors: Array<{ userId: string; error: string }>;
};
type UserInsertData = {
id: string;
email: string | null;
name: string;
emailVerified: boolean;
createdAt: string | null;
updatedAt: string | null;
image?: string;
[key: string]: any;
};
type AccountInsertData = {
id: string;
userId: string;
providerId: string;
accountId: string;
password: string | null;
createdAt: string | null;
updatedAt: string | null;
};
type SupabaseIdentityFromDB = {
id: string;
provider_id: string;
user_id: string;
identity_data: Record<string, any>;
provider: string;
last_sign_in_at: string | null;
created_at: string | null;
updated_at: string | null;
email: string | null;
};
type SupabaseUserFromDB = {
instance_id: string | null;
id: string;
aud: string | null;
role: string | null;
email: string | null;
encrypted_password: string | null;
email_confirmed_at: string | null;
invited_at: string | null;
confirmation_token: string | null;
confirmation_sent_at: string | null;
recovery_token: string | null;
recovery_sent_at: string | null;
email_change_token_new: string | null;
email_change: string | null;
email_change_sent_at: string | null;
last_sign_in_at: string | null;
raw_app_meta_data: Record<string, any> | null;
raw_user_meta_data: Record<string, any> | null;
is_super_admin: boolean | null;
created_at: string | null;
updated_at: string | null;
phone: string | null;
phone_confirmed_at: string | null;
phone_change: string | null;
phone_change_token: string | null;
phone_change_sent_at: string | null;
confirmed_at: string | null;
email_change_token_current: string | null;
email_change_confirm_status: number | null;
banned_until: string | null;
reauthentication_token: string | null;
reauthentication_sent_at: string | null;
is_sso_user: boolean;
deleted_at: string | null;
is_anonymous: boolean;
identities: SupabaseIdentityFromDB[];
};
// ============================================================================
// MIGRATION STATE MANAGER
// ============================================================================
class MigrationStateManager {
private state: MigrationState = {
status: 'idle',
totalUsers: 0,
processedUsers: 0,
successCount: 0,
failureCount: 0,
skipCount: 0,
currentBatch: 0,
totalBatches: 0,
startedAt: null,
completedAt: null,
lastProcessedId: null,
errors: [],
};
start(totalUsers: number, batchSize: number) {
this.state = {
status: 'running',
totalUsers,
processedUsers: 0,
successCount: 0,
failureCount: 0,
skipCount: 0,
currentBatch: 0,
totalBatches: Math.ceil(totalUsers / batchSize),
startedAt: new Date(),
completedAt: null,
lastProcessedId: null,
errors: [],
};
}
updateProgress(
processed: number,
success: number,
failure: number,
skip: number,
lastId: string | null,
) {
this.state.processedUsers += processed;
this.state.successCount += success;
this.state.failureCount += failure;
this.state.skipCount += skip;
this.state.currentBatch++;
if (lastId) {
this.state.lastProcessedId = lastId;
}
}
addError(userId: string, error: string) {
if (this.state.errors.length < 100) {
this.state.errors.push({ userId, error });
}
}
complete() {
this.state.status = 'completed';
this.state.completedAt = new Date();
}
fail() {
this.state.status = 'failed';
this.state.completedAt = new Date();
}
getState(): MigrationState {
return { ...this.state };
}
getProgress(): number {
if (this.state.totalUsers === 0) return 0;
return Math.round((this.state.processedUsers / this.state.totalUsers) * 100);
}
getETA(): string | null {
if (!this.state.startedAt || this.state.processedUsers === 0) {
return null;
}
const elapsed = Date.now() - this.state.startedAt.getTime();
const avgTimePerUser = elapsed / this.state.processedUsers;
const remainingUsers = this.state.totalUsers - this.state.processedUsers;
const remainingMs = avgTimePerUser * remainingUsers;
const seconds = Math.floor(remainingMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
}
// ============================================================================
// DATABASE CONNECTIONS
// ============================================================================
const fromDB = new Pool({
connectionString: process.env.FROM_DATABASE_URL,
});
const toDB = new Pool({
connectionString: process.env.TO_DATABASE_URL,
});
// ============================================================================
// BETTER AUTH VALIDATION
// ============================================================================
/**
* Validates that the imported Better Auth instance meets migration requirements
*/
async function validateAuthConfig() {
const ctx = await auth.$context;
const errors: string[] = [];
const warnings: string[] = [];
// Check emailAndPassword (required)
if (!ctx.options.emailAndPassword?.enabled) {
errors.push('emailAndPassword.enabled must be true');
}
// Check optional plugins - warn if missing, migration will skip related data
const optionalPlugins = ['admin', 'anonymous', 'phone-number'];
const plugins = ctx.options.plugins || [];
const pluginIds = plugins.map((p: any) => p.id);
for (const plugin of optionalPlugins) {
if (!pluginIds.includes(plugin)) {
warnings.push(`Plugin '${plugin}' not found - related Supabase data will be skipped`);
}
}
// Check required additional fields
const additionalFields = ctx.options.user?.additionalFields || {};
const requiredFields: Record<string, DBFieldAttribute> = {
userMetadata: { type: 'json', required: false, input: false },
appMetadata: { type: 'json', required: false, input: false },
invitedAt: { type: 'date', required: false, input: false },
lastSignInAt: { type: 'date', required: false, input: false },
};
for (const [fieldName, expectedConfig] of Object.entries(requiredFields)) {
const fieldConfig = additionalFields[fieldName];
if (!fieldConfig) {
errors.push(`Missing required user.additionalFields: ${fieldName}`);
} else {
// Validate field configuration
if (fieldConfig.type !== expectedConfig.type) {
errors.push(
`user.additionalFields.${fieldName} must have type: '${expectedConfig.type}' (got '${fieldConfig.type}')`,
);
}
if (fieldConfig.required !== expectedConfig.required) {
errors.push(
`user.additionalFields.${fieldName} must have required: ${expectedConfig.required}`,
);
}
if (fieldConfig.input !== expectedConfig.input) {
errors.push(`user.additionalFields.${fieldName} must have input: ${expectedConfig.input}`);
}
}
}
// Show warnings (non-blocking)
if (warnings.length > 0) {
console.warn('\n⚠️ Migration Warnings:\n');
warnings.forEach((warn) => console.warn(` ${warn}`));
console.warn('\n Add plugins for features you used in Supabase to migrate that data.\n');
}
// Show errors (blocking)
if (errors.length > 0) {
console.error('\n🟧 Better Auth Configuration Errors:\n');
errors.forEach((err) => console.error(` ${err}`));
console.error('\n🟧 Please update your Better Auth configuration to include:\n');
console.error(' 1. emailAndPassword: { enabled: true }');
console.error(
' 2. user.additionalFields: { userMetadata, appMetadata, invitedAt, lastSignInAt }\n',
);
process.exit(1);
}
return ctx;
}
// ============================================================================
// MIGRATION LOGIC
// ============================================================================
const stateManager = new MigrationStateManager();
let ctxCache: {
hasAnonymousPlugin: boolean;
hasAdminPlugin: boolean;
hasPhoneNumberPlugin: boolean;
supportedProviders: string[];
} | null = null;
async function processBatch(
users: SupabaseUserFromDB[],
ctx: any,
): Promise<{
success: number;
failure: number;
skip: number;
errors: Array<{ userId: string; error: string }>;
}> {
const stats = {
success: 0,
failure: 0,
skip: 0,
errors: [] as Array<{ userId: string; error: string }>,
};
if (!ctxCache) {
ctxCache = {
hasAdminPlugin: ctx.options.plugins?.some((p: any) => p.id === 'admin') || false,
hasAnonymousPlugin: ctx.options.plugins?.some((p: any) => p.id === 'anonymous') || false,
hasPhoneNumberPlugin: ctx.options.plugins?.some((p: any) => p.id === 'phone-number') || false,
supportedProviders: Object.keys(ctx.options.socialProviders || {}),
};
}
const { hasAdminPlugin, hasAnonymousPlugin, hasPhoneNumberPlugin, supportedProviders } = ctxCache;
const validUsersData: Array<{ user: SupabaseUserFromDB; userData: UserInsertData }> = [];
for (const user of users) {
if (!user.email && !user.phone) {
stats.skip++;
continue;
}
if (!user.email && !hasPhoneNumberPlugin) {
stats.skip++;
continue;
}
if (user.deleted_at) {
stats.skip++;
continue;
}
if (user.banned_until && !hasAdminPlugin) {
stats.skip++;
continue;
}
const getTempEmail = (phone: string) =>
`${phone.replace(/[^0-9]/g, '')}@${CONFIG.tempEmailDomain}`;
const getName = (): string => {
if (user.raw_user_meta_data?.name) return user.raw_user_meta_data.name;
if (user.raw_user_meta_data?.full_name) return user.raw_user_meta_data.full_name;
if (user.raw_user_meta_data?.username) return user.raw_user_meta_data.username;
if (user.raw_user_meta_data?.user_name) return user.raw_user_meta_data.user_name;
const firstId = user.identities?.[0];
if (firstId?.identity_data?.name) return firstId.identity_data.name;
if (firstId?.identity_data?.full_name) return firstId.identity_data.full_name;
if (firstId?.identity_data?.username) return firstId.identity_data.username;
if (firstId?.identity_data?.preferred_username)
return firstId.identity_data.preferred_username;
if (user.email) return user.email.split('@')[0]!;
if (user.phone) return user.phone;
return 'Unknown';
};
const getImage = (): string | undefined => {
if (user.raw_user_meta_data?.avatar_url) return user.raw_user_meta_data.avatar_url;
if (user.raw_user_meta_data?.picture) return user.raw_user_meta_data.picture;
const firstId = user.identities?.[0];
if (firstId?.identity_data?.avatar_url) return firstId.identity_data.avatar_url;
if (firstId?.identity_data?.picture) return firstId.identity_data.picture;
return undefined;
};
const userData: UserInsertData = {
id: user.id,
email: user.email || (user.phone ? getTempEmail(user.phone) : null),
emailVerified: !!user.email_confirmed_at,
name: getName(),
image: getImage(),
createdAt: user.created_at,
updatedAt: user.updated_at,
};
if (hasAnonymousPlugin) userData.isAnonymous = user.is_anonymous;
if (hasPhoneNumberPlugin && user.phone) {
userData.phoneNumber = user.phone;
userData.phoneNumberVerified = !!user.phone_confirmed_at;
}
if (hasAdminPlugin) {
userData.role = user.is_super_admin ? 'admin' : user.role || 'user';
if (user.banned_until) {
const banExpires = new Date(user.banned_until);
if (banExpires > new Date()) {
userData.banned = true;
userData.banExpires = banExpires;
userData.banReason = 'Migrated from Supabase (banned)';
} else {
userData.banned = false;
}
} else {
userData.banned = false;
}
}
if (user.raw_user_meta_data && Object.keys(user.raw_user_meta_data).length > 0) {
userData.userMetadata = user.raw_user_meta_data;
}
if (user.raw_app_meta_data && Object.keys(user.raw_app_meta_data).length > 0) {
userData.appMetadata = user.raw_app_meta_data;
}
if (user.invited_at) userData.invitedAt = user.invited_at;
if (user.last_sign_in_at) userData.lastSignInAt = user.last_sign_in_at;
validUsersData.push({ user, userData });
}
if (validUsersData.length === 0) {
return stats;
}
try {
await toDB.query('BEGIN');
const allFields = new Set<string>();
validUsersData.forEach(({ userData }) => {
Object.keys(userData).forEach((key) => allFields.add(key));
});
const fields = Array.from(allFields);
const maxParamsPerQuery = 65000;
const fieldsPerUser = fields.length;
const usersPerChunk = Math.floor(maxParamsPerQuery / fieldsPerUser);
for (let i = 0; i < validUsersData.length; i += usersPerChunk) {
const chunk = validUsersData.slice(i, i + usersPerChunk);
const placeholders: string[] = [];
const values: any[] = [];
let paramIndex = 1;
for (const { userData } of chunk) {
const userPlaceholders = fields.map((field) => {
values.push(userData[field] ?? null);
return `$${paramIndex++}`;
});
placeholders.push(`(${userPlaceholders.join(', ')})`);
}
await toDB.query(
`
INSERT INTO "user" (${fields.map((f) => `"${f}"`).join(', ')})
VALUES ${placeholders.join(', ')}
ON CONFLICT ("id") DO NOTHING
`,
values,
);
}
const accountsData: AccountInsertData[] = [];
for (const { user } of validUsersData) {
for (const identity of user.identities ?? []) {
if (identity.provider === 'email') {
accountsData.push({
id: generateId(),
userId: user.id,
providerId: 'credential',
accountId: user.id,
password: user.encrypted_password || null,
createdAt: user.created_at,
updatedAt: user.updated_at,
});
}
if (supportedProviders.includes(identity.provider)) {
accountsData.push({
id: generateId(),
userId: user.id,
providerId: identity.provider,
accountId: identity.identity_data?.sub || identity.provider_id,
password: null,
createdAt: identity.created_at ?? user.created_at,
updatedAt: identity.updated_at ?? user.updated_at,
});
}
}
}
if (accountsData.length > 0) {
const maxParamsPerQuery = 65000;
const fieldsPerAccount = 7;
const accountsPerChunk = Math.floor(maxParamsPerQuery / fieldsPerAccount);
for (let i = 0; i < accountsData.length; i += accountsPerChunk) {
const chunk = accountsData.slice(i, i + accountsPerChunk);
const accountPlaceholders: string[] = [];
const accountValues: any[] = [];
let paramIndex = 1;
for (const acc of chunk) {
accountPlaceholders.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`,
);
accountValues.push(
acc.id,
acc.userId,
acc.providerId,
acc.accountId,
acc.password,
acc.createdAt,
acc.updatedAt,
);
}
await toDB.query(
`
INSERT INTO "account" ("id", "userId", "providerId", "accountId", "password", "createdAt", "updatedAt")
VALUES ${accountPlaceholders.join(', ')}
ON CONFLICT ("id") DO NOTHING
`,
accountValues,
);
}
}
await toDB.query('COMMIT');
stats.success = validUsersData.length;
} catch (error: any) {
await toDB.query('ROLLBACK');
console.error('[TRANSACTION] Batch failed, rolled back:', error.message);
stats.failure = validUsersData.length;
if (stats.errors.length < 100) {
stats.errors.push({ userId: 'bulk', error: error.message });
}
}
return stats;
}
async function migrateFromSupabase() {
const { batchSize, resumeFromId } = CONFIG;
console.log('[MIGRATION] Starting migration with config:', CONFIG);
// Validate Better Auth configuration
const ctx = await validateAuthConfig();
try {
const countResult = await fromDB.query<{ count: string }>(
`
SELECT COUNT(*) as count FROM auth.users
${resumeFromId ? 'WHERE id > $1' : ''}
`,
resumeFromId ? [resumeFromId] : [],
);
const totalUsers = parseInt(countResult.rows[0]?.count || '0', 10);
console.log(`[MIGRATION] Starting migration for ${totalUsers.toLocaleString()} users`);
console.log(`[MIGRATION] Batch size: ${batchSize}\n`);
stateManager.start(totalUsers, batchSize);
let lastProcessedId: string | null = resumeFromId;
let hasMore = true;
let batchNumber = 0;
while (hasMore) {
batchNumber++;
const batchStart = Date.now();
const result: { rows: SupabaseUserFromDB[] } = await fromDB.query<SupabaseUserFromDB>(
`
SELECT
u.*,
COALESCE(
json_agg(
i.* ORDER BY i.id
) FILTER (WHERE i.id IS NOT NULL),
'[]'::json
) as identities
FROM auth.users u
LEFT JOIN auth.identities i ON u.id = i.user_id
${lastProcessedId ? 'WHERE u.id > $1' : ''}
GROUP BY u.id
ORDER BY u.id ASC
LIMIT $${lastProcessedId ? '2' : '1'}
`,
lastProcessedId ? [lastProcessedId, batchSize] : [batchSize],
);
const batch: SupabaseUserFromDB[] = result.rows;
hasMore = batch.length === batchSize;
if (batch.length === 0) break;
console.log(
`\nBatch ${batchNumber}/${Math.ceil(totalUsers / batchSize)} (${batch.length} users)`,
);
const stats = await processBatch(batch, ctx);
lastProcessedId = batch[batch.length - 1]!.id;
stateManager.updateProgress(
batch.length,
stats.success,
stats.failure,
stats.skip,
lastProcessedId,
);
stats.errors.forEach((err) => stateManager.addError(err.userId, err.error));
const batchTime = ((Date.now() - batchStart) / 1000).toFixed(2);
const usersPerSec = (batch.length / parseFloat(batchTime)).toFixed(0);
const state = stateManager.getState();
console.log(`Success: ${stats.success} | Skip: ${stats.skip} | Failure: ${stats.failure}`);
console.log(
`Progress: ${stateManager.getProgress()}% (${state.processedUsers.toLocaleString()}/${state.totalUsers.toLocaleString()})`,
);
console.log(`Speed: ${usersPerSec} users/sec (${batchTime}s for this batch)`);
const eta = stateManager.getETA();
if (eta) {
console.log(`ETA: ${eta}`);
}
}
stateManager.complete();
const finalState = stateManager.getState();
console.log('\n🎉 Migration completed');
console.log(` - Success: ${finalState.successCount.toLocaleString()}`);
console.log(` - Skipped: ${finalState.skipCount.toLocaleString()}`);
console.log(` - Failed: ${finalState.failureCount.toLocaleString()}`);
const totalTime =
finalState.completedAt && finalState.startedAt
? ((finalState.completedAt.getTime() - finalState.startedAt.getTime()) / 1000 / 60).toFixed(
1,
)
: '0';
console.log(` Total time: ${totalTime} minutes`);
if (finalState.errors.length > 0) {
console.log(`\nFirst ${Math.min(10, finalState.errors.length)} errors:`);
finalState.errors.slice(0, 10).forEach((err) => {
console.log(`- User ${err.userId}: ${err.error}`);
});
}
return finalState;
} catch (error) {
stateManager.fail();
console.error('\nMigration failed:', error);
throw error;
} finally {
await fromDB.end();
await toDB.end();
}
}
// ============================================================================
// MAIN ENTRY POINT
// ============================================================================
async function main() {
console.log('🚀 Supabase Auth → Better Auth Migration\n');
if (!process.env.FROM_DATABASE_URL) {
console.error('Error: FROM_DATABASE_URL environment variable is required');
process.exit(1);
}
if (!process.env.TO_DATABASE_URL) {
console.error('Error: TO_DATABASE_URL environment variable is required');
process.exit(1);
}
try {
await migrateFromSupabase();
process.exit(0);
} catch (error) {
console.error('\nMigration failed:', error);
process.exit(1);
}
}
main();
```
You can configure the script using `CONFIG` inside the script.
* `batchSize`: Number of users to process in each batch. Default: 5000
* `resumeFromId`: Resume from a specific user ID (cursor-based pagination). Default: null
* `tempEmailDomain`: Temporary email domain for phone-only users. Default: "temp.better-auth.com"
<Callout type="info">
The migration script uses **keyset pagination** (cursor-based) which efficiently handles large datasets without loading everything into memory. For very large migrations (500k+ users), you may need to increase Node's memory limit with `NODE_OPTIONS="--max-old-space-size=8192"`.
</Callout>
Run the migration script to migrate the users and accounts from Supabase to Better Auth.
```bash title="Terminal"
npx tsx migration.ts
```
You can also use other TypeScript runners like `bun migration.ts`, `ts-node migration.ts`, or compile to JS first.
By default, Better Auth uses the `scrypt` algorithm to hash passwords. Since Supabase uses `bcrypt`, you'll need to configure Better Auth to use bcrypt for password verification.
First, install bcrypt:
```package-install
npm install bcrypt
```
```bash title="Terminal"
npm install -D @types/bcrypt
```
Then add the password configuration to your existing auth config:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
import bcrypt from "bcrypt"; // [!code highlight]
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
password: { // [!code highlight]
hash: async (password) => { // [!code highlight]
return await bcrypt.hash(password, 10); // [!code highlight]
}, // [!code highlight]
verify: async ({ hash, password }) => { // [!code highlight]
return await bcrypt.compare(password, hash); // [!code highlight]
} // [!code highlight]
} // [!code highlight]
},
socialProviders: {
// ... your social providers
},
plugins: [admin(), anonymous(), phoneNumber()],
user: {
additionalFields: {
// ... your additional fields
},
},
})
```
Update your codebase from Supabase auth calls to Better Auth API.
Here's a list of the Supabase auth API calls and their Better Auth counterparts.
* `supabase.auth.signUp` -> `authClient.signUp.email`
* `supabase.auth.signInWithPassword` -> `authClient.signIn.email`
* `supabase.auth.signInWithOAuth` -> `authClient.signIn.social`
* `supabase.auth.signInAnonymously` -> `authClient.signIn.anonymous`
* `supabase.auth.signOut` -> `authClient.signOut`
* `supabase.auth.getSession` -> `authClient.getSession` - you can also use `authClient.useSession` for reactive state
Learn more:
* [Basic Usage](/docs/basic-usage): Learn how to use the auth client to sign up, sign in, and sign out.
* [Email and Password](/docs/authentication/email-password): Learn how to add email and password authentication to your project.
* [Anonymous](/docs/plugins/anonymous): Learn how to add anonymous authentication to your project.
* [Admin](/docs/plugins/admin): Learn how to add admin authentication to your project.
* [Email OTP](/docs/plugins/email-otp): Learn how to add email OTP authentication to your project.
* [Hooks](/docs/concepts/hooks): Learn how to use the hooks to listen for events.
* [Next.js](/docs/integrations/next): Learn how to use the auth client in a Next.js project.
If you're using Supabase's Enterprise SSO (SAML), follow these additional steps to migrate your SSO providers and users.
<Callout type="warn"> SSO migration requires updating your Identity Provider (IdP) configuration with new callback URLs. Plan for a brief cutover window as existing SSO sessions will be invalidated. </Callout> <Steps> <Step> ### Install the SSO pluginInstall the Better Auth SSO package:
```package-install
npm install @better-auth/sso
```
Add the SSO plugin to your auth configuration:
```ts title="auth.ts"
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
import { sso } from '@better-auth/sso'; // [!code highlight]
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
plugins: [
admin(),
anonymous(),
phoneNumber(),
sso(), // [!code highlight]
],
})
```
Run the migration to create the `ssoProvider` table:
```bash title="Terminal"
npx auth migrate
```
List and export your existing SSO providers from Supabase using the CLI:
```bash title="Terminal"
# List all SSO providers (note: this does NOT include metadata XML)
supabase sso list --project-ref <your-project-ref>
# Export each provider's full details (including metadata XML) to a JSON file
# You MUST use `sso show`, not `sso list`, to get the metadata_xml field
supabase sso show <provider-id> --project-ref <your-project-ref> -o json > sso-provider.json
```
A typical Supabase SSO provider export looks like this:
```json title="sso-provider.json"
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"saml": {
"entity_id": "https://idp.example.com/saml",
"metadata_url": "https://idp.example.com/saml/metadata",
"metadata_xml": "<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" entityID=\"https://idp.example.com/saml\">...</md:EntityDescriptor>",
"attribute_mapping": {
"keys": {
"email": { "name": "mail" },
"first_name": { "name": "givenName" },
"last_name": { "name": "surname" }
}
}
},
"domains": [
{ "id": "domain-uuid", "domain": "company.com" }
]
}
```
<Callout type="info">
If you have multiple SSO providers, export each one separately. You'll need all of them for the migration script.
</Callout>
Add these type definitions to your migration script:
```ts title="migration.ts"
// ============================================================================
// SSO MIGRATION TYPES
// ============================================================================
type SupabaseSSOProvider = {
id: string;
saml: {
entity_id: string;
metadata_url?: string;
metadata_xml?: string;
attribute_mapping?: {
keys: Record<string, {
name?: string;
names?: string[];
default?: unknown;
array?: boolean;
}>;
};
name_id_format?: string;
};
domains: Array<{ id?: string; domain: string }>;
};
type SSOProviderInsertData = {
id: string;
providerId: string;
issuer: string;
domain: string;
oidcConfig: null;
samlConfig: string;
organizationId: string | null;
createdAt: string;
updatedAt: string;
};
```
Add this function to migrate your SSO providers:
```ts title="migration.ts"
// ============================================================================
// SSO PROVIDER MIGRATION
// ============================================================================
/**
* Migrates Supabase SSO providers to Better Auth format
*
* @param providers - Array of exported Supabase SSO providers
* @param betterAuthUrl - Your Better Auth base URL (e.g., https://app.example.com)
*/
async function migrateSSOProviders(
providers: SupabaseSSOProvider[],
betterAuthUrl: string
) {
console.log(`\n[SSO] Migrating ${providers.length} SSO provider(s)...\n`);
for (const provider of providers) {
// Create a unique provider ID that references the original Supabase ID
const providerId = `sso-${provider.id}`;
// Combine all domains into a comma-separated string
const domains = provider.domains.map(d => d.domain).join(',');
// Transform Supabase attribute mapping to Better Auth format.
// Supabase supports both { name: "attr" } and { names: ["attr1", "attr2"] };
// Better Auth only uses a single attribute name, so take the first match.
const mapping = provider.saml.attribute_mapping?.keys || {};
const attrName = (key: string) => mapping[key]?.name || mapping[key]?.names?.[0];
// If Supabase provided a metadata URL, fetch the XML first.
// Better Auth requires inline metadata XML, not a URL.
let idpMetadataXml = provider.saml.metadata_xml;
if (!idpMetadataXml && provider.saml.metadata_url) {
const res = await fetch(provider.saml.metadata_url);
if (!res.ok) {
console.error(`[SSO] Failed to fetch IdP metadata from ${provider.saml.metadata_url}`);
continue;
}
idpMetadataXml = await res.text();
}
if (!idpMetadataXml) {
console.error(`[SSO] No IdP metadata available for provider ${providerId}`);
continue;
}
const samlConfig = {
issuer: provider.saml.entity_id,
entryPoint: '',
cert: '',
// Inline IdP metadata XML (required).
// When metadata is present, entryPoint/cert are ignored;
// samlify extracts them from the XML.
idpMetadata: {
metadata: idpMetadataXml,
},
spMetadata: {
entityID: `${betterAuthUrl}/api/auth/sso/saml2/sp/metadata?providerId=${providerId}`,
},
// Name ID format from Supabase config
identifierFormat: provider.saml.name_id_format ||
'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
// Map Supabase attribute names to Better Auth fields
mapping: {
id: 'nameID',
email: attrName('email') || 'email',
name: attrName('name') || 'displayName',
firstName: attrName('first_name') || 'givenName',
lastName: attrName('last_name') || 'surname',
},
};
const ssoProviderData: SSOProviderInsertData = {
id: generateId(),
providerId,
issuer: provider.saml.entity_id,
domain: domains,
oidcConfig: null,
samlConfig: JSON.stringify(samlConfig),
organizationId: null, // Set this if linking to a Better Auth organization
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
try {
await toDB.query(`
INSERT INTO "ssoProvider" (
"id", "providerId", "issuer", "domain",
"oidcConfig", "samlConfig", "organizationId",
"createdAt", "updatedAt"
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT ("providerId") DO UPDATE SET
"issuer" = EXCLUDED."issuer",
"domain" = EXCLUDED."domain",
"samlConfig" = EXCLUDED."samlConfig",
"updatedAt" = EXCLUDED."updatedAt"
`, [
ssoProviderData.id,
ssoProviderData.providerId,
ssoProviderData.issuer,
ssoProviderData.domain,
ssoProviderData.oidcConfig,
ssoProviderData.samlConfig,
ssoProviderData.organizationId,
ssoProviderData.createdAt,
ssoProviderData.updatedAt,
]);
console.log(`[SSO] ✓ Migrated provider: ${providerId}`);
console.log(` Domains: ${domains}`);
console.log(` Issuer: ${provider.saml.entity_id}\n`);
} catch (error: any) {
console.error(`[SSO] ✗ Failed to migrate provider ${providerId}:`, error.message);
}
}
console.log('[SSO] Provider migration complete.\n');
}
```
In the `processBatch` function, add handling for SSO identities. Find the section that processes identities and add:
```ts title="migration.ts"
// Inside the identity processing loop in processBatch()
for (const identity of user.identities ?? []) {
// ... existing email and social provider handling ...
// Handle SSO identities // [!code highlight]
if (identity.provider.startsWith('sso:')) { // [!code highlight]
const supabaseProviderId = identity.provider.replace('sso:', ''); // [!code highlight]
accountsData.push({ // [!code highlight]
id: generateId(), // [!code highlight]
userId: user.id, // [!code highlight]
providerId: `sso-${supabaseProviderId}`, // Matches the migrated provider ID // [!code highlight]
accountId: identity.identity_data?.sub || identity.provider_id, // [!code highlight]
password: null, // [!code highlight]
createdAt: identity.created_at ?? user.created_at, // [!code highlight]
updatedAt: identity.updated_at ?? user.updated_at, // [!code highlight]
}); // [!code highlight]
} // [!code highlight]
}
```
This ensures that users who previously signed in via SSO will have their accounts linked to the migrated SSO provider.
Create a separate script or add to your main migration to run the SSO provider migration:
```ts title="run-sso-migration.ts"
import { readFileSync } from 'fs';
// Load your exported Supabase SSO providers
const providers: SupabaseSSOProvider[] = [
JSON.parse(readFileSync('./sso-provider-1.json', 'utf-8')),
// Add more providers if you have multiple
];
// Your Better Auth application URL
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL || 'https://app.example.com';
async function main() {
// First, migrate users (including SSO user accounts)
await migrateFromSupabase();
// Then, migrate SSO providers
await migrateSSOProviders(providers, BETTER_AUTH_URL);
}
main();
```
Run the migration:
```bash title="Terminal"
npx tsx run-sso-migration.ts
```
After migrating, you need to update your IdP (Okta, Azure AD, Google Workspace, etc.) with the new Better Auth endpoints.
**Update these settings in your IdP:**
| Setting | Old Value (Supabase) | New Value (Better Auth) |
| -------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- |
| ACS URL / Reply URL | `https://<project>.supabase.co/auth/v1/sso/saml/acs` | `https://yourapp.com/api/auth/sso/saml2/sp/acs/<providerId>` |
| Entity ID / Audience | `https://<project>.supabase.co/auth/v1/sso/saml/metadata` | `https://yourapp.com/api/auth/sso/saml2/sp/metadata?providerId=<providerId>` |
Replace `<providerId>` with your migrated provider ID (e.g., `sso-550e8400-e29b-41d4-a716-446655440000`).
<Callout type="info">
You can retrieve the SP metadata for your provider at:
`GET https://yourapp.com/api/auth/sso/saml2/sp/metadata?providerId=<providerId>`
Share this URL with your IdP administrator if they need the full metadata XML.
</Callout>
Replace Supabase SSO authentication calls with Better Auth:
```ts title="Before (Supabase)"
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// Sign in with SSO
await supabase.auth.signInWithSSO({
domain: 'company.com'
});
```
```ts title="After (Better Auth)"
import { createAuthClient } from 'better-auth/client';
import { ssoClient } from '@better-auth/sso/client'; // [!code highlight]
const authClient = createAuthClient({
plugins: [ssoClient()], // [!code highlight]
});
// Sign in with SSO by domain // [!code highlight]
await authClient.signIn.sso({ // [!code highlight]
domain: 'company.com', // [!code highlight]
callbackURL: '/dashboard', // [!code highlight]
}); // [!code highlight]
```
You can also initiate SSO by email (the domain is extracted automatically):
```ts title="auth-client.ts"
// Sign in by email - domain is extracted from email
await authClient.signIn.sso({
email: '[email protected]',
callbackURL: '/dashboard',
});
// Or sign in by provider ID directly
await authClient.signIn.sso({
providerId: 'sso-550e8400-e29b-41d4-a716-446655440000',
callbackURL: '/dashboard',
});
```
Before going live, test the complete SSO flow:
1. **Test SP-initiated SSO**: Start from your app's login page, enter an SSO-enabled email domain
2. **Verify user attributes**: Check that name, email, and other attributes are mapped correctly
3. **Test existing users**: Ensure users who previously logged in via SSO can still access their accounts
4. **Test new users**: Verify that new SSO users are created correctly
```ts title="test-sso.ts"
// Quick test - check if SSO provider is configured correctly
const response = await fetch(
'https://yourapp.com/api/auth/sso/saml2/sp/metadata?providerId=sso-your-provider-id'
);
const metadata = await response.text();
console.log('SP Metadata:', metadata);
```
Use this checklist to ensure a complete SSO migration:
- [ ] SSO plugin installed and configured
- [ ] Database migration run (`ssoProvider` table created)
- [ ] Supabase SSO providers exported
- [ ] Providers migrated to Better Auth database
- [ ] User accounts linked to SSO providers
- [ ] IdP settings updated (ACS URL, Entity ID)
- [ ] Client code updated to use Better Auth SSO
- [ ] SP-initiated SSO flow tested
- [ ] Existing SSO users can log in
- [ ] New SSO users can be created
Learn more about SSO configuration in the SSO Plugin Documentation.
To protect routes with proxy(middleware), refer to the Next.js Auth Protection Guide or your framework's documentation.
Congratulations! You've successfully migrated from Supabase Auth to Better Auth.
Better Auth offers greater flexibility and more features—be sure to explore the documentation to unlock its full potential.