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 upgrade

Some 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 projectRead
Uses Better Auth at allBefore you upgrade and Behind a proxy
Uses social login, generic OAuth, One Tap, or SSOAs a login client
Runs its own OAuth or OpenID providerAs an identity provider
Runs MCPMCP
Uses SAML, SSO domain verification, or SCIMEnterprise SSO
Uses Stripe billingStripe
Uses two-factor auth, magic links, or email OTPTwo-factor and passwordless security
Uses a custom database adapter, secondary storage, or rate-limit storeCustom 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:

Terminal
npx auth@latest generate
npx auth@latest migrate

If 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:

FeatureWhat it addsConverts data for you?
Protected resourcesNew resource tables and key columnsNot needed (config move)
Resource-bound tokensResource columns on the token tablesYes
DPoPA token-binding columnNot needed
Refresh-token reuse windowA cached replay-response column on refresh tokensNot needed
Authorization-code replayAn indexed authorizationCodeId column on both token tablesNot needed
Back-channel logoutLogout-URL and revoked columnsNot needed
Requested user-info claimsA requested-claims column on the token and consent tablesNot needed
SCIM org scopingorganizationId required, userId removed, providerKey addedNo, reclaim old rows
Organization team countersteam.memberCount and teamMember.membershipKey columnsNot needed
SCIM groupsNew scimGroup, scimGroupMember, scimGroupRole, and scimGroupRoleGrant tablesNot 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 }) with signIn.social({ provider }).
  • Replace oauth2.link() with linkSocial().
  • Update your provider callback URL from /api/auth/oauth2/callback/:id to /api/auth/callback/:id.
  • Remove genericOAuthClient() from your client plugins and use the standard social client APIs.
  • PKCE now defaults to on. Set pkce: false only for a provider that rejects it.
  • Remove issuer and requireIssuerValidation; issuer validation is now automatic.
  • authorizationUrlParams and tokenUrlParams now 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 validAudiences into resources.
  • Link clients to specific resources through oauthClientResource or registration.
  • Run generate and migrate to 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:

  1. Install @better-auth/mcp and update imports: the server plugin and helpers from @better-auth/mcp, the client and adapters from @better-auth/mcp/client and @better-auth/mcp/client/adapters.
  2. Add the jwt() plugin, which is now required.
  3. Move options that were nested under oidcConfig up to the top level of mcp({ ... }), and add a resource identifier, for example resource: "https://api.example.com/mcp".
  4. Rename withMcpAuth to requireMcpAuth, and createMcpAuthClient to createMcpResourceClient.
  5. 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 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_uri that 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-store so proxies and browsers do not cache them.
  • userinfo rejects bad tokens: an invalid access token at the userinfo endpoint returns 401 invalid_token with a WWW-Authenticate header.
  • OAuth authorize error redirect: a missing response_type now redirects the error to the verified client redirect_uri instead of a generic error.
  • Drizzle affected-row validation: the Drizzle adapter throws on an invalid affected-row count instead of returning 0.
  • organization.updateTeam immutable fields: id, createdAt, and updatedAt are no longer accepted in the request body.
  • updateMemberRole ordering: role-existence validation now runs after authorization checks.
  • CLI generate --output to 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_token cookie.
  • 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_grant instead of 401 invalid_client or invalid_request.
  • Sign-out hooks with external session stores: session.delete hooks now run on sign-out even with secondaryStorage and preserveSessionInDatabase.
  • 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:

Terminal
npx auth@latest generate
npx auth@latest migrate

Then complete the manual data step if it applies:

  • Reclaim SCIM connections: delete or assign an organizationId to 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.

On this page

Before you upgrade
As a login client
Generic OAuth is rebuilt on the social-provider path
Identity tokens go through one verifier
Electron requires modern PKCE
generateState() signature changed
OAuth callback error code renamed
Google One Tap signs in the right person
Scopes are kept across logins
Discovery providers verify their identity tokens
Signed-assertion login validates at startup
Anonymous account linking works in mobile and in-app browsers
As an identity provider
Protected resources replace the audience list
Token target is locked to the login
DPoP renames the token verifier
Sign-out revokes session tokens
Custom ID-token claims cannot override protocol claims
ID tokens drop profile and email scope claims
jwt.sign callbacks must match the configured alg
/oauth2/revoke rejects valid JWT access tokens
max_age is enforced
Client creation returns 201
Unauthenticated registration keeps the client's auth method
Registration requires reciprocal response and grant types
OAuth endpoints return standard error envelopes
Introspection returns consistent claims
UserInfo accepts a bearer token in the form body
Client authentication is tied to the grant
Server-side OAuth requests refuse redirects
PKCE requirements for confidential and OIDC clients
Authorize accepts form-encoded requests and rejects request objects
Refresh-token retries can be tolerated
If you extended the OAuth provider by hand
The old oidcProvider plugin is removed
MCP
MCP moves to its own package
Enterprise SSO
IdP-initiated SAML is off by default
SAML certificates can be a list
SAML configuration is simplified
SCIM writes are safer
SCIM connections are scoped to an organization
/sso/update-provider rejects partial mappings
SCIM gains durable group resources
OIDC SSO works on Cloudflare Workers
Stripe
Organization subscriptions require organization.enabled
onSubscriptionCancel event is required
Behind a proxy
Forwarded headers are not trusted by default
IdP redirects and DPoP need your canonical origin
Custom adapters and storage
Database adapters must implement incrementOne and consumeOne
Secondary storage must implement increment and getAndDelete
Rate-limit storage uses consume
Default Drizzle schema uses singular relation keys
getIp is renamed getIP
Captcha
Captcha matches full paths
Two-factor and passwordless security
Two-factor enableTwoFactor returns a discriminated response
Magic-link and email-OTP sign-in can clear unproven credentials
Behavior changes worth noticing
Upgrade checklist
Tracking prereleases