从 Clerk 迁移到 Better Auth

在本指南中,我们将逐步讲解如何将项目从 Clerk 迁移到 Better Auth - 包括带有正确哈希处理的电子邮件/密码、社交/外部账户、电话号码、双因素数据等。

【In this guide, we'll walk through the steps to migrate a project from Clerk to Better Auth — including email/password with proper hashing, social/external accounts, phone number, two-factor data, and more.】

此迁移将使所有活动会话失效。此指南目前未显示如何迁移组织,但通过额外步骤和组织插件应该是可行的。

在你开始之前

【Before You Begin】

在开始迁移过程之前,请在你的项目中设置 Better Auth。按照安装指南开始操作。然后前往

【Before starting the migration process, set up Better Auth in your project. Follow the installation guide to get started. And go to 】

连接到你的数据库

【Connect to your database】

你需要连接到你的数据库以迁移用户和账户。你可以使用任何数据库,但在此示例中,我们将使用 PostgreSQL。

【You'll need to connect to your database to migrate the users and accounts. You can use any database you want, but for this example, we'll use PostgreSQL.】

npm install pg

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

【And then you can use the following code to connect to your database.】

auth.ts
import { Pool } from "pg";

export const auth = betterAuth({
    database: new Pool({ 
        connectionString: process.env.DATABASE_URL 
    }),
})

启用电子邮件和密码(可选)

【Enable Email and Password (Optional)】

在你的认证配置中启用电子邮件和密码,并实现你自己的逻辑来发送验证邮件、重置密码邮件等。

【Enable the email and password in your auth config and implement your own logic for sending verification emails, reset password emails, etc.】

对职员迁移的重要信息:Clerk 使用 bcrypt 对密码进行哈希,而 Better Auth 默认使用 scrypt。为了确保迁移的用户可以使用现有密码登录,你需要将 Better Auth 配置为使用 bcrypt 来验证密码。

首先,安装 bcrypt:

【First, install bcrypt:】

npm install pnpm add bcrypt
pnpm add -D @types/bcrypt

然后配置 Better Auth 使用 bcrypt:

【Then configure Better Auth to use bcrypt:】

auth.ts
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";

export const auth = betterAuth({
    emailAndPassword: { 
        enabled: true,
        password: { 
            hash: async (password) => { 
                return await bcrypt.hash(password, 10); 
            }, 
            verify: async ({ hash, password }) => { 
                return await bcrypt.compare(password, hash); 
            }, 
        }, 
    }, 
})

有关更多配置选项,请参见 电子邮件和密码

设置社交提供商(可选)

【Setup Social Providers (Optional)】

在你的身份验证配置中添加你在 Clerk 项目中启用的社交提供商。

【Add social providers you have enabled in your Clerk project in your auth config.】

auth.ts
import { betterAuth } from "better-auth";

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, 
        } 
    } 
})

添加插件(可选)

【Add Plugins (Optional)】

你可以根据需要将以下插件添加到你的身份验证配置中。

【You can add the following plugins to your auth config based on your needs.】

Admin 插件将允许你管理用户、用户模拟以及应用级别的角色和权限。

双因素 插件将允许你为你的应用添加双因素认证。

电话号码 插件将允许你在应用中添加电话号码身份验证。

Username 插件将允许你向你的应用添加用户名认证。

auth.ts
import { Pool } from "pg";
import { betterAuth } from "better-auth";
import { admin, twoFactor, phoneNumber, username } 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: {
        github: {
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }
    },
    plugins: [admin(), twoFactor(), phoneNumber(), username()], 
})

Generate Schema

如果你正在使用自定义数据库适配器,请生成模式:

npx @better-auth/cli generate

或者如果你使用的是默认适配器,你可以使用以下命令:

npx @better-auth/cli migrate

Export Clerk Users

Go to the Clerk dashboard and export the users. Check how to do it here. It will download a CSV file with the users data. You need to save it as exported_users.csv and put it in the root of your project.

Create the migration script

scripts 文件夹中创建一个名为 migrate-clerk.ts 的新文件,并添加以下代码:

scripts/migrate-clerk.ts
import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";

import { auth } from "@/lib/auth"; // import your auth instance

