Migrated from Clerk to Better Auth

I’ve migrated the authentication system for my application from Clerk to Better Auth.

I really liked Clerk as an authentication SaaS since it was very convenient and enabled rapid development. It allowed me to offload all the troublesome account-related processes like password reset emails and password changes to an external service. However, in a development environment where I frequently had to reset the database and reinsert seed data, having part of that seed data on a remote service made this process costly, and resetting the data itself became a psychologically undesirable task.

I happened to see someone using Better Auth and after reviewing their documentation, sample code, and Discord community, I found that it could work well with the ORM I was using (Prisma). I also felt that if development stopped, I could switch to my own implementation as a last resort, so I decided to make the change.

From the Clerk dashboard, you can export user data in a CSV file that includes Clerk ID, primary_email_address, verified_email_addresses, password_digest, password_hasher, and other details.

I found that Clerk was using bcrypt for passwords. Better Auth uses a different method by default, so the authentication information couldn’t be used as is. However, I configured Better Auth to match Clerk’s approach by setting up the hash and verify functions as follows:

import bcrypt from "bcryptjs";

export const hashPassword = async (password: string): Promise<string> => {
  const salt = await bcrypt.genSalt(10);
  const hash = await bcrypt.hash(password, salt);
  return hash;
};

export const verifyPassword = async ({
  hash,
  password,
}: { hash: string; password: string }): Promise<boolean> => {
  return await bcrypt.compare(password, hash);
};

export const auth = betterAuth({
  baseURL: process.env.BETTER_AUTH_BASE_URL,
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      ctx.context.password.hash = hashPassword;
      ctx.context.password.verify = verifyPassword;
    })
  }
})

Better Auth creates User and Account models by directly editing the schema configuration file of your ORM, such as Prisma.

The User model contains unique user data like email addresses and names, excluding authentication information. The Account model creates data linked to users for each authentication method. For example, if one user has set up both password authentication and Google authentication, two Account data records would be inserted.

Core Schema - Database | Better Auth

My previous database already had a User model, but it contained the password column directly. I needed to change this to the User and Account structure, so I wrote a migration script to create the Account data:

import { createRandomStringGenerator } from "@better-auth/utils/random"
export const generateRandomString = createRandomStringGenerator("A-Z", "0-9", "a-z", "-_")

const prisma = new PrismaClient();

async function migrateUsersToAccounts() {
  try {
    console.log('Fetching all users...');
    const users = await prisma.user.findMany();
    console.log(`Found ${users.length} users.`);

    if (users.length === 0) {
      console.log('No users found to migrate.');
      return;
    }

    // Read the CSV file
    console.log('Reading Clerk CSV data...');
    const csvData = await fs.readFile('scripts/clerk.csv', 'utf-8');
    const records = parse(csvData, {
      columns: true,
      skip_empty_lines: true
    }) as ClerkRecord[];
    console.log(`Loaded ${records.length} records from Clerk CSV.`);

    // Map Clerk records using email address as key
    const clerkMap = new Map<string, ClerkRecord>();
    for (const record of records) {
      clerkMap.set(record.primary_email_address, record);
    }

    console.log('Generating accounts...');
    const accountCreationPromises = users.map((user) => {
      const clerkRecord = clerkMap.get(user.email);
      const password = clerkRecord.password_digest;
      console.log(`🔑 Creating account for user: ${user.email} with password: ${password}`);

      return prisma.account.create({
        data: {
          id: generateRandomString(30),
          accountId: user.id,
          providerId: 'credential',  // ← Meaning password authentication
          userId: user.id,
          password: password,
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      });
    });

    const createdAccounts = await Promise.all(accountCreationPromises);
    console.log(`Successfully created ${createdAccounts.length} accounts.`);
  } catch (error) {
    console.error('Error during migration:', error);
  } finally {
    await prisma.$disconnect();
    console.log('Database connection closed.');
  }
}

migrateUsersToAccounts();

There were many other minor migrations, but this is roughly what was needed to adapt to Better Auth.

The issue isn’t with Better Auth itself, but Better Auth is designed with the assumption that IDs are stored as strings, and it doesn’t currently support the common autoincrement Primary ID.

Because of this, even if I forcibly made the DB side use int type, the types wouldn’t match, which caused me a lot of trouble. Honestly, this was the most frustrating part, but I wrote and executed a script to reassign string IDs for all tables with User ID, including updating all the foreign keys.

I used Cursor and Claude to write these scripts, so it wasn’t too much work, but reassigning string IDs in production was quite a high-stakes task. I didn’t want to do it because testing and procedure creation were troublesome, but I managed to complete it successfully.

I used Cursor and Claude to write these scripts, so it wasn’t too much work, but reassigning IDs in production was quite a high-stakes task. I didn’t want to do it because testing and procedure creation were troublesome, but I managed to complete it successfully.

From Better Auth’s Discord: