Update, 6 September 2026. Better Auth 1.7.3 removed the
issuercolumn this article spends a section on. Accounts are identified byproviderIdandaccountIdagain, as they were in 1.6. Their reasoning: a required column that a populated 1.6 database cannot take without a backfill is too risky to ask of production services, and they have committed to leaving the core schema alone for the rest of v1. Their 1.7 upgrade guide has the steps, which are to drop the unique index and then relax or remove the column.The rest of this article still describes 1.7.0 through 1.7.2 accurately, so it is left as it was written. If you are on one of those versions, section 4 is still the thing that will bite you, and it is still worth reading before you upgrade: the wrong issuer is exactly the state you have to clean up first.
We had opened a documentation pull request upstream about that section, because the Auth.js migration guide was the one guide of theirs that never mentioned the issuer. It was closed, absorbed into the larger revert rather than turned down, which is a good outcome: the guide no longer needs the paragraph because the column no longer exists.
We moved a production Next.js starter kit from Auth.js v5 to Better Auth in one weekend. The library swap took an afternoon. The database took the rest, and we still shipped a bug that locked Google users out of any installation that upgraded.
This is what that migration actually costs, written while it is fresh. Every failure below leaves you with a perfectly valid schema, no error in any log, and users who cannot get in.
The code change is the small half
If your application reads the session through one module, the swap is contained. Ours went through a single @/lib/auth boundary, so changing libraries meant changing one file and letting the call sites follow. If instead you import the auth helper in forty components, do that consolidation first, as a separate change you can verify on its own. Mixing a refactor into a data migration means you will not know which half broke.
What does change everywhere is the shape of the session read:
// Auth.js v5
const session = await auth()
// Better Auth
const session = await auth.api.getSession({ headers: await headers() })And one behaviour worth knowing before you start: with database sessions, the session is a row you can delete. That is the difference that made a column in our schema redundant. We kept a sessionVersion integer purely to fake revocation for signed tokens that cannot be revoked, and it went away with the migration.
Now the part that bites.
1. Your password hashes are in the wrong table, and nothing will tell you
Auth.js, with the Prisma adapter, has no opinion about passwords. Most people who add credentials sign-in put a hash on the user, and that is what we did: User.passwordHash.
Better Auth puts it on an account row instead, with providerId: "credential", next to the OAuth accounts.
So the migration has to create a row per password user:
INSERT INTO "Account" ("id", "userId", "issuer", "accountId", "providerId", "password", ...)
SELECT gen_random_uuid(), "id", 'local:credential', "id", 'credential', "passwordHash", ...
FROM "User"
WHERE "passwordHash" IS NOT NULL;If you forget this, here is what happens: nothing. The schema is valid. The application boots. OAuth users sign in normally. Every password user is told their credentials are wrong, forever, and the hash they need has been dropped with the old column.
Two defences, both cheap. Run the count before and after and refuse to continue if they disagree:
SELECT count(*) FROM "User" WHERE "passwordHash" IS NOT NULL; -- before
SELECT count(*) FROM "Account" WHERE "providerId" = 'credential'; -- afterAnd keep your hashing algorithm. Better Auth hashes with scrypt, your existing hashes are probably bcrypt, and you cannot convert between them without the plaintext. Pass your own functions:
emailAndPassword: {
enabled: true,
password: {
hash: hashPassword,
verify: ({ password, hash }) => verifyPassword(password, hash),
},
},Then prove it with a real sign-in against the migrated database, using a password you know. A count tells you rows exist. Only a sign-in tells you they work.
2. expires_at is a conversion, not a rename
Auth.js stores the OAuth token expiry as expires_at, an integer of Unix seconds. Better Auth calls it accessTokenExpiresAt and it is a timestamp.
Treat that as one of the renames in the list and one of two things happens. If you are lucky the cast is refused and you get a loud error. If you are unlucky it succeeds, and every OAuth token in your database is now dated to January 1970.
"accessTokenExpiresAt" = CASE
WHEN "expires_at" IS NULL THEN NULL
ELSE (to_timestamp("expires_at") AT TIME ZONE 'UTC')
ENDThis one does not block sign-in, which is exactly why it is dangerous. It surfaces weeks later, the first time a refresh token actually matters, in a code path nobody was looking at.
3. Prisma does not wrap a migration in a transaction
We learned this by failing. Our first run stopped halfway through, on a column ordering mistake, and left the database in a state we had not imagined: emailVerified already converted to a boolean, the account table carrying the old columns and the new ones at once, the old token table still present and the new one not yet created.
On a scratch database that is an annoyance. On production it is the worst outcome available, worse than a clean failure: no version of your application can talk to that database. The old build cannot find passwordHash. The new build cannot find the tables it expects. The site is down until somebody repairs a schema they have never seen before, by hand, under pressure.
The fix is two lines:
BEGIN;
-- everything
COMMIT;PostgreSQL runs DDL inside transactions, so this genuinely works: a failure rolls the whole thing back and costs you nothing but a rerun. Put them in before your first attempt, not after your first accident.
And a note on how we found it. A migration tried against a freshly seeded database always passes, because a clean seed only produces the rows the happy path creates. We built a deliberately dirty corpus first: the OAuth user with no name, the account row whose expiry is an integer, the magic-link user with no account row at all, the user whose name is an empty string rather than null. That last one matters more than it sounds, because name becomes required and an empty string satisfies the constraint perfectly while showing a blank name to a real person forever.
4. The issuer, and why a wrong one locks people out instead of duplicating them
This is the one we got wrong, in a release we shipped.
Better Auth finds an account at sign-in by the pair (issuer, accountId). There is no fallback on the provider id. If the pair does not match, the row does not exist as far as sign-in is concerned.
Auth.js has no issuer column, so the migration invents one. Better Auth exports a helper for that, createOAuthAccountIssuer, which builds local:oauth:<providerId>. We read it, applied it to every OAuth account, and it was green in every check we ran.
It is the fallback, not the rule. It is what Better Auth uses for a provider that declares no issuer of its own. GitHub declares none, so local:oauth:github is right. OpenID Connect providers declare theirs, and Google's is https://accounts.google.com. Ours said local:oauth:google, which is a value the library never writes and never looks for.
Now follow what that does to a real person, because the obvious guess is wrong.
You would expect a second account: the sign-in is not recognised, so a new one is created. That is not what happens to most users. Better Auth finds the existing user by email and tries to link the unrecognised sign-in to it, which would quietly fix everything. That path is refused when the local user's emailVerified is false, which is the default and is not configurable away by accident.
And here is where two independent decisions meet. emailVerified in Auth.js is a nullable timestamp, and the natural migration is IS NOT NULL. Auth.js leaves that column null for most accounts created through OAuth, because the provider vouched for the address and no verification email was ever sent. So the migration correctly writes false for exactly the users who signed in with Google.
Wrong issuer, plus a false verified flag, equals account not linked. Not a duplicate account. A closed door.
Neither half is a bug on its own. The issuer is documented, in Better Auth's own upgrade guide for 1.7 and in their migration guides from Clerk, Auth0 and Supabase. The one we were reading, the guide for migrating from Auth.js, is the only one of the four that does not mention it. The verified flag is documented too, under account linking. Nothing was hidden. They are just never on the same page, and the failure needs both.
The check that would have caught it, and why ours did not
Our release checklist for that migration had a line for the issuer. It ran, and it passed: three OAuth rows, three in the format local:oauth:*, green.
It compared the database against a string we had typed. A check written that way can only confirm the assumption that produced it. It cannot find out that the assumption is wrong, because the assumption is both the question and the answer.
The check that would have found it is a real sign-in, with a real Google account, against a database that has been through the migration. We had run six sign-in tests that day. Every one of them was on the credentials path. The coverage felt complete because there were so many tests, and nobody counted which paths still had none.
So the verification script we ship now does not compare your data to a value written inside it. It asks the library what the issuer of each configured provider should be, and compares that:
const factory = socialProviders[providerId]
const declared = factory({ clientId: "-", clientSecret: "-" }).accountIssuerIt also builds each provider twice with different options, because some issuers are computed from your own configuration. Cognito's contains your region and user pool. Paybin's is options.issuer || <default>, so reading it once with placeholder credentials returns a confident, wrong answer. If the two builds disagree, the script reports that it cannot decide rather than guessing, and tells you to map that provider by hand.
What we did about it
A repair migration, as a new file rather than an edit of the original. Not for checksum reasons, which we tested and which do not apply the way it is usually claimed, but for a simpler one: an applied migration is never run again. Editing the original would repair only people who had not upgraded yet, and leave everybody the bug actually reached exactly as broken as before.
It also has to handle a case an UPDATE alone would break. Someone who signed in after the bad migration and got past the email check now has two rows, the migrated one and the one Better Auth created. Rewriting the first collides with the second on the unique index. The stale row goes instead, and the surviving one is the better of the two anyway, since its tokens came from a real sign-in.
And it stops rather than guesses for the providers whose issuer depends on your configuration. Writing a plausible wrong value is the mistake that started all this.
Then we proved it, in five steps, because a repair verified against the same assumption that produced the bug is not verified at all:
- Sign in with a real Google account. A correct row appears, carrying the real account id.
- Break it deliberately, back into the state the bad migration produced.
- Sign in again. It must fail. If it succeeds, the diagnosis is wrong and nothing ships.
- Run the repair.
- Sign in a third time. It must succeed, as the same user, with no second account created.
Step 3 is the one that matters. Without it you have a repair for a fault you reasoned about. With it you have a repair for a fault you watched happen.
One consequence we did not expect, and it is good news if you are migrating now: the repair does not need to touch anybody's emailVerified. Once the issuer is right, the pair matches directly and the linking path is never reached. The first successful sign-in then sets the flag to true on its own, from what the provider says.
If you are about to do this
Write the counts down before you start, and treat a disagreement as a stop rather than something to look into later. Build a dirty corpus rather than a clean seed, because the failures live in the rows a happy path never creates. Wrap the migration in a transaction before your first attempt. And do one real sign-in per authentication method you support, against the migrated database, before anyone else does.
Every file behind this article is in a free, MIT licensed starter kit: the migration, the repair, the verification script and the upgrade guide with the counts to run. Clone it and read prisma/migrations next to the upgrade guide, or start with the authentication setup.
If you have not decided whether to run authentication yourself at all, the longer argument is in self-hosted authentication in Next.js. If you are on Next.js 16 and wondering what happened to your middleware, that rename and the route protection it changes are covered in authentication middleware is now proxy.