DevVersus

Migration guide · 2026 · verified Better Auth 1.6.x

Migrate NextAuth (Auth.js) to Better Auth

A working, end-to-end path for moving a Next.js + Drizzle + Postgres app from Auth.js v5 (next-auth) to Better Auth 1.6.x — without forcing a single user to sign in again.

The Better Auth docs cover the framework. The Auth.js docs cover Auth.js. This guide covers what the docs skip: the schema diff, the dual-cookie window, the silent-fail traps. Verified against a 5-user seeded Postgres 16 database with a 5/5-passing Playwright suite.

Affiliate disclosure: Some “Visit” links on this page are affiliate links. We may earn a commission if you sign up — at no extra cost to you. It does not affect our rankings or editorial coverage. Learn more.

TL;DR — what changes when you migrate

  • Tables get a ba_ prefix: user/account/session/verification_token become ba_user/ba_account/ba_session/ba_verification. Both can co-exist in the same database during the cutover.
  • emailVerified changes type: timestamp in NextAuth, boolean in Better Auth. The SQL backfill needs CASE WHEN "emailVerified" IS NOT NULL THEN TRUE ELSE FALSE END.
  • No forced sign-outs: a dual-cookie middleware reads BA first, falls back to the legacy NextAuth JWT, and silently reissues a Better Auth session.
  • Bcrypt passwords carry over verbatim from user.passwordHashba_account.password where provider_id = 'credential'.
  • 2FA secrets need re-encoding from Base64 (NextAuth convention) to Base32 (Better Auth twoFactor plugin convention).
  • The session cookie must be HMAC-signed. Setting a raw session token into the cookie produces silent sign-outs on the next request — the trap most migrations hit.

Schema diff — what moves where

NextAuth (Auth.js v5)Better Auth 1.6.xNotes
user.id (text)ba_user.id (text)Carries over verbatim
user.emailVerified (timestamp)ba_user.email_verified (boolean)Cast: NOT NULL → TRUE
user.passwordHash (text)ba_account.password (text), provider_id='credential'Bcrypt hash carries verbatim
account.providerAccountIdba_account.account_idRename + camelCase strip
account.providerba_account.provider_idRename
account.expires_at (integer)ba_account.access_token_expires_at (timestamp)to_timestamp(expires_at)
session.sessionTokenba_session.tokenRename
session.expiresba_session.expires_atRename + tz
verification_token.identifier + tokenba_verification.identifier + valueAdd UNIQUE(identifier, value) for re-run safety
(none)ba_user.two_factor_enabled (boolean)Required when twoFactor() plugin registered

1. Install Better Auth side-by-side

Don't remove next-auth yet. The dual-cookie window needs the legacy config to verify in-flight JWTs.

pnpm add better-auth @better-auth/cli
pnpm add -D drizzle-orm@0.45.2 drizzle-kit@0.31.4

# Generate the BA Drizzle schema (writes to ./db/schema/auth.ts)
pnpm exec @better-auth/cli generate --output db/schema/auth.ts

Drizzle peer-dep gotcha: Better Auth 1.6.x requires drizzle-orm ≥ 0.45 and drizzle-kit ≥ 0.31. Older versions throw on the uniqueIndex + $onUpdateFn usage in the generated BA schema.

2. Push both schemas

The legacy user tables and the new ba_* tables sit in the same database during the migration window. drizzle-kit push applies both atomically.

# drizzle.config.ts already references both schemas via ./db/schema/index.ts
pnpm exec drizzle-kit push --force

# Verify
psql $DATABASE_URL -c "\dt" | grep -E "(^|\s)(user|account|session|ba_)"

--force is needed because Drizzle interprets the new ba_*tables as "data may be lost" even though they are empty. The flag confirms intent; nothing destructive happens.

3. The SQL backfill (idempotent)

One transaction, four INSERT…SELECT blocks, ON CONFLICT DO NOTHING for re-run safety. Run it on a copy first, diff the row counts, then on production.

BEGIN;

-- 1. ba_user from legacy "user" — emailVerified timestamp → boolean
INSERT INTO ba_user (id, name, email, email_verified, image, created_at, updated_at)
SELECT id, name, email,
       CASE WHEN "emailVerified" IS NOT NULL THEN TRUE ELSE FALSE END,
       image,
       COALESCE("emailVerified", now()),
       now()
FROM "user"
ON CONFLICT (email) DO NOTHING;

-- 2. ba_account — rename + cast expires_at integer → timestamp
INSERT INTO ba_account (
  id, user_id, account_id, provider_id,
  access_token, refresh_token, id_token,
  access_token_expires_at, scope, created_at, updated_at
)
SELECT gen_random_uuid()::text,
       "userId", "providerAccountId", provider,
       access_token, refresh_token, id_token,
       CASE WHEN expires_at IS NOT NULL THEN to_timestamp(expires_at) ELSE NULL END,
       scope, now(), now()
