Skip to content

Moving an existing user table onto Better Auth without logging everyone out

Map your columns to the four required tables, backfill ids and accounts, and let people migrate themselves on next sign-in instead of forcing a password reset.

Better Auth4 min readships at docs/solutions/better-auth/migrating-an-existing-user-table.md

Tags: better-auth · migration · users · passwords · sessions

You have a users table with a few thousand rows, some bcrypt hashes, and a homegrown session cookie. You want Better Auth. What you do not want is an email that begins "we have upgraded our systems, please reset your password": that email costs a measurable share of your active users.

Here is the sequence that avoids it.

1. Understand what Better Auth requires

Four tables, resolved by exported name, not by table name:

ModelWhat it holds
useridentity: id, name, email, emailVerified, image, plus plugin columns like role
sessionone row per active session: token, userId, expiresAt
accountone row per credential: password hash, or an OAuth link
verificationshort-lived tokens: magic links, email confirmations

The important structural difference from most homegrown schemas: the password does not live on the user row. It lives on an account row whose provider is credential. That indirection is what lets one person have a password and three social logins without a column per provider.

2. Decide what happens to ids

Better Auth generates string ids. If your existing ids are integers or UUIDs, you have two options.

Keep your ids. Configure the adapter to use your id type and carry the existing values across. Everything that references users.id (orders, projects, audit rows) keeps working, and no foreign key has to change. This is almost always the right call.

Generate new ids. Only sane if very little references the user table. You will need a mapping table and an update on every referencing row, run inside one transaction.

Whichever you choose, write it down in the migration file's comment. The next person to look at a foreign key will want to know.

3. Write the backfill as a migration, not a script someone runs once

-- 1. Better Auth's tables already exist from the ORM migration.
-- 2. Copy identities across.
insert into "user" (id, name, email, email_verified, role, created_at, updated_at)
select
  u.id::text,
  coalesce(u.full_name, split_part(u.email, '@', 1)),
  lower(u.email),
  coalesce(u.email_confirmed_at is not null, false),
  case when u.is_admin then 'admin' else 'user' end,
  u.created_at,
  u.updated_at
from legacy_users u
where u.deleted_at is null;

-- 3. Passwords become credential accounts.
insert into "account" (id, account_id, provider_id, user_id, password, created_at, updated_at)
select
  gen_random_uuid()::text,
  u.id::text,
  'credential',
  u.id::text,
  u.password_hash,
  u.created_at,
  u.updated_at
from legacy_users u
where u.password_hash is not null
  and u.deleted_at is null;

Three details that matter:

  • Lowercase the email. Better Auth looks users up by exact match. A table with Sam@Example.com and sam@example.com will produce a duplicate account the first time someone signs in with the other casing. Deduplicate before you add the unique index, not after it fails.
  • Do not invent emailVerified: true. Marking every legacy row verified because "they signed up years ago" turns a stale address into a trusted one. If you never verified it, it is not verified.
  • Skip soft-deleted rows. They are not users any more, and importing them makes a deleted account signable-into.

4. Passwords: rehash on first sign-in

If your hashes are bcrypt, argon2 or scrypt, Better Auth can be configured to verify them and immediately rehash to its own format on a successful sign-in. Nobody resets anything; the migration happens one user at a time, silently, as people return.

emailAndPassword: {
  enabled: true,
  password: {
    verify: async ({ hash, password }) => legacyVerify(hash, password),
    // hash: omitted, new and rehashed passwords use the default.
  },
},

Keep the legacy verifier for as long as the tail of dormant accounts justifies (six to twelve months is typical) then drop it and send a reset email to whoever is left. By then it is a handful of people, not your whole list.

If your hashes are unsalted MD5 or SHA-1, do not carry them over at all. Import the users without an account row and require a reset. Verifying a broken hash to "be kind" keeps a liability alive.

5. Sessions do not migrate

Your old cookie format cannot be verified by Better Auth, and forging compatibility is exactly the custom-crypto trap. Everyone signs in once after the cutover.

You can make that painless: deploy the new stack, and on the first request that carries an old cookie, validate it with the old code path one last time, create a real Better Auth session for that user, and clear the old cookie. Ship that shim, keep it for a couple of weeks, then delete it. It is a small amount of throwaway code that turns a forced logout into a silent upgrade.

6. Rehearse on a copy

Restore production into a scratch database and run the whole migration there. Then check:

-- every legacy user made it
select (select count(*) from legacy_users where deleted_at is null) as before,
       (select count(*) from "user") as after;

-- no duplicate emails after lowercasing
select lower(email), count(*) from "user" group by 1 having count(*) > 1;

-- every password came across
select count(*) from "account" where provider_id = 'credential' and password is null;

Then sign in as three real accounts on the copy: one with a password, one that only ever used a social login, one that has both.

7. Cut over

Deploy with the legacy table still present and untouched. Keep it for a release or two: it is your rollback. Drop it only after a period where no support ticket has needed it.

Checking your work

  • Row counts match, no duplicate emails, no credential account without a hash.
  • A legacy password signs in and, on the second sign-in, the stored hash has changed format.
  • A user who only used Google is not blocked by a missing credential row.
  • The old sessions table is no longer written to by anything.