从 WorkOS 迁移到 Better Auth

在本指南中,我们将详细讲解如何将项目从 WorkOS 迁移到 Better Auth,包括如何迁移与 Next.js 应用集成的基本 WorkOS 设置,以及在迁移过程中需要注意的关键事项。

【In this guide, we’ll walk through how to migrate a project from WorkOS to Better Auth, covering how to move a basic WorkOS setup integrated with a Next.js app and the key considerations to keep in mind.】

在我们开始之前

【Before we begin】

在开始之前,让我们先回顾一下哪些 WorkOS 身份验证功能在 Better Auth 中是完全支持或部分支持的。如果你在 WorkOS 中使用的某个功能可以通过插件实现,则需要在下一步进行配置。

【Before getting started, let’s review which WorkOS authentication features are fully or partially supported in Better Auth. If a feature you use in WorkOS is available via a plugin, you’ll need to configure it in the next step.】

创建更好的认证实例

【Create Better Auth Instance】

首先,在你的项目中设置 Better Auth。请按照安装指南开始操作。

【First, set up Better Auth in your project. Follow the installation guide to get started.】

数据库

【Database】

Better Auth 支持多种数据库。请设置你偏好的数据库。在本指南中,我们将使用带有默认数据库适配器的 PostgreSQL。

【Better Auth supports various databases. Set up your preferred database. In this guide, we’ll use PostgreSQL with the default database adapter.】

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

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

电子邮箱和密码

【Email & Password】

按如下所示启用电子邮件和密码认证。由于 WorkOS 默认会验证每个用户的电子邮件,因此此设置与默认行为类似。如有需要,你可以进行调整。更多信息,请参见 这里

【Enable Email & Password authentication as shown below. Since WorkOS verifies each user’s email by default, this setup is similar to the default behavior. You can adjust it if needed. For more information, see here.】

import { betterAuth } from "better-auth";
import { Pool } from "pg";

export const auth = betterAuth({
  database: new Pool({
    connectionString: process.env.DATABASE_URL,
  }),
  emailAndPassword: { 
    enabled: true, 
    requireEmailVerification: true, 
    minPasswordLength: 10, 
    sendResetPassword: async ({ user, url, token }, request) => { 
      // Implement your email sending logic
    }, 
  }, 
  emailVerification: { 
    sendVerificationEmail: async ({ user, url, token }, request) => { 
      // Implement your email sending logic
    }, 
  }, 
});

社交提供商(可选)

【Social Providers (optional)】

按如下方式设置你在 WorkOS 中使用的社交提供商。Better Auth 支持更广泛的提供商,因此如果需要,你可以添加更多。由于 WorkOS 确保电子邮件是唯一的,请在 Better Auth 中配置 account.accountLinking 以确保相同的行为。

【Set up the social providers you used in WorkOS as follows. Better Auth supports a wider range of providers, so you can add more if needed. Since WorkOS ensures emails are unique, configure account.accountLinking in Better Auth to ensure the same behavior.】

import { betterAuth } from "better-auth";
import { Pool } from "pg";

export const auth = betterAuth({
  // ... other options

  socialProviders: { 
    github: { 
      clientId: process.env.GITHUB_CLIENT_ID!, 
      clientSecret: process.env.GITHUB_CLIENT_SECRET!, 
    }, 
    // ... other providers
  }, 
  account: { 
    accountLinking: { 
      enabled: true, 
      trustedProviders: ["email-password", "github"], 
    }, 
  }, 
});

附加字段

【Additional Fields】

你可能在 WorkOS 中使用了元数据。要保留这些元数据以及来自 WorkOS 的用户 ID(例如,user_01KBT4BMFF7ASGRDD0WZ6W63FF),请按如下所示扩展 user 模式。Better Auth 提供了一种更灵活的方式来存储用户数据。有关更多信息,请参见 这里

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

export const auth = betterAuth({
  // ... other options
  
  user: { 
    additionalFields: { 
      metadata: { 
        type: "json", 
        required: false, 
        defaultValue: null, 
      }, 
    }, 
  }, 
});

插件

【Plugins】

