# 从 Supabase Auth 迁移到 Better Auth (/docs/guides/supabase-migration-guide)

从 Supabase Auth 迁移到 Better Auth 的分步指南



在本指南中，我们将逐步介绍如何将项目从 Supabase Auth 迁移到 Better Auth。

<Callout type="warn">
  此迁移会使所有活动会话失效。虽然本指南目前未涵盖双因素身份验证（2FA）或行级安全性（RLS）配置的迁移，但通过额外步骤应该可以实现这两项迁移。
</Callout>

<Callout type="error">
  **运行任何迁移脚本前，请备份您的数据库。** 本指南会修改生产数据。在继续操作前，请完整备份 Supabase 数据库和目标数据库。
</Callout>

## 开始之前 [#开始之前]

开始迁移过程前，请在您的项目中设置 Better Auth。请参阅 [安装指南](/docs/installation) 来快速开始。

<Steps>
  <Step>
    ### 连接到您的数据库 [#连接到您的数据库]

    您需要连接到数据库，以迁移用户和账户。从 Supabase 项目中复制您的 `DATABASE_URL`，并使用它连接到数据库。在本示例中，我们还需要安装 `pg` 来连接数据库。

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install pg
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add pg
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add pg
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add pg
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    然后，您可以使用以下代码连接到数据库。

    ```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 
        }),
    })
    ```
  </Step>

  <Step>
    ### 启用电子邮件和密码 [#启用电子邮件和密码]

    在 auth 配置中启用电子邮件和密码。

    ```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">
      如果您希望要求进行电子邮件验证，请单独添加 `emailVerification` 配置。详情请参阅[电子邮件验证文档](/docs/authentication/email-password#邮箱验证)。
    </Callout>
  </Step>

  <Step>
    ### 设置社交登录提供商（可选） [#设置社交登录提供商可选]

    将 Supabase 中使用的所有社交登录提供商添加到 auth 配置中。遗漏任何提供商都可能导致迁移期间用户数据丢失。

    ```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]
    })
    ```
  </Step>

  <Step>
    ### 根据 Supabase 功能添加插件 [#根据-supabase-功能添加插件]

    添加与您在 Supabase 中使用的功能相匹配的插件。仅包含您需要的插件：

    * **[admin](/docs/plugins/admin)**：如果您有包含 `is_super_admin` 或 `banned_until` 字段的用户
    * **[anonymous](/docs/plugins/anonymous)**：如果您使用了匿名身份验证（`signInAnonymously`）
    * **[phoneNumber](/docs/plugins/phone-number)**：如果您有使用电话号码注册的用户

    <Callout type="info">
      仅为您实际在 Supabase 中使用的功能添加插件。迁移脚本会自动检测启用了哪些插件，并据此迁移数据。
    </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]
    })
    ```
  </Step>

  <Step>
    ### 添加额外字段 [#添加额外字段]

    为尽量减少 Supabase Auth 的数据丢失，需要添加以下额外字段。迁移完成后，您可以根据需要调整这些字段。

    ```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]
    })
    ```
  </Step>

  <Step>
    ### 运行迁移 [#运行迁移]

    运行迁移，在数据库中创建必要的表。

    ```bash title="Terminal"
    npx auth migrate
    ```

    这将在 Better Auth 实例的 `public` schema 中创建必要的表。

    现在数据库中已经有了必要的表，我们可以运行迁移脚本，将用户和账户从 Supabase 迁移到 Better Auth。
  </Step>

  <Step>
    ### 复制迁移脚本 [#复制迁移脚本]

    首先，设置脚本使用的环境变量。

    ```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">
      **使用同一个数据库？** 如果您要在同一个 Supabase Postgres 数据库中迁移（从 `auth` schema 到 `public` schema），两个 URL 可以使用相同的 `DATABASE_URL`。如果要迁移到其他数据库（例如 PlanetScale 或单独的 Postgres），请使用不同的 URL。
    </Callout>

    然后，将以下代码复制并粘贴到文件中。

    ```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();
    ```

    您可以使用脚本中的 `CONFIG` 配置脚本。

    * `batchSize`：每批处理的用户数量。默认值：5000
    * `resumeFromId`：从指定用户 ID 继续（基于游标的分页）。默认值：null
    * `tempEmailDomain`：仅使用电话号码的用户的临时电子邮件域名。默认值："temp.better-auth.com"
  </Step>

  <Step>
    ### 运行迁移脚本 [#运行迁移脚本]

    <Callout type="info">
      迁移脚本使用**键集分页**（基于游标），能够高效处理大型数据集，而无需将所有数据加载到内存中。对于非常大型的迁移（超过 50 万个用户），您可能需要使用 `NODE_OPTIONS="--max-old-space-size=8192"` 增加 Node 的内存限制。
    </Callout>

    运行迁移脚本，将用户和账户从 Supabase 迁移到 Better Auth。

    ```bash title="Terminal"
    npx tsx migration.ts
    ```

    您也可以使用其他 TypeScript 运行器，例如 `bun migration.ts`、`ts-node migration.ts`，或者先编译为 JS。
  </Step>

  <Step>
    ### 更改密码哈希算法 [#更改密码哈希算法]

    默认情况下，Better Auth 使用 `scrypt` 算法对密码进行哈希。由于 Supabase 使用 `bcrypt`，您需要配置 Better Auth，以便使用 bcrypt 进行密码验证。

    首先，安装 bcrypt：

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install bcrypt
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add bcrypt
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add bcrypt
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add bcrypt
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    ```bash title="Terminal"
    npm install -D @types/bcrypt
    ```

    然后，将密码配置添加到现有的 auth 配置中：

    ```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
           },
       },
    })
    ```
  </Step>

  <Step>
    ### 更新您的代码 [#更新您的代码]

    将代码库中的 Supabase auth 调用更新为 Better Auth API。

    以下是 Supabase auth API 调用及其对应的 Better Auth 调用。

    * `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` - 您也可以使用 `authClient.useSession` 获取响应式状态

    了解更多：

    * [基本用法](/docs/basic-usage)：了解如何使用 auth 客户端进行注册、登录和退出登录。
    * [电子邮件和密码](/docs/authentication/email-password)：了解如何向项目添加电子邮件和密码身份验证。
    * [匿名身份验证](/docs/plugins/anonymous)：了解如何向项目添加匿名身份验证。
    * [管理员](/docs/plugins/admin)：了解如何向项目添加管理员身份验证。
    * [电子邮件 OTP](/docs/plugins/email-otp)：了解如何向项目添加电子邮件 OTP 身份验证。
    * [Hooks](/docs/concepts/hooks)：了解如何使用 Hooks 监听事件。
    * [Next.js](/docs/integrations/next)：了解如何在 Next.js 项目中使用 auth 客户端。
  </Step>
</Steps>

## 迁移企业 SSO [#迁移企业-sso]

<Callout type="info">
  如果您没有使用 Supabase 的企业 SSO 功能，请**跳过本节**。本节仅适用于在 Supabase 中配置了 SAML SSO 提供商的用户。
</Callout>

如果您正在使用 Supabase 的企业 SSO（SAML），请按以下额外步骤迁移您的 SSO 提供商与用户。

<Callout type="warn">
  SSO 迁移需要使用新的回调 URL 更新您的身份提供商（IdP）配置。请规划一个短暂的切换窗口，因为现有 SSO 会话将失效。
</Callout>

<Steps>
  <Step>
    ### 安装 SSO 插件 [#安装-sso-插件]

    安装 Better Auth SSO 软件包：

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @better-auth/sso
        ```
      </CodeBlockTab>
    </CodeBlockTabs>

    将 SSO 插件添加到您的身份验证配置中：

    ```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]
        ],
    })
    ```
  </Step>

  <Step>
    ### 运行 SSO 数据库迁移 [#运行-sso-数据库迁移]

    运行迁移以创建 `ssoProvider` 表：

    ```bash title="终端"
    npx auth migrate
    ```
  </Step>

  <Step>
    ### 导出您的 Supabase SSO 提供商 [#导出您的-supabase-sso-提供商]

    使用 CLI 列出并导出现有的 Supabase SSO 提供商：

    ```bash title="终端"
    # 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 提供商导出结果如下：

    ```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">
      如果您有多个 SSO 提供商，请分别导出每个提供商。迁移脚本需要使用所有这些提供商。
    </Callout>
  </Step>

  <Step>
    ### 将 SSO 类型添加到迁移脚本 [#将-sso-类型添加到迁移脚本]

    将以下类型定义添加到您的迁移脚本中：

    ```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;
    };
    ```
  </Step>

  <Step>
    ### 添加 SSO 提供商迁移函数 [#添加-sso-提供商迁移函数]

    添加以下函数以迁移您的 SSO 提供商：

    ```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: {
            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');
    }
    ```
  </Step>

  <Step>
    ### 准备受信任的 SSO 账户身份 [#准备受信任的-sso-账户身份]

    Better Auth 会根据提供商元数据中的实际 IdP 实体 ID 以及经过签名的 `NameID` 来识别 SAML 账户。Supabase 导出数据不包含足够的已验证数据，无法安全地推导出这些值。在迁移账户之前，请创建一个经过操作员审核的映射，将每个旧版 SSO 身份映射到其 IdP 实体 ID 和经过签名的 `NameID`。不要使用服务提供商的 `entity_id`、电子邮件地址或配置文件的 `sub` 声明来替代这些值。

    将经过审核的映射添加到您的迁移脚本中。外层映射以 Supabase SSO 提供商 ID 为键，内层映射以其旧版身份 ID 为键：

    ```ts title="migration.ts"
    const samlAccountIdentityByProviderId = new Map([
      [
        'supabase-provider-id',
        new Map([
          [
            'legacy-identity-id',
            {
              issuer: 'https://idp.example.com/metadata',
              accountId: 'signed-name-id',
            },
          ],
        ]),
      ],
    ]);
    ```
  </Step>

  <Step>
    ### 更新用户迁移以关联 SSO 账户 [#更新用户迁移以关联-sso-账户]

    在 `processBatch` 函数中，添加对 SSO 身份的处理。找到处理身份的部分并添加：

    ```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]
        const accountIdentity = samlAccountIdentityByProviderId // [!code highlight]
          .get(supabaseProviderId) // [!code highlight]
          ?.get(identity.provider_id); // [!code highlight]
        if (!accountIdentity) { // [!code highlight]
          throw new Error(`Missing trusted SAML account identity for ${supabaseProviderId}`); // [!code highlight]
        } // [!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]
          issuer: accountIdentity.issuer, // IdP metadata entity ID // [!code highlight]
          accountId: accountIdentity.accountId, // Signed NameID // [!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]
    }
    ```

    这会将每个迁移后的账户关联到 Better Auth 在登录时将验证的协议身份。如果缺少受信任的映射，请停止迁移，与身份提供商确认并解决问题，然后重新运行该批次。
  </Step>

  <Step>
    ### 运行 SSO 迁移 [#运行-sso-迁移]

    创建单独的脚本，或将其添加到主迁移脚本中，以运行 SSO 提供商迁移：

    ```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();
    ```

    运行迁移：

    ```bash title="终端"
    npx tsx run-sso-migration.ts
    ```
  </Step>

  <Step>
    ### 更新您的身份提供商 [#更新您的身份提供商]

    迁移后，您需要使用新的 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`）。

    <Callout type="info">
      您可以通过以下地址获取提供商的 SP 元数据：
      `GET https://yourapp.com/api/auth/sso/saml2/sp/metadata?providerId=<providerId>`

      如果 IdP 管理员需要完整的元数据 XML，请将此 URL 分享给他们。
    </Callout>
  </Step>

  <Step>
    ### 更新您的客户端代码 [#更新您的客户端代码]

    将 Supabase SSO 身份验证调用替换为 Better Auth：

    ```ts title="之前（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="之后（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]
    ```

    您也可以通过电子邮件发起 SSO（系统会自动提取域名）：

    ```ts title="auth-client.ts"
    // Sign in by email - domain is extracted from email
    await authClient.signIn.sso({ 
      email: 'user@company.com',
      callbackURL: '/dashboard',
    });

    // Or sign in by provider ID directly
    await authClient.signIn.sso({ 
      providerId: 'sso-550e8400-e29b-41d4-a716-446655440000',
      callbackURL: '/dashboard',
    });
    ```
  </Step>

  <Step>
    ### 测试 SSO 流程 [#测试-sso-流程]

    上线前，请测试完整的 SSO 流程：

    1. **测试 SP 发起的 SSO**：从应用的登录页面开始，输入已启用 SSO 的电子邮件域名
    2. **验证用户属性**：检查姓名、电子邮件和其他属性是否正确映射
    3. **测试现有用户**：确保之前通过 SSO 登录的用户仍然可以访问其账户
    4. **测试新用户**：验证新 SSO 用户是否能够正确创建

    ```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);
    ```
  </Step>
