从 Supabase Auth 迁移到 Better Auth
从 Supabase Auth 迁移到 Better Auth 的分步指南
在本指南中,我们将逐步介绍如何将项目从 Supabase Auth 迁移到 Better Auth。
此迁移会使所有活动会话失效。虽然本指南目前未涵盖双因素身份验证(2FA)或行级安全性(RLS)配置的迁移,但通过额外步骤应该可以实现这两项迁移。
运行任何迁移脚本前,请备份您的数据库。 本指南会修改生产数据。在继续操作前,请完整备份 Supabase 数据库和目标数据库。
开始之前
开始迁移过程前,请在您的项目中设置 Better Auth。请参阅 安装指南 来快速开始。
连接到您的数据库
您需要连接到数据库,以迁移用户和账户。从 Supabase 项目中复制您的 DATABASE_URL,并使用它连接到数据库。在本示例中,我们还需要安装 pg 来连接数据库。
npm install pg然后,您可以使用以下代码连接到数据库。
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
})启用电子邮件和密码
在 auth 配置中启用电子邮件和密码。
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
}
})如果您希望要求进行电子邮件验证,请单独添加 emailVerification 配置。详情请参阅电子邮件验证文档。
设置社交登录提供商(可选)
将 Supabase 中使用的所有社交登录提供商添加到 auth 配置中。遗漏任何提供商都可能导致迁移期间用户数据丢失。
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: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
}
})根据 Supabase 功能添加插件
添加与您在 Supabase 中使用的功能相匹配的插件。仅包含您需要的插件:
- admin:如果您有包含
is_super_admin或banned_until字段的用户 - anonymous:如果您使用了匿名身份验证(
signInAnonymously) - phoneNumber:如果您有使用电话号码注册的用户
仅为您实际在 Supabase 中使用的功能添加插件。迁移脚本会自动检测启用了哪些插件,并据此迁移数据。
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()],
})添加额外字段
为尽量减少 Supabase Auth 的数据丢失,需要添加以下额外字段。迁移完成后,您可以根据需要调整这些字段。
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: {
additionalFields: {
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,
},
},
},
})运行迁移
运行迁移,在数据库中创建必要的表。
npx auth migrate这将在 Better Auth 实例的 public schema 中创建必要的表。
现在数据库中已经有了必要的表,我们可以运行迁移脚本,将用户和账户从 Supabase 迁移到 Better Auth。
复制迁移脚本
首先,设置脚本使用的环境变量。
FROM_DATABASE_URL= # Supabase database connection string (reads from auth.users)
TO_DATABASE_URL= # Target Postgres database connection string (writes to public.user)使用同一个数据库? 如果您要在同一个 Supabase Postgres 数据库中迁移(从 auth schema 到 public schema),两个 URL 可以使用相同的 DATABASE_URL。如果要迁移到其他数据库(例如 PlanetScale 或单独的 Postgres),请使用不同的 URL。
然后,将以下代码复制并粘贴到文件中。
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();您可以使用脚本中的 CONFIG 配置脚本。
batchSize:每批处理的用户数量。默认值:5000resumeFromId:从指定用户 ID 继续(基于游标的分页)。默认值:nulltempEmailDomain:仅使用电话号码的用户的临时电子邮件域名。默认值:"temp.better-auth.com"
运行迁移脚本
迁移脚本使用键集分页(基于游标),能够高效处理大型数据集,而无需将所有数据加载到内存中。对于非常大型的迁移(超过 50 万个用户),您可能需要使用 NODE_OPTIONS="--max-old-space-size=8192" 增加 Node 的内存限制。
运行迁移脚本,将用户和账户从 Supabase 迁移到 Better Auth。
npx tsx migration.ts您也可以使用其他 TypeScript 运行器,例如 bun migration.ts、ts-node migration.ts,或者先编译为 JS。
更改密码哈希算法
默认情况下,Better Auth 使用 scrypt 算法对密码进行哈希。由于 Supabase 使用 bcrypt,您需要配置 Better Auth,以便使用 bcrypt 进行密码验证。
首先,安装 bcrypt:
npm install bcryptnpm install -D @types/bcrypt然后,将密码配置添加到现有的 auth 配置中:
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
import bcrypt from "bcrypt";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => {
return await bcrypt.hash(password, 10);
},
verify: async ({ hash, password }) => {
return await bcrypt.compare(password, hash);
}
}
},
socialProviders: {
// ... your social providers
},
plugins: [admin(), anonymous(), phoneNumber()],
user: {
additionalFields: {
// ... your additional fields
},
},
})更新您的代码
将代码库中的 Supabase auth 调用更新为 Better Auth API。
以下是 Supabase auth API 调用及其对应的 Better Auth 调用。
supabase.auth.signUp->authClient.signUp.emailsupabase.auth.signInWithPassword->authClient.signIn.emailsupabase.auth.signInWithOAuth->authClient.signIn.socialsupabase.auth.signInAnonymously->authClient.signIn.anonymoussupabase.auth.signOut->authClient.signOutsupabase.auth.getSession->authClient.getSession- 您也可以使用authClient.useSession获取响应式状态
了解更多:
迁移企业 SSO
如果您没有使用 Supabase 的企业 SSO 功能,请跳过本节。本节仅适用于在 Supabase 中配置了 SAML SSO 提供商的用户。
如果您正在使用 Supabase 的企业 SSO(SAML),请按以下额外步骤迁移您的 SSO 提供商与用户。
SSO 迁移需要使用新的回调 URL 更新您的身份提供商(IdP)配置。请规划一个短暂的切换窗口,因为现有 SSO 会话将失效。
安装 SSO 插件
安装 Better Auth SSO 软件包:
npm install @better-auth/sso将 SSO 插件添加到您的身份验证配置中:
import { betterAuth } from 'better-auth';
import { Pool } from "pg";
import { admin, anonymous, phoneNumber } from 'better-auth/plugins';
import { sso } from '@better-auth/sso';
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
plugins: [
admin(),
anonymous(),
phoneNumber(),
sso(),
],
})导出您的 Supabase SSO 提供商
使用 CLI 列出并导出现有的 Supabase SSO 提供商:
# 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典型的 Supabase SSO 提供商导出结果如下:
{
"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" }
]
}如果您有多个 SSO 提供商,请分别导出每个提供商。迁移脚本需要使用所有这些提供商。
将 SSO 类型添加到迁移脚本
将以下类型定义添加到您的迁移脚本中:
// ============================================================================
// 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;
};添加 SSO 提供商迁移函数
添加以下函数以迁移您的 SSO 提供商:
// ============================================================================
// 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: {
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');
}准备受信任的 SSO 账户身份
Better Auth 会根据提供商元数据中的实际 IdP 实体 ID 以及经过签名的 NameID 来识别 SAML 账户。Supabase 导出数据不包含足够的已验证数据,无法安全地推导出这些值。在迁移账户之前,请创建一个经过操作员审核的映射,将每个旧版 SSO 身份映射到其 IdP 实体 ID 和经过签名的 NameID。不要使用服务提供商的 entity_id、电子邮件地址或配置文件的 sub 声明来替代这些值。
将经过审核的映射添加到您的迁移脚本中。外层映射以 Supabase SSO 提供商 ID 为键,内层映射以其旧版身份 ID 为键:
const samlAccountIdentityByProviderId = new Map([
[
'supabase-provider-id',
new Map([
[
'legacy-identity-id',
{
issuer: 'https://idp.example.com/metadata',
accountId: 'signed-name-id',
},
],
]),
],
]);更新用户迁移以关联 SSO 账户
在 processBatch 函数中,添加对 SSO 身份的处理。找到处理身份的部分并添加:
// Inside the identity processing loop in processBatch()
for (const identity of user.identities ?? []) {
// ... existing email and social provider handling ...
// Handle SSO identities
if (identity.provider.startsWith('sso:')) {
const supabaseProviderId = identity.provider.replace('sso:', '');
const accountIdentity = samlAccountIdentityByProviderId
.get(supabaseProviderId)
?.get(identity.provider_id);
if (!accountIdentity) {
throw new Error(`Missing trusted SAML account identity for ${supabaseProviderId}`);
}
accountsData.push({
id: generateId(),
userId: user.id,
providerId: `sso-${supabaseProviderId}`, // Matches the migrated provider ID
issuer: accountIdentity.issuer, // IdP metadata entity ID
accountId: accountIdentity.accountId, // Signed NameID
password: null,
createdAt: identity.created_at ?? user.created_at,
updatedAt: identity.updated_at ?? user.updated_at,
});
}
}这会将每个迁移后的账户关联到 Better Auth 在登录时将验证的协议身份。如果缺少受信任的映射,请停止迁移,与身份提供商确认并解决问题,然后重新运行该批次。
运行 SSO 迁移
创建单独的脚本,或将其添加到主迁移脚本中,以运行 SSO 提供商迁移:
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();运行迁移:
npx tsx run-sso-migration.ts更新您的身份提供商
迁移后,您需要使用新的 Better Auth 端点更新您的 IdP(Okta、Azure AD、Google Workspace 等)。
在您的 IdP 中更新以下设置:
| 设置 | 旧值(Supabase) | 新值(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> |
将 <providerId> 替换为您迁移后的提供商 ID(例如 sso-550e8400-e29b-41d4-a716-446655440000)。
您可以通过以下地址获取提供商的 SP 元数据:
GET https://yourapp.com/api/auth/sso/saml2/sp/metadata?providerId=<providerId>
如果 IdP 管理员需要完整的元数据 XML,请将此 URL 分享给他们。
更新您的客户端代码
将 Supabase SSO 身份验证调用替换为 Better Auth:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// Sign in with SSO
await supabase.auth.signInWithSSO({
domain: 'company.com'
});import { createAuthClient } from 'better-auth/client';
import { ssoClient } from '@better-auth/sso/client';
const authClient = createAuthClient({
plugins: [ssoClient()],
});
// Sign in with SSO by domain
await authClient.signIn.sso({
domain: 'company.com',
callbackURL: '/dashboard',
}); 您也可以通过电子邮件发起 SSO(系统会自动提取域名):
// 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',
});测试 SSO 流程
上线前,请测试完整的 SSO 流程:
- 测试 SP 发起的 SSO:从应用的登录页面开始,输入已启用 SSO 的电子邮件域名
- 验证用户属性:检查姓名、电子邮件和其他属性是否正确映射
- 测试现有用户:确保之前通过 SSO 登录的用户仍然可以访问其账户
- 测试新用户:验证新 SSO 用户是否能够正确创建
// 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);SSO 迁移检查清单
使用此清单确保完成 SSO 迁移:
- [ ] 安装并配置 SSO 插件
- [ ] 运行数据库迁移(创建 ssoProvider 表)
- [ ] 导出 Supabase SSO 提供商
- [ ] 将提供商迁移至 Better Auth 数据库
- [ ] 用户账户关联到 SSO 提供商
- [ ] 更新 IdP 设置(ACS URL、Entity ID)
- [ ] 更新客户端代码使用 Better Auth SSO
- [ ] 测试 SP 发起的 SSO 流程
- [ ] 已有 SSO 用户可登录
- [ ] 可创建新 SSO 用户SSO 常见问题排查
SAML 签名错误:如果您看到签名验证错误,请确保 IdP 的证书是最新的。某些 IdP 会定期轮换证书,请检查元数据 URL 以获取最新证书。
属性映射问题:如果用户属性未正确填充,请使用浏览器开发者工具检查来自 IdP 的 SAML 断言。更新 samlConfig 中的 mapping 字段,使其与 IdP 发送的确切属性名称匹配。
多个域名:如果单个 SSO 提供商有多个域名,请在 domain 字段中使用逗号分隔它们:"company.com,company.org"。
更多 SSO 配置细节,参见 SSO 插件文档。
认证保护
要使用代理(中间件)保护路由,请参考 Next.js 认证保护指南 或您使用的框架文档。
总结
恭喜!您已成功完成从 Supabase Auth 到 Better Auth 的迁移。
Better Auth 拥有更强的灵活性和更多功能,务必深入探索 文档,挖掘其全部潜力。