FROM "account"
ON CONFLICT DO NOTHING;

-- 3. ba_session — rename sessionToken → token, expires → expires_at
INSERT INTO ba_session (id, user_id, token, expires_at, created_at, updated_at)
SELECT gen_random_uuid()::text, "userId", "sessionToken", expires, now(), now()
FROM "session"
ON CONFLICT (token) DO NOTHING;

-- 4. ba_verification — rename token → value
INSERT INTO ba_verification (id, identifier, value, expires_at, created_at, updated_at)
SELECT gen_random_uuid()::text, identifier, token, expires, now(), now()
FROM "verification_token"
ON CONFLICT (identifier, value) DO NOTHING;

-- 5. Credentials password carry — separate from the four above
UPDATE ba_account ba
SET password = u."passwordHash"
FROM "user" u
WHERE ba.user_id = u.id
  AND ba.provider_id = 'credential'
  AND u."passwordHash" IS NOT NULL;

COMMIT;

What this does NOT cover: 2FA secret re-encoding (Base64 → Base32), Stripe customerId carry, custom user fields, organization membership, and the dual-cookie reissue endpoint. Those are §5–§9 of the full playbook.

The 8 silent-fail traps

These are the bugs that don't throw — they just sign users out, drop sessions, or fail OAuth without an error log. The full playbook gives the why and the fix for each. The names alone tell you whether you've hit one.

  1. 1

    Missing twoFactorEnabled column

    Sign-up returns 500 when twoFactor() plugin is registered. Column is absent from BA's default generator output.

  2. 2

    Raw (unsigned) session cookie

    BA server reads cookie, fails HMAC verification silently, treats request as unauthenticated.

  3. 3

    Drizzle peer-dep version skew

    drizzle-orm < 0.45 throws on $onUpdateFn(); drizzle-kit < 0.31 throws on uniqueIndex composite.

  4. 4

    ba_verification missing UNIQUE(identifier, value)

    Backfill re-runs duplicate rows; magic links break once cleanup runs.

  5. 5

    account.type column drop

    NextAuth's account.type ('oauth' | 'credentials') has no BA equivalent — drop it; do not try to map.

  6. 6

    internalAdapter.createSession() signature drift

    BA 1.6.x signature is (userId, dontRememberMe?, override?) — not the older docs' single-arg form.

  7. 7

    next-auth vs @auth/core import confusion

    Auth.js v5 ships under both names; the legacy reissue must import from next-auth/jwt, not @auth/core/jwt.

  8. 8

    2FA secret format mismatch

    NextAuth credentials providers tend to store Base64 TOTP secrets; BA twoFactor() plugin requires Base32.

Frequently asked questions

How long does a NextAuth to Better Auth migration take?

For a typical Next.js + Drizzle + Postgres app with under 100k users, the full migration — schema, data backfill, dual-cookie window, and Playwright verification — takes 4–8 hours of focused work. The SQL backfill itself runs in seconds. The bulk of the time is the dual-cookie reissue endpoint and verifying every auth path still works.

Do I need to log every user out during a NextAuth to Better Auth migration?

No. The recommended pattern is a dual-cookie window: middleware reads the Better Auth cookie first, falls back to the legacy NextAuth JWT cookie, and silently reissues a Better Auth session via the internal adapter. Existing users stay signed in; the legacy cookie is dropped on next request.

Will my OAuth users (Google, GitHub) still work after migrating to Better Auth?

Yes, if the SQL backfill copies account.id_token, providerAccountId, and provider correctly into ba_account. Better Auth uses the same OAuth flow as Auth.js — the migration is purely a database and session-format change.

What breaks if I use Better Auth's twoFactor() plugin without adding the twoFactorEnabled column?

Sign-up returns a 500 error. Better Auth 1.6.x writes to ba_user.two_factor_enabled at sign-up time when the twoFactor plugin is registered, even if the user has not enrolled in 2FA. This column is missing from Better Auth's default generator output and is one of the most common silent-fail traps.

Can bcrypt password hashes from NextAuth's credentials provider be carried into Better Auth?

Yes. Copy user.passwordHash directly into ba_account.password where ba_account.provider_id = 'credential'. Better Auth uses the same bcrypt verification, so existing credentials sessions continue to authenticate without forcing a password reset.

Why does the Better Auth session cookie need to be HMAC-signed?

Better Auth's server reads the cookie value, verifies the HMAC against process.env.BETTER_AUTH_SECRET, and rejects unsigned tokens silently. Setting a raw token (e.g. just the session.token value) into the cookie produces a session that BA's server cannot validate, leading to silent sign-out on the next request. This trap is not in the official docs.

Related