Upgrading to 0.11
0.11 requires Better Auth 1.7. Upgrade the peers together, then handle the account issuer change, which Better Auth 1.7.3 later reverted.
0.11 requires Better Auth 1.7. Better Auth 1.6 is no longer supported: 1.7 added two required adapter methods (consumeOne and incrementOne), and a 1.6 install throws at runtime.
The issuer backfill only applies to Better Auth 1.7.0–1.7.2
Better Auth 1.7.3 reverted the issuer account schema, and 0.14 requires Better Auth 1.7.7 or later. If you are upgrading straight to the current release, skip step 2: the generated accounts collection no longer has issuer. If you already applied the backfill, follow the issuer cleanup. The detail below is kept for databases that went through 1.7.0–1.7.2.
Upgrade the peers together
better-auth@^1.7, plus @better-auth/api-key / @better-auth/passkey at the same major if you use them.
Generate the migration, then backfill issuer (1.7.0–1.7.2 only)
Better Auth 1.7.0 keyed provider identities on (issuer, accountId) instead of providerId, so the accounts collection gained a required issuer field plus a unique index. payload migrate:create emits an ADD COLUMN … NOT NULL that fails on a populated table. Split it into add-nullable, backfill, enforce:
ALTER TABLE accounts ADD COLUMN issuer varchar;
-- Email/password rows
UPDATE accounts SET issuer = 'local:credential' WHERE provider_id = 'credential';
-- OAuth rows: the issuer each provider DECLARES (see below)
UPDATE accounts SET issuer = 'https://accounts.google.com' WHERE provider_id = 'google';
UPDATE accounts SET issuer = 'https://www.facebook.com' WHERE provider_id = 'facebook';
-- Only providers that declare no issuer of their own get the synthetic form
UPDATE accounts SET issuer = 'local:oauth:' || provider_id WHERE issuer IS NULL;
ALTER TABLE accounts ALTER COLUMN issuer SET NOT NULL;
-- then the unique index exactly as Payload generated itBefore enforcing, confirm the backfill produced no duplicates. This must return zero rows, or the unique index fails:
SELECT issuer, account_id, COUNT(*)
FROM accounts GROUP BY issuer, account_id HAVING COUNT(*) > 1;Table and column names assume the defaults on Postgres (pluralized slug accounts, snake_case columns). Adjust for usePlural: false, a custom account model name, or MongoDB.
The local:oauth: form is the fallback, not the rule
local:oauth:<providerId> applies only where a provider declares no issuer of its own. In Better Auth 1.7.1, seven built-ins declare one and must not get the synthetic form: google (https://accounts.google.com), facebook (https://www.facebook.com), apple (https://appleid.apple.com), line, cognito, paybin and microsoft. Every generic-OAuth/OIDC provider (Okta, Auth0, Keycloak) uses its discovery issuer. Email/password rows take local:credential.
Getting this wrong is silent. The row is filed under a key Better Auth never queries, so the next sign-in doesn't find it, takes the new-identity path and writes a second account row, which the unique index permits because the pair differs. It surfaces later as an unexpected account-link prompt, or account_not_linked where linking is forbidden.
Read a provider's issuer instead of guessing it:
node -e "import('better-auth/social-providers').then(m => console.log(m.google({clientId:'x',clientSecret:'y'}).accountIssuer))"Microsoft Entra ID needs a row-by-row backfill, and its account_id moves too. Its issuer is per tenant (https://login.microsoftonline.com/<tid>/v2.0), so there is no constant to write, and 1.7 keys the subject on the oid claim where 1.6 stored sub. Both values are in the id_token already on the row: decode the claims segment (base64url) and take iss and oid. Decode only; there is no signature to check, because this reads a claim from your own database rather than a token presented by a caller.
Facebook's account_id does not move. Facebook rows need only the issuer update. For any other provider you use, compare its 1.7 accountSubject with how 1.6 derived the id; where they disagree, account_id needs migrating alongside issuer.
Apply the migration and verify
Apply it and verify sign-in for each provider you support. No application-code changes are required for the common setup; the adapter, generated collections and admin UI absorb the rest of 1.7.
Smaller Better Auth 1.7 changes
These are Better Auth changes the plugin passes through. They apply only if you use them:
- Database joins moved out of experimental:
experimental: { joins: true }→advanced: { database: { joins: true } }. - Dynamic
baseURLbehind a proxy: 1.7 resolves the auth origin fromHostby default. If your proxy exposes the public hostname inx-forwarded-host, setadvanced: { trustedProxyHeaders: true }. Proxies that rewrite the host (Vercel, Cloudflare, Netlify) need no change. - Generic OAuth was rewritten:
signIn.oauth2({ providerId })→signIn.social({ provider }),oauth2.link()→linkSocial(), and callbacks moved to/api/auth/callback/:id. The admin login already usessignIn.social. - The
oidcProviderplugin was removed in favor of@better-auth/oauth-provider, and the MCP plugin moved to@better-auth/mcp. - OAuth JWT bearer auth in
betterAuthStrategynow usesverifyBearerToken; DPoP-bound tokens are rejected.
Upgrading to 0.12
0.12 stores array fields natively. Convert rows written as JSON strings once, verify in SQL, and only then remove your own workarounds.
0.10 and earlier
Breaking changes from 0.7 through 0.10 — locked secret fields, server-assigned roles, the passkey login client and more — with the steps for each.