</Steps>

### SSO 迁移检查清单 [#sso-迁移检查清单]

使用此清单确保完成 SSO 迁移：

```
- [ ] 安装并配置 SSO 插件
- [ ] 运行数据库迁移（创建 ssoProvider 表）
- [ ] 导出 Supabase SSO 提供商
- [ ] 将提供商迁移至 Better Auth 数据库
- [ ] 用户账户关联到 SSO 提供商
- [ ] 更新 IdP 设置（ACS URL、Entity ID）
- [ ] 更新客户端代码使用 Better Auth SSO
- [ ] 测试 SP 发起的 SSO 流程
- [ ] 已有 SSO 用户可登录
- [ ] 可创建新 SSO 用户
```

### SSO 常见问题排查 [#sso-常见问题排查]

<Callout type="warn">
  **SAML 签名错误**：如果您看到签名验证错误，请确保 IdP 的证书是最新的。某些 IdP 会定期轮换证书，请检查元数据 URL 以获取最新证书。
</Callout>

<Callout type="info">
  **属性映射问题**：如果用户属性未正确填充，请使用浏览器开发者工具检查来自 IdP 的 SAML 断言。更新 `samlConfig` 中的 `mapping` 字段，使其与 IdP 发送的确切属性名称匹配。
</Callout>

<Callout type="info">
  **多个域名**：如果单个 SSO 提供商有多个域名，请在 `domain` 字段中使用逗号分隔它们：`"company.com,company.org"`。
</Callout>

更多 SSO 配置细节，参见 [SSO 插件文档](/docs/plugins/sso)。

### 认证保护 [#认证保护]

要使用代理（中间件）保护路由，请参考 [Next.js 认证保护指南](/docs/integrations/next#认证保护) 或您使用的框架文档。

## 总结 [#总结]

恭喜！您已成功完成从 Supabase Auth 到 Better Auth 的迁移。

Better Auth 拥有更强的灵活性和更多功能，务必深入探索 [文档](/docs)，挖掘其全部潜力。