function getCSVData(csv: string) {
  const lines = csv.split('\n').filter(line => line.trim());
  const headers = lines[0]?.split(',').map(header => header.trim()) || [];
  const jsonData = lines.slice(1).map(line => {
      const values = line.split(',').map(value => value.trim());
      return headers.reduce((obj, header, index) => {
          obj[header] = values[index] || '';
          return obj;
      }, {} as Record<string, string>);
  });

  return jsonData as Array<{
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      primary_email_address: string;
      primary_phone_number: string;
      verified_email_addresses: string;
      unverified_email_addresses: string;
      verified_phone_numbers: string;
      unverified_phone_numbers: string;
      totp_secret: string;
      password_digest: string;
      password_hasher: string;
  }>;
}

const exportedUserCSV = await Bun.file("exported_users.csv").text(); // this is the file you downloaded from Clerk

async function getClerkUsers(totalUsers: number) {
  const clerkUsers: {
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      image_url: string;
      password_enabled: boolean;
      two_factor_enabled: boolean;
      totp_enabled: boolean;
      backup_code_enabled: boolean;
      banned: boolean;
      locked: boolean;
      lockout_expires_in_seconds: number;
      created_at: number;
      updated_at: number;
      external_accounts: {
          id: string;
          provider: string;
          identification_id: string;
          provider_user_id: string;
          approved_scopes: string;
          email_address: string;
          first_name: string;
          last_name: string;
          image_url: string;
          created_at: number;
          updated_at: number;
      }[]
  }[] = [];
  for (let i = 0; i < totalUsers; i += 500) {
      const response = await fetch(`https://api.clerk.com/v1/users?offset=${i}&limit=${500}`, {
          headers: {
              'Authorization': `Bearer ${process.env.CLERK_SECRET_KEY}`
          }
      });
      if (!response.ok) {
          throw new Error(`Failed to fetch users: ${response.statusText}`);
      }
      const clerkUsersData = await response.json();
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      clerkUsers.push(...clerkUsersData as any);
  }
  return clerkUsers;
}


export async function generateBackupCodes(
  secret: string,
) {
  const key = secret;
  const backupCodes = Array.from({ length: 10 })
      .fill(null)
      .map(() => generateRandomString(10, "a-z", "0-9", "A-Z"))
      .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
  const encCodes = await symmetricEncrypt({
      data: JSON.stringify(backupCodes),
      key: key,
  });
  return encCodes
}

