Upgrading to Better Auth 1.7
Upgrade Better Auth from 1.6 to 1.7, including OAuth, OpenID Connect, MCP, SAML, SCIM, proxy, and custom adapter changes.
Most Better Auth 1.7 changes are additive. Most projects start with one command:
npx auth upgradeSome areas need more care: OAuth, OpenID Connect, SAML, SCIM, two-factor authentication, MCP, custom storage, and proxy setups. Use this table to choose the sections that apply to your project.
| If your project | Read |
|---|---|
| Uses Better Auth at all | Before you upgrade and Behind a proxy |
| Uses social login, generic OAuth, One Tap, or SSO | As a login client |
| Runs its own OAuth or OpenID provider | As an identity provider |
| Runs MCP | MCP |
| Uses SAML, SSO domain verification, or SCIM | Enterprise SSO |
| Uses Stripe billing | Stripe |
| Uses two-factor auth, magic links, or email OTP | Two-factor and passwordless security |
| Uses a custom database adapter, secondary storage, or rate-limit store | Custom adapters and storage |
Before you upgrade
The upgrade command handles package updates. The database needs more care. A few 1.7 features add or change tables, and some also need a manual data step.
Run the schema migration before deploying 1.7 if any changed feature applies to your project.
Use the CLI migration commands:
npx auth@latest generate
npx auth@latest migrateIf you manage your own schema with Drizzle or Prisma, run generate and apply the result through your own migration tooling.
The auth CLI now requires Node.js 22.12 or newer to run.
These features change the schema:
| Feature | What it adds | Converts data for you? |
|---|---|---|
| Protected resources | New resource tables and key columns | Not needed (config move) |
| Resource-bound tokens | Resource columns on the token tables | Yes |
| DPoP | A token-binding column | Not needed |
| Refresh-token reuse window | A cached replay-response column on refresh tokens | Not needed |
| Authorization-code replay | An indexed authorizationCodeId column on both token tables | Not needed |
| Back-channel logout | Logout-URL and revoked columns | Not needed |
| Requested user-info claims | A requested-claims column on the token and consent tables | Not needed |
| SCIM org scoping | organizationId required, userId removed, providerKey added | No, reclaim old rows |
| Organization team counters | team.memberCount and teamMember.membershipKey columns | Not needed |
| SCIM groups | New scimGroup, scimGroupMember, scimGroupRole, and scimGroupRoleGrant tables | Not needed |
The SCIM reclaim is the one manual data step. Plan for it before you cut over.
The SCIM reclaim is not automatic. Connections created before 1.7 may have no organization, so you must delete or assign an organizationId to rows without one before regenerating their tokens.
As a login client
This covers social login, the generic OAuth plugin, One Tap, and consuming SSO.
Generic OAuth is rebuilt on the social-provider path
The generic OAuth plugin now works like the built-in social providers.
What to do:
- Replace
signIn.oauth2({ providerId })withsignIn.social({ provider }). - Replace
oauth2.link()withlinkSocial(). - Update your provider callback URL from
/api/auth/oauth2/callback/:idto/api/auth/callback/:id. - Remove
genericOAuthClient()from your client plugins and use the standard social client APIs. - PKCE now defaults to on. Set
pkce: falseonly for a provider that rejects it. - Remove
issuerandrequireIssuerValidation; issuer validation is now automatic. authorizationUrlParamsandtokenUrlParamsnow accept only plain string maps.
Identity tokens go through one verifier
Each provider used to verify its own identity tokens. Now there is one verifier, and each provider declares an idToken config: keys, issuer, and audience.
What to do: a custom provider should replace its verifyIdToken method with an idToken config. PayPal no longer accepts identity-token login; it uses its access token instead. Switch PayPal identity-token login to the redirect flow. The built-in provider options are unchanged.
Electron requires modern PKCE
The Electron login flow now requires S256 PKCE, no longer trusts a custom origin header, and matches custom URL schemes more safely.
What to do: upgrade the @better-auth/electron client and server together. Make sure your app URL scheme is in trustedOrigins. Remove the old disableOriginOverride option. Review trusted entries with a host, such as myapp://callback, because they no longer match lookalike hosts.
generateState() signature changed
The public generateState() helper now takes an options object instead of positional arguments.
What to do: if you call generateState() directly, replace generateState(c, link, additionalData) with the options form generateState(c, options).
OAuth callback error code renamed
The OAuth callback redirect error value email_doesn't_match is renamed email_does_not_match.
What to do: if you read this error code from the callback redirect, update the string to email_does_not_match.
Google One Tap signs in the right person
One Tap now signs in the user who actually owns the Google account by validating the identity token's subject, rather than matching on email.
What to do: nothing to configure. If you relied on email-based matching, expect One Tap to bind to the Google account subject instead.
Scopes are kept across logins
Granted scopes used to overwrite each other. A permission granted earlier could disappear after a later login that asked for less access.
Better Auth now keeps the scopes already on the account across re-login and token refresh, using the existing account.scope field. This is a behavior fix: no schema change, no backfill, and no action for most projects.
If you tracked the 1.7 prereleases, note that an early beta moved scopes into a grantedScopes array and a later beta reverted it. The shipped 1.7 keeps the original account.scope string. If you adopted the grantedScopes column from an early beta, drop it and restore scope on every schema you changed. See Tracking prereleases.
Discovery providers verify their identity tokens
A generic OAuth provider configured with a discovery URL now verifies the provider identity token. A token that fails verification is rejected.
What to do: if a discovery provider returned a token that could not be verified and your app trusted it anyway, the login is now rejected. Confirm the provider's published keys, issuer, and audience.
Signed-assertion login validates at startup
A signed-assertion login setup, also called private-key JWT, is now checked when it is created. An unsupported algorithm, a key with no material, or a mismatch between the declared algorithm and the key now fails immediately instead of silently doing the wrong thing.
What to do: fix any signing setup whose declared algorithm disagrees with the key. Replace createAuthorizationCodeRequest, createRefreshAccessTokenRequest, and createClientCredentialsTokenRequest with the async authorizationCodeRequest, refreshAccessTokenRequest, and clientCredentialsTokenRequest.
Anonymous account linking works in mobile and in-app browsers
Linking an anonymous account after a social login now works in Expo and other in-app browsers, where the callback returns without the usual cookie. A new addOAuthServerContext API carries trusted data across the login that a client cannot forge.
What to do: nothing for most apps. If you carried anonymous-link state across the OAuth redirect yourself, move it onto addOAuthServerContext.
As an identity provider
This covers @better-auth/oauth-provider. Several changes need a schema migration; run generate and migrate once after applying them.
Protected resources replace the audience list
Audiences are now resources. Each resource can have its own token lifetime, scopes, claims, and signing keys. The old validAudiences list is removed.
What to do:
- Move each entry from
validAudiencesintoresources. - Link clients to specific resources through
oauthClientResourceor registration. - Run
generateandmigrateto add the new tables and columns. - Check your refresh-token lifetimes: the shortest applicable lifetime now wins, so a per-resource value longer than the provider default is capped at the default.
If you accept dynamically registered clients, the resource model enforces per-client resource access by default. A dynamic client's token request can be rejected with invalid_target. Set enforcePerClientResources: false for that case, and register each client's grant_types explicitly, or the token endpoint rejects them with unauthorized_client.
Token target is locked to the login
The API a token is for is now captured at login and locked to that grant. A later request can narrow the target API but cannot widen it. Asking for an API the login did not cover is rejected. A custom-claims callback now receives a list of resources instead of one value.
What to do: run the migration to add the resource columns. Update custom-claims callbacks to read the resource list. Make sure clients ask only for resources their login covered.
DPoP renames the token verifier
The plain token-checking helper verifyAccessToken is renamed verifyBearerToken and now rejects DPoP tokens. Use the new verifyAccessTokenRequest on endpoints that may receive DPoP requests.
What to do: rename verifyAccessToken to verifyBearerToken, and switch DPoP-capable endpoints to verifyAccessTokenRequest. Run generate and migrate to add the token-binding column. To support DPoP, configure database-backed verification storage.
Watch the proxy case. Native DPoP checks the proof's htu claim against the URL the token endpoint computes for itself. Behind a TLS-terminating proxy or a custom server, that computed URL can be the internal bind address (http://0.0.0.0:3000) or the proxy's internal scheme and port. The client signed htu from your public discovery URL, so a valid proof can be rejected. Canonicalize the incoming request's scheme and host to your configured baseURL at the route boundary before the provider reads it.
Sign-out revokes session tokens
When a session ends, the access tokens tied to it are now revoked. They read as inactive at introspection and userinfo. Before, they lived until they expired. Your server also sends a logout message to each app that registered a logout URL.
What to do: run generate and migrate to add the new columns. Expect session-bound tokens to stop working at sign-out. On serverless platforms, set advanced.backgroundTasks.handler so sending logout messages does not slow down sign-out.
Custom ID-token claims cannot override protocol claims
Your custom ID-token claims can no longer set protocol claims that the standard reserves for the server: issuer, subject, audience, expiry, nonce, session binding, auth_time, acr, amr, and azp. Your own namespaced claims still appear. ID tokens also report acr: "0" rather than a vendor-specific value.
What to do: if a customIdTokenClaims callback, an extension claim contributor, or a per-issuance idTokenClaims set one of those reserved claims, that value is now ignored. Move the data into a namespaced claim of your own, or rely on the server's value.
ID tokens drop profile and email scope claims
ID tokens issued through the authorization-code flow no longer carry the profile and email scope claims. Those claims are available from the UserInfo endpoint.
What to do: read profile and email claims from UserInfo instead of the ID token.
jwt.sign callbacks must match the configured alg
A custom jwt.sign callback is rejected when its algorithm differs from keyPairConfig.alg during ID-token issuance.
What to do: align your custom signing algorithm with keyPairConfig.alg.
/oauth2/revoke rejects valid JWT access tokens
Revoking a still-valid JWT access token now returns 400 unsupported_token_type.
What to do: revoke refresh tokens or opaque access tokens; do not call revoke on JWT access tokens.
max_age is enforced
When a client asks for max_age and the user's login is older than that, the provider sends them back to log in. Before, the request was ignored.
What to do: nothing to configure. If a client sent max_age expecting it to be ignored, expect a re-login prompt now.
Watch your date columns. The max_age check reads a session's creation time back as a date. If your custom schema stores session timestamps as text instead of a real date or integer-timestamp type, the check can misread the value and send users into a login loop. Store user, session, account, and verification timestamps with a date or timestamp type. The Better Auth CLI generates the correct type.
Client creation returns 201
Creating a client now returns 201 Created instead of 200 OK, and the registration endpoint enforces the same permission checks as the manual create endpoints.
What to do: update any client that expects a 200 from client creation to accept 201. To allow machine clients to register, configure validateInitialAccessToken.
Unauthenticated registration keeps the client's auth method
Dynamic Client Registration without a logged-in user no longer forces the client to be public. A client that omits token_endpoint_auth_method is now confidential with the RFC 7591 default client_secret_basic and a generated secret; it becomes public only when it registers token_endpoint_auth_method: "none".
What to do: if you relied on unauthenticated registrations being downgraded to public, register token_endpoint_auth_method: "none" explicitly for clients that must stay public.
Registration requires reciprocal response and grant types
A registered client's response_types and grant_types must now be reciprocal: a code response type requires the authorization_code grant, and a token grant requires its matching response type. Mismatched registrations are rejected.
What to do: register matching response_types and grant_types for each client.
OAuth endpoints return standard error envelopes
Validation and malformed-request failures on the OAuth endpoints (token, authorize, revoke, introspect, register, end-session) now return RFC 6749 { error, error_description } envelopes instead of the previous generic validation-error shape.
What to do: if a client or tool parsed the old error shape, update it to read error and error_description.
Introspection returns consistent claims
/oauth2/introspect now returns the same claims for an opaque token as it does for a JWT, and a resource server can introspect a token issued to a different client.
What to do: nothing. Expect richer, consistent introspection responses for opaque tokens.
UserInfo accepts a bearer token in the form body
The userinfo endpoint now accepts the access token in a form-encoded body and rejects a request that sends the token in both the header and the body.
What to do: send the access token in one place, the Authorization header or the form body, not both.
Client authentication is tied to the grant
A custom client-authentication method registered through the extension surface can now only prove which client is calling. The server decides what that client is allowed to do.
What to do: companion plugins that added a client-authentication method should rely on the server-resolved client rather than returning their own client decision.
Server-side OAuth requests refuse redirects
Better Auth now refuses HTTP redirects on the server-side OAuth requests it makes: token exchange, token refresh, client credentials, token introspection, and JWKS requests. Conformant OAuth providers answer these endpoints directly and do not redirect.
What to do: nothing for standard providers. If a custom provider endpoint redirects, make it return the final response directly.
PKCE requirements for confidential and OIDC clients
PKCE is always required for public clients. A confidential client registered through Dynamic Client Registration can opt out with clientRegistrationRequirePKCE: false. A request that carries the offline_access scope still requires PKCE unless it is an OIDC request with a nonce, which a confidential client can use instead.
What to do: set clientRegistrationRequirePKCE: false only for confidential clients that cannot use PKCE. Send a nonce if a confidential OIDC client needs offline_access without PKCE.
Authorize accepts form-encoded requests and rejects request objects
The authorization and userinfo endpoints now accept form-encoded (POST) requests, and the authorization endpoint explicitly rejects the OIDC request and request_uri parameters it does not support.
What to do: nothing for standard clients.
Refresh-token retries can be tolerated
The OAuth provider can replay the same refresh response for duplicate refresh requests during refreshTokenReuseInterval. Strict refresh-token replay handling remains the default.
What to do: set refreshTokenReuseInterval only if a public or native client can retry a refresh request with an old token after another local session already rotated it. MCP defaults this window to 30 seconds for native and public clients. Set refreshTokenReuseInterval: 0 to keep strict replay handling.
If you extended the OAuth provider by hand
If you added custom grants, claims, or client-authentication methods by patching or forking the OAuth provider, use the supported extension surface instead of re-applying a patch. Register your contributions with extendOAuthProvider(ctx, ...) from your plugin's init(ctx) hook. Mint tokens with provider.issueTokens(...), authenticate a client with provider.authenticateClient(...), and hash a token with provider.hashToken(...). Bind a token's audience by passing resources to issueTokens; the server owns the audience.
A contribution written in an older or hand-rolled shape can fail silently here. It may type-check and run while the grant or claim never reaches a token. After moving each one into init(), confirm the grant or claim reaches a real token.
The old oidcProvider plugin is removed
What to do: replace oidcProvider from better-auth/plugins with oauthProvider from @better-auth/oauth-provider, move your config across, and run the schema migration.
MCP
MCP moves to its own package
The MCP plugin moves from better-auth into @better-auth/mcp, built on the OAuth provider. This is the largest single change for existing MCP users.
What to do:
- Install
@better-auth/mcpand update imports: the server plugin and helpers from@better-auth/mcp, the client and adapters from@better-auth/mcp/clientand@better-auth/mcp/client/adapters. - Add the
jwt()plugin, which is now required. - Move options that were nested under
oidcConfigup to the top level ofmcp({ ... }), and add a resource identifier, for exampleresource: "https://api.example.com/mcp". - Rename
withMcpAuthtorequireMcpAuth, andcreateMcpAuthClienttocreateMcpResourceClient. - Regenerate or migrate your schema.
The OAuth endpoints move from /mcp/* to /oauth2/*. Discovery-based MCP clients find the new locations on their own. MCP also defaults refreshTokenReuseInterval to 30 seconds for native and public clients; set it to 0 if you want strict refresh-token replay handling.
Enterprise SSO
IdP-initiated SAML is off by default
Unsolicited logins started by the identity provider are now disabled by default. A login response is validated for InResponseTo, so it cannot be replayed against a request it was not issued for, and Single Logout requests are matched to their session by SessionIndex.
What to do: set saml.allowIdpInitiated: true to restore the old behavior if you depend on it.
SAML certificates can be a list
Signing certificates now accept a single value or a list, which lets you rotate certificates without downtime. The management endpoints return the certificate as a list, or omit it when the certificates live inside an idpMetadata document.
What to do: update any code that reads the certificate from those endpoints to expect a list or its absence. Make sure every SAML config supplies a signing-cert source, an explicit certificate or an idpMetadata document, or registration fails.
SAML configuration is simplified
The callback URL is derived automatically, service-provider metadata is generated for you, and several fields are removed. One endpoint path changes, and error codes in the redirect change from short aliases to the full lowercased internal code (for example saml_multiple_assertions).
What to do: remove callbackUrl, the empty spMetadata, and the removed fields from your config. Update your provider callback URL to /sso/saml2/sp/acs/:providerId. Set the post-login redirect with callbackURL in signIn.sso(). If you read SAML error codes from the redirect URL, switch to the lowercased codes.
SCIM writes are safer
SCIM user writes now honor the active attribute, and non-organization SCIM deletes only remove the global user when the SCIM account is their only identity. PUT and PATCH reject changing a user's email to one already used by another user.
What to do: if your SCIM client sends active: false, Better Auth now deactivates the user through the admin plugin and revokes their sessions. If your SCIM client changes emails, handle 409 conflicts. Honoring active requires the admin plugin.
SCIM connections are scoped to an organization
SCIM connections now require organizationId, add a providerKey column, and drop the old userId column. The defaultSCIM option becomes staticProviders, trustedDomains is removed, and provider IDs are namespaced per organization. The old per-user ownership option (providerOwnership) is removed, so every connection is bound to an organization.
What to do: run the migration, set organizationId on connections, rename the defaultSCIM config to staticProviders, and remove trustedDomains. Reclaim pre-1.7 connections that have no organization by assigning an organizationId or deleting them, then regenerate their tokens.
/sso/update-provider rejects partial mappings
Updating an SSO provider now rejects a partial OIDC or SAML mapping object.
What to do: send a complete mapping object when you call /sso/update-provider.
SCIM gains durable group resources
SCIM now manages groups with membership, roles, and lifecycle endpoints, backed by new scimGroup, scimGroupMember, scimGroupRole, and scimGroupRoleGrant tables.
What to do: run generate and migrate to add the SCIM group tables before using group provisioning.
OIDC SSO works on Cloudflare Workers
OIDC SSO with discovery now works on Cloudflare Workers. A discovery or token endpoint that redirects is rejected with a clear configuration error instead of failing in a runtime-specific way.
What to do: nothing. If a provider endpoint redirects, point your config at the final URL.
Stripe
Organization subscriptions require organization.enabled
referenceMiddleware now rejects organization-scoped subscriptions unless organization: { enabled: true } is set in the Stripe plugin config.
What to do: set organization: { enabled: true } in your stripe() plugin options for organization-scoped subscriptions. The organization plugin is still needed separately to resolve the active organization.
onSubscriptionCancel event is required
The event parameter on the onSubscriptionCancel callback is now required.
What to do: update your onSubscriptionCancel callback to expect a non-optional event.
Behind a proxy
Forwarded headers are not trusted by default
When your app reads its own address, it now uses the Host header and ignores forwarded headers unless you opt in.
What to do: if your proxy exposes the public hostname only through x-forwarded-host, opt in:
betterAuth({
baseURL: { allowedHosts: [...] },
advanced: {
trustedProxyHeaders: true,
},
});Setups where the proxy rewrites the host for you, such as nginx, Vercel, Cloudflare, and Netlify, need no change.
IdP redirects and DPoP need your canonical origin
Two OAuth-provider behaviors read the incoming request origin. Behind a custom server or TLS-terminating proxy, that origin can be the internal bind address rather than your public origin. The provider returns your consentPage and loginPage as relative paths, so a server-side redirect, such as NextResponse.redirect, needs them resolved against an absolute origin. Native DPoP also compares the proof's htu against the URL the token endpoint computes for itself.
What to do: treat baseURL as the server identity, and canonicalize the incoming request scheme and host to it at the route boundary before the provider reads the request. One helper, applied wherever a route forwards to the provider, fixes consent redirects, the DPoP htu check, and origin checks together.
Custom adapters and storage
The atomic-state work introduces required methods. If you use only the built-in adapters and storage, you can skip this.
Database adapters must implement incrementOne and consumeOne
incrementOne updates one row's counter atomically and returns the row, or null when the guard did not match. consumeOne reads and deletes a row in one step for single-use credentials. Both are now required, and the old fallback is gone.
What to do: implement both incrementOne and consumeOne in any custom adapter. A missing consumeOne throws at runtime. All built-in adapters already do.
Secondary storage must implement increment and getAndDelete
increment(key, ttl) bumps a counter by one and sets the expiry only when the key is first created. getAndDelete(key) reads and removes a key in one step. Both were optional before and are now required.
What to do: implement both increment and getAndDelete in custom secondary storage. Redis storage already does.
Rate-limit storage uses consume
Rate-limit storage now needs a single consume(key, rule) method that checks and increments in one step. Separate get and set are no longer accepted.
What to do: replace get and set in custom rate-limit storage with consume.
Default Drizzle schema uses singular relation keys
The default Drizzle schema generator now emits singular relation keys.
What to do: regenerate your Drizzle schema and review the relation keys.
getIp is renamed getIP
The public getIp export is renamed getIP.
What to do: update imports of the IP helper to getIP.
Captcha
Captcha matches full paths
Captcha rules now match full request paths or explicit wildcards, which closes a way to skip a captcha rule through partial path matching.
What to do: replace a partial path like /sign-in with /sign-in/* or /sign-in/**.
Two-factor and passwordless security
Two-factor enableTwoFactor returns a discriminated response
enableTwoFactor now accepts a method of "otp" or "totp" (default "totp") and returns that method in the response. totpURI and backup codes are present only for "totp". The skipVerificationOnEnable option still works.
What to do: update callers that read totpURI or backup codes to branch on the returned method.
Magic-link and email-OTP sign-in can clear unproven credentials
Magic-link and email-OTP sign-in now treat proven mailbox control as the source of truth for an account whose email had never been confirmed. If that account had an unproven password or other linked accounts, Better Auth removes all of them and revokes existing sessions before signing the user in.
What to do: if a user signed up with email and password but first signs in through a magic link or email OTP instead of confirming the verification email, ask them to set a new password through password reset.
Behavior changes worth noticing
These do not need action for most setups, but they change what you see.
- Safer OAuth token handling: a refresh token used by a different client, a replayed authorization code, and a
redirect_urithat does not match the one used at login are now rejected with the correct standard error. A replayed code also revokes the tokens it already issued. Well-behaved clients are unaffected. - No caching of credentials: token, introspection, userinfo, registration, and device-authorization responses now send
Cache-Control: no-storeso proxies and browsers do not cache them. - userinfo rejects bad tokens: an invalid access token at the userinfo endpoint returns
401 invalid_tokenwith aWWW-Authenticateheader. - OAuth authorize error redirect: a missing
response_typenow redirects the error to the verified clientredirect_uriinstead of a generic error. - Drizzle affected-row validation: the Drizzle adapter throws on an invalid affected-row count instead of returning
0. organization.updateTeamimmutable fields:id,createdAt, andupdatedAtare no longer accepted in the request body.updateMemberRoleordering: role-existence validation now runs after authorization checks.- CLI
generate --outputto a directory: picks an adapter-specific default filename. - Generated schema and disabled migrations: references to migration-disabled models are omitted.
- Cookie-cache session binding: the cached session is now tied to the
session_tokencookie. - SSRF host checks: outbound-host classification now blocks additional reserved ranges (6to4 relay anycast, site-local IPv6, and IPv4-compatible IPv6).
- Standard token-redemption errors: authorization-code redemption failures return
400 invalid_grantinstead of401 invalid_clientorinvalid_request. - Sign-out hooks with external session stores:
session.deletehooks now run on sign-out even withsecondaryStorageandpreserveSessionInDatabase. - Two-factor invalidation error code: a failed two-factor challenge cleanup now returns
FAILED_TO_INVALIDATE_TWO_FACTOR_CHALLENGE.
Upgrade checklist
Run once, after applying the changes that affect you:
npx auth@latest generate
npx auth@latest migrateThen complete the manual data step if it applies:
- Reclaim SCIM connections: delete or assign an
organizationIdto connection rows that have no organization.
Tracking prereleases
If you upgraded straight from 1.6 to the final 1.7, skip this. It applies only if you adopted a 1.7 beta and followed its changes.
A breaking change in one beta can be reverted in a later beta of the same line, and the reversal is a migration in the opposite direction. The clearest case is OAuth scopes: an early beta moved account.scope into a grantedScopes array, and a later beta reverted it, so the final 1.7 keeps the original account.scope string. If you adopted the grantedScopes column, drop it and restore the scope column on every schema you changed. When your migration tool cannot tell a rename from a drop, reconcile the column with direct SQL so you keep existing rows.
Two habits make this cheaper. Read revert entries in the changelog, not just feat and fix; a revert is a migration for you even though it does not announce itself as breaking. Bump only the packages that carry the change, and confirm your own plugins do not import the reverted surface before you rebuild them.