请参阅将 WorkOS 功能映射到 Better Auth 的部分。如果你在 WorkOS 中使用的功能在 Better Auth 中可作为插件使用,请将其添加到插件选项中。Better Auth 通过插件提供更广泛的开箱即用功能。更多信息,请参见这里

【Refer to the section mapping WorkOS features to Better Auth. If a feature you used in WorkOS is available as a Better Auth plugin, add it to the plugin options. Better Auth provides a wider range of out-of-the-box features through plugins. For more information, see here.】

auth.ts
import { betterAuth } from "better-auth";
import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
import { Pool } from "pg";

export const auth = betterAuth({
  // ... other options
  
  plugins: [ 
    haveIBeenPwned() 
    // ... other plugins
  ], 
});

如果你依赖于超出基本电子邮件+密码和社交登录的高级 WorkOS 功能,请参考上述功能映射以配置相应的插件。

生成模式

【Generate Schema】

Better Auth 允许你控制自己的数据库,并且可以使用 CLI 轻松为你的认证实例生成合适的模式。更多信息,请参见这里

【Better Auth allows you to control your own database, and you can easily generate the appropriate schema for your auth instance using the CLI. For more information, see here.】

默认数据库适配器

【Default database adapter】

运行 migrate 命令,在数据库中为你的 Better Auth 实例创建模式。

【Run the migrate command to create the schema for your Better Auth instance in the database.】

npx @better-auth/cli migrate

其他数据库适配器

【Other database adapters】

如果你使用像 Prisma 或 Drizzle 这样的数据库适配器,请使用 generate 命令为你的 ORM 创建 schema。之后,使用像 Drizzle Kit 这样的外部工具运行迁移。

【If you’re using a database adapter like Prisma or Drizzle, use the generate command to create the schema for your ORM. After that, run the migration with an external tool such as Drizzle Kit.】

npx @better-auth/cli generate

迁移脚本

【Migration Script】

创建迁移脚本

【Create Migration Script】

创建一个迁移脚本,将你的用户数据从 WorkOS 导入到你的数据库中。

【Create a migration script to import your user data from WorkOS into your database.】

scripts/migration.ts
import { auth } from "@/lib/auth"; // Your auth instance path
import { WorkOS } from "@workos-inc/node";

//==============================================================================

/*
  Rate limiting configuration

  WorkOS Read APIs: 1,000 requests per 10 seconds
  Default setting: Use 80% of limit to avoid edge cases

  Reference: https://workos.com/docs/reference/rate-limits
*/
const TIME_WINDOW_MS = 10 * 1000; // Time window in ms (10 seconds)
const MAX_REQUESTS_PER_WINDOW = 800; // Maximum API calls per time window
const USERS_PER_REQUEST = 100; // How many users to fetch per API call

//==============================================================================

if (!process.env.WORKOS_API_KEY || !process.env.WORKOS_CLIENT_ID) {
  throw new Error(
    "Missing required environment variables WORKOS_API_KEY and/or WORKOS_CLIENT_ID",
  );
}
const workos = new WorkOS(process.env.WORKOS_API_KEY);

/**
 * Create a rate limiter to track and control request rate
 */
const createRateLimiter = (maxRequests: number, windowMs: number) => {
  let requestTimestamps: number[] = [];

  const waitIfNeeded = async (): Promise<void> => {
    const now = Date.now();

    // Remove timestamps outside the current window
    requestTimestamps = requestTimestamps.filter(
      (timestamp) => now - timestamp < windowMs,
    );

    // If we've hit the limit, calculate wait time
    if (requestTimestamps.length >= maxRequests) {
      const oldestTimestamp = requestTimestamps[0]!;
      const waitTime = windowMs - (now - oldestTimestamp) + 1000; // 1 sec buffer

      console.log(
        `⏳ Throttling (${requestTimestamps.length}/${maxRequests} calls used). Waiting ${Math.ceil(waitTime / 1000)}s...`,
      );
      await new Promise((resolve) => setTimeout(resolve, waitTime));

      // Clean up old timestamps after waiting
      const newNow = Date.now();
      requestTimestamps = requestTimestamps.filter(
        (timestamp) => newNow - timestamp < windowMs,
      );
    }

    // Record this request
    requestTimestamps.push(Date.now());
  };

  const getStats = (): {
    current: number;
    max: number;
    windowMinutes: number;
  } => {
    const now = Date.now();
    requestTimestamps = requestTimestamps.filter(
      (timestamp) => now - timestamp < windowMs,
    );

    return {
      current: requestTimestamps.length,
      max: maxRequests,
      windowMinutes: windowMs / (60 * 1000),
    };
  };

  return { waitIfNeeded, getStats };
};