// Helper function to safely convert timestamp to Date
function safeDateConversion(timestamp?: number): Date {
  if (!timestamp) return new Date();

  const date = new Date(timestamp);

  // Check if the date is valid
  if (isNaN(date.getTime())) {
      console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`);
      return new Date();
  }

  // Check for unreasonable dates (before 2000 or after 2100)
  const year = date.getFullYear();
  if (year < 2000 || year > 2100) {
      console.warn(`Suspicious date year: ${year}, falling back to current date`);
      return new Date();
  }

  return date;
}

async function migrateFromClerk() {
  const jsonData = getCSVData(exportedUserCSV);
  const clerkUsers = await getClerkUsers(jsonData.length);
  const ctx = await auth.$context
  const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin");
  const isTwoFactorEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "two-factor");
  const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username");
  const isPhoneNumberEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "phone-number");
  for (const user of jsonData) {
      const { id, first_name, last_name, username, primary_email_address, primary_phone_number, verified_email_addresses, unverified_email_addresses, verified_phone_numbers, unverified_phone_numbers, totp_secret, password_digest, password_hasher } = user;
      const clerkUser = clerkUsers.find(clerkUser => clerkUser?.id === id);

      // create user
      const createdUser = await ctx.adapter.create<{
          id: string;
      }>({
          model: "user",
          data: {
              id,
              email: primary_email_address,
              emailVerified: verified_email_addresses.length > 0,
              name: `${first_name} ${last_name}`,
              image: clerkUser?.image_url,
              createdAt: safeDateConversion(clerkUser?.created_at),
              updatedAt: safeDateConversion(clerkUser?.updated_at),
              // # Two Factor (if you enabled two factor plugin)
              ...(isTwoFactorEnabled ? {
                  twoFactorEnabled: clerkUser?.two_factor_enabled
              } : {}),
              // # Admin (if you enabled admin plugin)
              ...(isAdminEnabled ? {
                  banned: clerkUser?.banned,
                  banExpires: clerkUser?.lockout_expires_in_seconds
                     ? new Date(Date.now() + clerkUser.lockout_expires_in_seconds * 1000)
                     : undefined,
                  role: "user"
              } : {}),
              // # Username (if you enabled username plugin)
              ...(isUsernameEnabled ? {
                  username: username,
              } : {}),
              // # Phone Number (if you enabled phone number plugin)  
              ...(isPhoneNumberEnabled ? {
                  phoneNumber: primary_phone_number,
                  phoneNumberVerified: verified_phone_numbers.length > 0,
              } : {}),
          },
          forceAllowId: true
      }).catch(async e => {
          return await ctx.adapter.findOne<{
              id: string;
          }>({
              model: "user",
              where: [{
                  field: "id",
                  value: id
              }]
          })
      })
      // create external account
      const externalAccounts = clerkUser?.external_accounts;
      if (externalAccounts) {
          for (const externalAccount of externalAccounts) {
              const { id, provider, identification_id, provider_user_id, approved_scopes, email_address, first_name, last_name, image_url, created_at, updated_at } = externalAccount;
              if (externalAccount.provider === "credential") {
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId: provider,
                          accountId: externalAccount.provider_user_id,
                          scope: approved_scopes,
                          userId: createdUser?.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                          password: password_digest,
                      }
                  })
              } else {
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId: provider.replace("oauth_", ""),
                          accountId: externalAccount.provider_user_id,
                          scope: approved_scopes,
                          userId: createdUser?.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                      },
                      forceAllowId: true
                  })
              }
          }
      }

      //two factor
      if (isTwoFactorEnabled) {
          await ctx.adapter.create({
              model: "twoFactor",
              data: {
                  userId: createdUser?.id,
                  secret: totp_secret,
                  backupCodes: await generateBackupCodes(totp_secret)
              }
          })
      }
  }
}

migrateFromClerk()
  .then(() => {
      console.log('Migration completed');
      process.exit(0);
  })
  .catch((error) => {
      console.error('Migration failed:', error);
      process.exit(1);
  });

Make sure to replace the process.env.CLERK_SECRET_KEY with your own Clerk secret key. Feel free to customize the script to your needs.

执行迁移

运行迁移:

bun run script/migrate-clerk.ts # you can use any thing you like to run the script

请确保:

  1. 首先在开发环境中测试迁移
  2. 监控迁移过程中的任何错误
  3. 在继续之前在 Better Auth 中验证已迁移的数据
  4. 在迁移完成之前保持 Clerk 安装并配置好

验证迁移

运行迁移后,通过检查数据库验证所有用户是否已正确迁移。

更新你的组件

现在数据已经迁移完毕,你可以开始更新你的组件以使用更好的身份验证。以下是登录组件的示例:

components/auth/sign-in.tsx
import { authClient } from "better-auth/client";

export const SignIn = () => {
  const handleSignIn = async () => {
    const { data, error } = await authClient.signIn.email({
      email: "user@example.com",
      password: "password",
    });
    
    if (error) {
      console.error(error);
      return;
    }
    // Handle successful sign in
  };

  return (
    <form onSubmit={handleSignIn}>
      <button type="submit">Sign in</button>
    </form>
  );
};

Update the middleware

用 Better Auth 的中间件替换你的 Clerk 中间件:

middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;
  if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard", "/login", "/signup"],
};

Remove Clerk Dependencies

一旦你确认 Better Auth 一切正常工作,就可以移除 Clerk:

Remove Clerk
pnpm remove @clerk/nextjs @clerk/themes @clerk/types

附加资源

【Additional Resources】

再见 Clerk,你好 Better Auth – 完整迁移指南!

总结

【Wrapping Up】

恭喜!你已成功从 Clerk 迁移到 Better Auth。

【Congratulations! You've successfully migrated from Clerk to Better Auth.】

Better Auth 提供了更高的灵活性和更多功能 - 务必查看文档以充分发挥其潜力。

【Better Auth offers greater flexibility and more features—be sure to explore the documentation to unlock its full potential.】