# 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.

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

```sql
-- 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.

```ts
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:

```sql
-- 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