/**
 * Safely converts various date formats to Date object.
 * Returns current date if conversion fails (safe for createdAt/updatedAt).
 */
const safeDateConversion = (date?: string | number | Date | null): Date => {
  if (date == null) return new Date();

  if (date instanceof Date) return new Date(date.getTime());

  if (typeof date === "number") {
    if (!Number.isFinite(date)) return new Date();
    return new Date(date);
  }

  if (typeof date === "string") {
    const trimmed = date.trim();
    if (trimmed === "") return new Date();
    const parsed = new Date(trimmed);
    if (isNaN(parsed.getTime())) return new Date();
    return parsed;
  }

  return new Date();
};

/**
 * Safely converts firstName and lastName to a full name string.
 * Returns "Username" if both names are empty.
 */
const safeNameConversion = (
  firstName?: string | null,
  lastName?: string | null,
): string => {
  const trimmedFirstName = firstName?.trim();
  const trimmedLastName = lastName?.trim();

  if (trimmedFirstName && trimmedLastName) {
    return `${trimmedFirstName} ${trimmedLastName}`;
  }

  if (trimmedFirstName) return trimmedFirstName;
  if (trimmedLastName) return trimmedLastName;

  return "Username";
};

async function migrateFromWorkOS() {
  const ctx = await auth.$context;
  const rateLimiter = createRateLimiter(
    MAX_REQUESTS_PER_WINDOW,
    TIME_WINDOW_MS,
  );

  let totalUsers = 0;
  let migratedUsers = 0;
  let skippedUsers = 0;
  let failedUsers = 0;

  let hasMoreUsers = true;
  let after: string | undefined;
  let batchCount = 0;

  console.log("");
  console.log("=".repeat(40));
  console.log("🚀 Starting migration");
  console.log("");
  console.log(`Settings:`);
  console.log(
    ` - Max API calls: ${MAX_REQUESTS_PER_WINDOW} per ${TIME_WINDOW_MS / 1000}s`,
  );
  console.log(` - Users per call: ${USERS_PER_REQUEST}`);
  console.log("=".repeat(40));
  console.log("");

  while (hasMoreUsers) {
    try {
      await rateLimiter.waitIfNeeded();

      const workosUserList = await workos.userManagement.listUsers({
        limit: USERS_PER_REQUEST,
        after,
      });

      batchCount++;
      console.log(
        `📦 Batch ${batchCount}: Fetched ${workosUserList.data.length} users from WorkOS`,
      );

      after = workosUserList.listMetadata.after || undefined;
      hasMoreUsers = !!after;
      totalUsers += workosUserList.data.length;

      for (const workosUser of workosUserList.data) {
        try {
          console.log(`\nProcessing user: ${workosUser.email}`);

          // Check if user already exists by email
          // WorkOS ensures all user emails are unique via an email verification process
          const existingUser = await ctx.adapter.findOne<
            typeof auth.$Infer.Session.user
          >({
            model: "user",
            where: [
              {
                field: "email",
                value: workosUser.email,
              },
            ],
          });

          if (existingUser) {
            console.log(
              `🟡 User already exists, skipping: ${workosUser.email}`,
            );
            skippedUsers++;
            continue;
          }

          // Create the user
          await ctx.adapter.create<typeof auth.$Infer.Session.user>({
            model: "user",
            data: {
              email: workosUser.email,
              emailVerified: workosUser.emailVerified,
              image: workosUser.profilePictureUrl,
              name: safeNameConversion(
                workosUser.firstName,
                workosUser.lastName,
              ),
              createdAt: safeDateConversion(workosUser.createdAt),
              updatedAt: safeDateConversion(workosUser.updatedAt),
              metadata: {
                workosId: workosUser.id,
                ...(workosUser.metadata || {}),
              },
            },
          });

          console.log(`🟢 Migrated user ${workosUser.email}`);
          migratedUsers++;
        } catch (error) {
          console.error(
            `🔴 Failed to migrate user ${workosUser.email}\n`,
            error,
          );
          failedUsers++;
        }
      }

      console.log("");
    } catch (error) {
      console.error("🚨 Error fetching batch:", error);
      throw error;
    }
  }

  console.log("");
  console.log("=".repeat(40));
  console.log("📝 Migration Summary");
  console.log(`Total users processed: ${totalUsers}`);
  console.log("");
  console.log(`🔴 Failed: ${failedUsers}`);
  console.log(`🟡 Skipped: ${skippedUsers}`);
  console.log(`🟢 Successfully migrated: ${migratedUsers}`);
  console.log("=".repeat(40));
}

