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:
| Model | What it holds |
|---|---|
user | identity: id, name, email, emailVerified, image, plus plugin columns like role |
session | one row per active session: token, userId, expiresAt |
account | one row per credential: password hash, or an OAuth link |
verification | short-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.comandsam@example.comwill 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.