async function main() {
  try {
    await migrateFromWorkOS();
    process.exit(0);
  } catch (error) {
    console.error("\nMigration failed:", error);
    process.exit(1);
  }
}
main();

注意事项

  • 从 WorkOS 检索用户数据时,需要使用他们的 API,该 API 有速率限制。示例脚本包含了基本配置,因此请根据你的环境进行调整。
  • 此迁移脚本涵盖了通过电子邮件+密码和社交登录管理用户的常见情况。对于像 SSO 或 CLI 认证这样的功能,这些是在 Better Auth 中作为插件提供的,请务必根据示例更新脚本。

运行迁移脚本

【Run Migration Script】

Terminal
bun scripts/migration.ts # or use node, ts-node, etc.

🎉 现在你已经将用户数据迁移到了数据库中,让我们来看看如何更新你的应用逻辑吧。

创建客户端实例

【Create Client Instance】

此客户端实例包含一组用于与 Better Auth 服务器实例交互的功能。更多信息,请参见这里

【This client instance includes a set of functions for interacting with the Better Auth server instance. For more information, see here.】

auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
    plugins: [
        // Add plugins that require a client, if needed
    ]
});

创建 API 路由

【Create API Route】

在 WorkOS 中,身份验证 API 是作为托管服务提供的。而在 Better Auth 中,身份验证 API 现在直接内置在你的应用中。

【In WorkOS, the auth API was provided as a managed service. With Better Auth, the auth API now lives directly within your application.】

/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth)

登录/注册页面

【Sign-in/Sign-up Page】

在 WorkOS 中,你可能像这样获取并使用了 URL。

【In WorkOS, you probably fetched and used the URL like this.】

const signInUrl = await getSignInUrl();
const signUpUrl = await getSignUpUrl();

在 Better Auth 中,你可以直接在你想要的路径创建页面并使用它们,而不是通过 API 获取这些值。

保护资源

【Protecting Resources】

代理(中间件)并不适用于慢速数据获取。虽然代理对于基于权限的重定向等乐观检查可能有帮助,但它不应作为完整的会话管理或授权解决方案使用。 - Next.js 文档

中间件认证

【Middleware auth】

WorkOS 提供代理(中间件)身份验证。Better Auth 不建议直接在中间件中保护资源,因此我们不提供专门的辅助工具来实现这一点。

【WorkOS provides Proxy (Middleware) authentication. Better Auth doesn’t recommend protecting resources directly in middleware, so we don't provide dedicated helpers for that.】

proxy.ts / middleware.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';

export default authkitMiddleware({
  middlewareAuth: {
    enabled: true,
    unauthenticatedPaths: ['/'],
  },
});

export const config = { matcher: ['/', '/account/:page*'] };

在 Better Auth 中,为了方便而不是为了资源保护,可以按如下方式使用代理(中间件)。在 Next.js 15 及以上版本的 Node.js 运行时中支持此功能。

【In Better Auth, for convenience rather than resource protection, the proxy (middleware) can be used as follows. This is supported in Next.js 15+ with the Node.js runtime.】

proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
    const session = await auth.api.getSession({
        headers: await headers()
    })

    // This is the recommended approach to optimistically redirect users
    // We recommend handling auth checks in each page/route
    if(!session) {
        return NextResponse.redirect(new URL("/sign-in", request.url));
    }

    return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard"], // Specify the routes the middleware applies to
};

基于页面的认证

【Page based auth】

在 WorkOS 中,如果每个页面的资源都受到保护,你可以按如下方式在 Better Auth 中更新逻辑。

【In WorkOS, if resources were protected on each page, you can update the logic in Better Auth as follows.】

服务器端

【Server-side】

app/dashboard/page.tsx
import { withAuth } from "@workos-inc/authkit-nextjs";

export default async function DashboardPage() {
  const { user } = await withAuth({ ensureSignedIn: true });

  return (
    <div>
      <p>Welcome {user.firstName && `, ${user.lastName}`}</p>
    </div>
  );
}
app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

const DashboardPage = async () => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect("/sign-in");
  }

  return (
    <div>
      <p>Welcome {session.user.name}</p>
    </div>
  );
};

export default DashboardPage;

客户端

【Client-side】

app/dashboard/page.tsx
"use client";

import { useAuth } from "@workos-inc/authkit-nextjs/components";

export default function HomePage() {
  const { user, loading } = useAuth({ ensureSignedIn: true });

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <p>Welcome {user.firstName && `, ${user.lastName}`}</p>
    </div>
  );
}
app/dashboard/page.tsx
"use client";

import { authClient } from "@/lib/auth-client";
import { redirect } from "next/navigation";

const DashboardPage = () => {
  const { data, error, isPending } = authClient.useSession();

  if (isPending) {
    return <div>Pending...</div>;
  }
  if (!data || error) {
    redirect("/sign-in");
  }

  return (
    <div>
      <p>Welcome {data.user.name}</p>
    </div>
  );
};

export default DashboardPage;

如果在 WorkOS 中像 ensureSignedIn 这样的选项很方便,你可以在 Better Auth 中创建一个可重用的辅助函数,比如 ensureSession()

移除 WorkOS 依赖

【Remove WorkOS Dependencies】

在确认一切正常后,移除 WorkOS 依赖:

【After verifying everything works, remove WorkOS dependencies:】

npm uninstall @workos-inc/node @workos-inc/authkit-nextjs

注意事项

【Considerations】

密码哈希

如果你一直使用电子邮件+密码系统来管理用户,WorkOS 目前不提供密码哈希的导出。在迁移之后,用户需要在你的身份验证系统中重置密码。请确保在迁移前后给予用户足够的提前通知,以告知他们这一更改。

【If you’ve been managing users with an email + password system, WorkOS does not provide an export of password hashes at this time. After migration, users will need to reset their passwords within your authentication system. Make sure to notify them of this change with sufficient lead time both before and after the migration.】

数据同步

WorkOS 是一个托管服务,可以通过 API 或 Webhook 将你的数据与服务器保持同步。使用 Better Auth,你可以完全拥有自己的身份验证系统,并通过 API 自由管理数据。然而,如果你之前依赖 Webhook 进行同步,则需要进行额外的调整。

【WorkOS is a managed service and keeps your data in sync with your server through APIs or Webhooks. With Better Auth, you fully own your authentication system and can manage data freely through the API. However, if you previously relied on Webhooks for synchronization, additional adjustments will be needed.】

停机时间

WorkOS 通过其 API 提供数据访问,但存在一些限制,例如无法导出密码哈希。由于这些限制,实现零停机时间的迁移具有挑战性。请仔细规划迁移,预留足够的缓冲时间,并向用户传达预期的影响。

【WorkOS exposes data through its API, but with limitations such as the inability to export password hashes. Because of these constraints, performing a migration with zero downtime is challenging. Plan the migration carefully, allow enough buffer time, and communicate the expected impact to your users.】

活跃会话

现有的活跃会话将不会被迁移。迁移后,用户需要重新登录,因此请务必提前通知他们。

【Existing active sessions will not be migrated. After the migration, users will need to sign in again, so be sure to notify them in advance.】

总结

【Wrapping Up】

恭喜!你已成功从 WorkOS 迁移到 Better Auth。Better Auth 提供更高的灵活性和更多功能,记得浏览 文档 以充分发挥其潜力。

【Congratulations! You've successfully migrated from WorkOS to Better Auth. Better Auth offers greater flexibility and more features, so be sure to explore the documentation to unlock its full potential.】

如果你在迁移方面需要帮助,请加入我们的社区或在此处联系我们获取企业支持。

【If you need help with migration, join our community or reach out for Enterprise support here.】