What’s New

Better Auth

Frameworks & Libraries

Framework-agnostic authentication and authorization for TypeScript.

Latest v1.6.25 · by Better AuthWebsitebetter-auth/better-auth

Changelog

v1.6.25

Fixed 4
  • Fixed Apple OAuth not sending the PKCE code challenge during authorization, causing token exchange failures
  • Fixed Google One Tap creating new users when sign-up was disabled on the Google provider
  • Fixed $fetch and $store not being exposed on the Solid client
  • Fixed internal adapter queries being routed to the wrong table when a built-in table's modelName was set to another table's schema key
better-auth
Bug Fixes
  • Fixed Apple OAuth not sending the PKCE code challenge during authorization, causing token exchange failures (#10294)
  • Fixed Google One Tap creating new users when sign-up was disabled on the Google provider (#10479)
  • Fixed $fetch and $store not being exposed on the Solid client (#10444)
  • Fixed internal adapter queries being routed to the wrong table when a built-in table's modelName was set to another table's schema key (e.g. user.modelName = "account").

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@birkskyum, @jsj, @krish-vachhani

Full changelog: v1.6.24...v1.6.25

v1.7.0-rc.2

Pre-release
Added 5
  • Add ctx parameter to verifyIdToken
  • Add compound table indexes to database
  • Add beforeStoreCookie option to last-login-method plugin for GDPR compliance
  • Add getOrganization method for metadata-only fetches in organization plugin
  • Add transactional OIDC user resolution to SSO
Changed 12
  • Move joins configuration from experimental.joins to advanced.database.joins
  • Rename Account.accountId to Account.providerAccountId and make Account.issuer required
  • Change account-specific APIs to select Account.id through accountId parameter
  • Use token and provider-profile APIs with useAccountCookie option for signed account cookie selection
  • Use local:credential as credential accounts provider identity
  • OAuth provider identity now comes from raw verified profiles with OIDC using sub and plain OAuth using id
Fixed 3
  • Add no-cache cache control headers to get-session endpoint
  • Recognize BIGINT as valid number type for SQLite in migrations
  • Handle request clone failures in callbacks
Removed 2
  • Remove mapping.id from OIDC and SAML configurations
  • Remove SCIM configuration, client APIs, database schema, and organization-backed Group model
better-auth
❗ Breaking Changes
  • chore!: move joins to advanced.database.joins (#10359)

    If you previously set experimental: { joins: true }, update your config to:

    advanced: {
      database: {
        joins: true,
      },
    }
    

    Adapters that support native joins use them when enabled. If an adapter cannot return joined data for a query, Better Auth falls back to additional queries and combines the results. Drizzle and Prisma users should ensure their schema includes the required relations (npx auth@latest generate).

  • feat(auth)!: scope accounts by issuer (#10403)

    This release is breaking. Account.accountId is renamed to Account.providerAccountId, and Account.issuer is required. Account-specific APIs select the local Account.id through accountId; token and provider-profile APIs can instead select the signed account cookie with useAccountCookie: true. Credential accounts use local:credential and the linked user's stable id as their provider identity.

    OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses sub, plain OAuth uses id, and providers can declare accountSubject for another immutable field; Better Auth no longer switches between sub and id at runtime. getUserInfo().user no longer carries provider identity, and mapProfileToUser cannot return id. Read the selected identity from accountInfo.account.providerAccountId instead of accountInfo.user.id. The generic microsoftEntraId helper now requires a concrete tenant GUID; use the built-in Microsoft provider for multi-tenant authorities.

    SSO account subjects are now protocol-defined. OIDC uses the verified sub claim, and SAML uses the signed NameID; mapping.id is removed from both configurations. A manual SAML configuration without metadata XML must set idpMetadata.entityID, because samlConfig.issuer identifies the service provider and no longer acts as the IdP identity.

    Apply the reviewed account-identity backfill in the Better Auth 1.7 upgrade guide before deploying. The generated schema migration cannot assign trusted issuers or resolve existing identity collisions automatically.

  • feat(scim)!: decouple provisioning from the organization plugin (#10390)

    This replaces the previous SCIM configuration, client APIs, database schema, and organization-backed Group model. Existing SCIM installations cannot migrate provisioning state in place. Follow the SCIM cutover in the 1.7 upgrade guide, including full directory reprovisioning, before resuming traffic.

    Deferred database side effects now run only after a successful transaction. A rolled-back User update no longer refreshes its cached profile, and a rolled-back bulk session revocation no longer invalidates sessions.

Features
  • feat: add ctx to verifyIdToken (#10376)
  • feat(db): add compound table indexes (#10402)
  • feat(last-login-method): beforeStoreCookie option for GDPR compliance (#5753)
  • feat(organization): add getOrganization for metadata-only fetches (#10397)
  • feat(sso): add transactional OIDC user resolution (#10473)
Bug Fixes
  • chore: widen drizzle-kit peer dependency range (#10299)
  • fix: get-session should have no-cache cache control headers (#10222)
  • fix: recognize BIGINT as valid number type for SQLite in migrations. (#10316)
  • fix(auth): handle request clone failures in callbacks (#10336)
  • fix(client): preserve null in useSession().data type with throw:true (#9787)
  • fix(client): restore auth query lifecycle after remount (#10379)
  • fix(cookies): tighten CookieAttributes index signature type (#10441) (#10442)
  • fix(core): dedup request-state AsyncLocalStorage init to fix intermittent "No request state found" (#9862)
  • fix(core): resolve user.modelName collisions in references and adapter (#10235)
  • fix(db): avoid duplicate unique indexes in kysely migrations (#10357)
  • fix(magic-link, email-otp): force-validate Origin on cookieless send endpoints (#10368)
  • fix(mcp): expose remote auth challenge headers (#10290)
  • fix(open-api): include plugin user fields on sign-up/update bodies (#10453)
  • fix(organization): apply membershipLimit to listMembers user fetch (#10342)
  • fix(organization): let the database generate invitation ids (#10040)
  • fix(siwe): issue addressless nonces (#10234)

For detailed changes, see CHANGELOG

@better-auth/core
❗ Breaking Changes
  • chore!: move joins to advanced.database.joins (#10359)

    If you previously set experimental: { joins: true }, update your config to:

    advanced: {
      database: {
        joins: true,
      },
    }
    

    Adapters that support native joins use them when enabled. If an adapter cannot return joined data for a query, Better Auth falls back to additional queries and combines the results. Drizzle and Prisma users should ensure their schema includes the required relations (npx auth@latest generate).

  • feat(auth)!: scope accounts by issuer (#10403)

    This release is breaking. Account.accountId is renamed to Account.providerAccountId, and Account.issuer is required. Account-specific APIs select the local Account.id through accountId; token and provider-profile APIs can instead select the signed account cookie with useAccountCookie: true. Credential accounts use local:credential and the linked user's stable id as their provider identity.

    OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses sub, plain OAuth uses id, and providers can declare accountSubject for another immutable field; Better Auth no longer switches between sub and id at runtime. getUserInfo().user no longer carries provider identity, and mapProfileToUser cannot return id. Read the selected identity from accountInfo.account.providerAccountId instead of accountInfo.user.id. The generic microsoftEntraId helper now requires a concrete tenant GUID; use the built-in Microsoft provider for multi-tenant authorities.

    SSO account subjects are now protocol-defined. OIDC uses the verified sub claim, and SAML uses the signed NameID; mapping.id is removed from both configurations. A manual SAML configuration without metadata XML must set idpMetadata.entityID, because samlConfig.issuer identifies the service provider and no longer acts as the IdP identity.

    Apply the reviewed account-identity backfill in the Better Auth 1.7 upgrade guide before deploying. The generated schema migration cannot assign trusted issuers or resolve existing identity collisions automatically.

  • feat(scim)!: decouple provisioning from the organization plugin (#10390)

    This replaces the previous SCIM configuration, client APIs, database schema, and organization-backed Group model. Existing SCIM installations cannot migrate provisioning state in place. Follow the SCIM cutover in the 1.7 upgrade guide, including full directory reprovisioning, before resuming traffic.

    Deferred database side effects now run only after a successful transaction. A rolled-back User update no longer refreshes its cached profile, and a rolled-back bulk session revocation no longer invalidates sessions.

Features
  • feat(db): add compound table indexes (#10402)
  • feat(sso): add transactional OIDC user resolution (#10473)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter
❗ Breaking Changes
  • chore!: move joins to advanced.database.joins (#10359)

    If you previously set experimental: { joins: true }, update your config to:

    advanced: {
      database: {
        joins: true,
      },
    }
    

    Adapters that support native joins use them when enabled. If an adapter cannot return joined data for a query, Better Auth falls back to additional queries and combines the results. Drizzle and Prisma users should ensure their schema includes the required relations (npx auth@latest generate).

Features
  • feat(db): add compound table indexes (#10402)
  • feat(drizzle): generate drizzle schema with schema namespace (#7169)

For detailed changes, see CHANGELOG

@better-auth/sso
❗ Breaking Changes
  • feat(auth)!: scope accounts by issuer (#10403)

    This release is breaking. Account.accountId is renamed to Account.providerAccountId, and Account.issuer is required. Account-specific APIs select the local Account.id through accountId; token and provider-profile APIs can instead select the signed account cookie with useAccountCookie: true. Credential accounts use local:credential and the linked user's stable id as their provider identity.

    OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses sub, plain OAuth uses id, and providers can declare accountSubject for another immutable field; Better Auth no longer switches between sub and id at runtime. getUserInfo().user no longer carries provider identity, and mapProfileToUser cannot return id. Read the selected identity from accountInfo.account.providerAccountId instead of accountInfo.user.id. The generic microsoftEntraId helper now requires a concrete tenant GUID; use the built-in Microsoft provider for multi-tenant authorities.

    SSO account subjects are now protocol-defined. OIDC uses the verified sub claim, and SAML uses the signed NameID; mapping.id is removed from both configurations. A manual SAML configuration without metadata XML must set idpMetadata.entityID, because samlConfig.issuer identifies the service provider and no longer acts as the IdP identity.

    Apply the reviewed account-identity backfill in the Better Auth 1.7 upgrade guide before deploying. The generated schema migration cannot assign trusted issuers or resolve existing identity collisions automatically.

Features
  • feat(sso): add transactional OIDC user resolution (#10473)
Bug Fixes
  • fix(sso): redirect idp initiated saml flows in split origin deployments (#10388)

For detailed changes, see CHANGELOG

@better-auth/scim
❗ Breaking Changes
  • feat(scim)!: decouple provisioning from the organization plugin (#10390)

    This replaces the previous SCIM configuration, client APIs, database schema, and organization-backed Group model. Existing SCIM installations cannot migrate provisioning state in place. Follow the SCIM cutover in the 1.7 upgrade guide, including full directory reprovisioning, before resuming traffic.

    Deferred database side effects now run only after a successful transaction. A rolled-back User update no longer refreshes its cached profile, and a rolled-back bulk session revocation no longer invalidates sessions.

Features
  • feat(scim): expose active provisioned user links (#10474)

For detailed changes, see CHANGELOG

auth
Features
  • feat(db): add compound table indexes (#10402)
Bug Fixes
  • fix: stub SvelteKit's explicit-environment-variables modules (#10221)
  • fix(cli): avoid duplicate unique indexes in drizzle schema (#10333)
  • fix(cli): disambiguate Drizzle relations with relationName (#10352)
  • fix(cli): recover when auth generate's config self-imports its own output (#10302)

For detailed changes, see CHANGELOG

@better-auth/electron
Bug Fixes
  • fix(electron): forward each Set-Cookie from /electron/init-oauth-proxy individually (#9672)

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter
Bug Fixes
  • fix(kysely-adapter): restore local migration constants (#10377)

For detailed changes, see CHANGELOG

@better-auth/mongo-adapter
Features
  • feat(db): add compound table indexes (#10402)

For detailed changes, see CHANGELOG

@better-auth/oauth-provider
Bug Fixes
  • fix(oauth-provider): defer logout effects until commit (#10472)

For detailed changes, see CHANGELOG

@better-auth/stripe
Bug Fixes
  • fix(organization): pass endpoint context to organization delete hooks (#10190)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@akshatmalik-bruh, @ayushman46, @c-nicol, @gaurav-init, @gaurav0107, @GautamBytes, @gustavovalverde, @momomuchu, @OrangeManLi, @paoloricciuti, @ping-maxwell, @shiminshen, @swithek, @Tushar-Khandelwal-2004, @vinay-oppuri

Full changelog: v1.7.0-rc.1...v1.7.0-rc.2

v1.6.24

Added 2
  • Request context (ctx) as a third argument to verifyIdToken, enabling custom ID token verifiers to read request headers
  • beforeStoreCookie option to the last-login-method plugin for GDPR compliance
Fixed 19
  • get-session endpoint to include no-store cache control headers, preventing stale session data from being served
  • SQLite migration diffs to recognize BIGINT as a valid number type, preventing spurious pending changes on rate limiter columns
  • Auth requests failing when request cloning throws an error inside verification callbacks
  • useSession({ throw: true }) incorrectly excluding null from its data type
  • Auth query revalidation and signal listeners not being restored after a client component remounts
  • CookieAttributes index signature type to be more precise
better-auth
Features
  • Added request context (ctx) as a third argument to verifyIdToken, enabling custom ID token verifiers to read request headers (#10376)
  • Added beforeStoreCookie option to the last-login-method plugin for GDPR compliance (#5753)
Bug Fixes
  • Replaced flaky MongoDB where-coercion integration test with a direct unit test for more reliable test runs (#10369)
  • Fixed the get-session endpoint to include no-store cache control headers, preventing stale session data from being served (#10222)
  • Fixed SQLite migration diffs to recognize BIGINT as a valid number type, preventing spurious pending changes on rate limiter columns (#10316)
  • Fixed auth requests failing when request cloning throws an error inside verification callbacks (#10336)
  • Fixed useSession({ throw: true }) incorrectly excluding null from its data type (#9787)
  • Fixed auth query revalidation and signal listeners not being restored after a client component remounts (#10379)
  • Fixed the CookieAttributes index signature type to be more precise (#10442)
  • Fixed silent misrouting of adapter queries when user.modelName was set to a value that collides with another schema key (#10235)
  • Fixed Kysely migration generation producing duplicate indexes for fields marked both unique and index (#10357)
  • Fixed magic-link and email-OTP send endpoints to validate the Origin header on cookieless requests, preventing cross-origin abuse (#10368)
  • Fixed remote MCP auth 401 challenge headers being hidden from browser clients due to missing CORS exposure (#10290)
  • Fixed OpenAPI schema to include plugin user fields (such as username and displayUsername) in /sign-up/email and /update-user request bodies (#10453)
  • Fixed organization.listMembers failing with "User not found for member" for organizations with more than ~100 members (#10342)
  • Fixed organization invitations to use database-generated IDs when advanced.database.generateId is configured, matching the behavior of other models (#10040)
  • Fixed getDefaultModelName to prefer exact schema key matches over modelName aliases, preventing adapter queries from being misrouted when a built-in table's name collides with another schema key

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Fixed SvelteKit builds by stubbing explicit-environment-variables modules (#10221)
  • Fixed Drizzle schema generation producing duplicate indexes for fields marked both unique and index (#10333)
  • Fixed Drizzle schema generation for tables with multiple foreign keys to the same model by adding disambiguating relationName values (#10352)
  • Fixed auth generate failing when the config file imports the not-yet-generated output file (e.g. on a Convex first run) (#10302)

For detailed changes, see CHANGELOG

@better-auth/electron
Bug Fixes
  • Updated compatibility testing to include Electron 43 (peer range unchanged at >=36.0.0) (#10440)
  • Fixed /electron/init-oauth-proxy forwarding multiple Set-Cookie headers as a single comma-joined string, which caused the browser to drop the transfer-token cookie during OAuth handoff (#9672)

For detailed changes, see CHANGELOG

@better-auth/core
Bug Fixes
  • Fixed an intermittent "No request state found" error caused by a race condition in AsyncLocalStorage initialization on serverless cold starts (e.g. Cloudflare Workers) (#9862)

For detailed changes, see CHANGELOG

@better-auth/sso
Bug Fixes
  • Fixed IdP-initiated SAML sign-ins in split-origin deployments to redirect users to the configured application URL instead of the authentication server, using idpInitiatedCallbackUrl (#10388)

For detailed changes, see CHANGELOG

@better-auth/stripe
Bug Fixes
  • Fixed beforeDeleteOrganization and afterDeleteOrganization hooks not receiving the endpoint context as the second argument (#10190)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@akshatmalik-bruh, @ayushman46, @c-nicol, @gaurav-init, @gaurav0107, @GautamBytes, @momomuchu, @OrangeManLi, @paoloricciuti, @ping-maxwell, @shiminshen, @swithek, @Tushar-Khandelwal-2004, @vinay-oppuri

Full changelog: v1.6.23...v1.6.24

v1.7.0-rc.1

Pre-release
Added 1
  • Add Yandex as a supported OAuth social provider
Fixed 3
  • Fix auth migrate to no longer abort when adding required or unique columns to an existing table
  • Fix affected row counting for D1 and postgres-js adapters
  • Fix string default values to be properly escaped in generated Drizzle schema
better-auth
Features
  • Added Yandex as a supported OAuth social provider (#9138)
Bug Fixes
  • Fixed auth migrate to no longer abort when adding required or unique columns to an existing table (#10293)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter
Bug Fixes
  • Fixed affected row counting for D1 and postgres-js adapters (#10257)

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Fixed string default values to be properly escaped in generated Drizzle schema (#10259)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@bytaesu, @gustavovalverde, @vladflotsky

Full changelog: v1.7.0-rc.0...v1.7.0-rc.1

v1.6.23

Added 1
  • Add Yandex as a social OAuth provider
Fixed 3
  • Fix affected row counting for D1 and postgres-js adapters
  • Fix organization subscription actions (cancel, upgrade, restore, and the billing portal) that could act on the wrong organization
  • Fix string default values not being properly escaped in the generated Drizzle schema
better-auth
Features
  • Added Yandex as a social OAuth provider (#9138)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter
Bug Fixes
  • Fixed affected row counting for D1 and postgres-js adapters (#10257)

For detailed changes, see CHANGELOG

@better-auth/stripe
Bug Fixes
  • Fixed organization subscription actions (cancel, upgrade, restore, and the billing portal) that could act on the wrong organization.

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Fixed string default values not being properly escaped in the generated Drizzle schema (#10259)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@bytaesu, @vladflotsky

Full changelog: v1.6.22...v1.6.23

v1.7.0-rc.0

Pre-release
Changed 19
  • Support wildcard endpoint matching in captcha
  • Ship MCP as its own package built on the OAuth provider, renaming route helper to requireMcpAuth and remote client to createMcpResourceClient
  • Add OIDC back-channel logout to oauth-provider, notifying Relying Parties when a user's session ends and invalidating access tokens immediately
  • Revoke refresh tokens without offline_access on session end while preserving offline_access refresh tokens for long-lived API access
  • Add backchannel_logout_supported and backchannel_logout_session_supported to OIDC discovery endpoints when JWT plugin is enabled
  • Model OAuth protected resources explicitly by removing validAudiences and moving resource identifiers to resources configuration
better-auth
❗ Breaking Changes
  • feat(captcha)!: support wildcard endpoint matching (#10004)

  • feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)

    The route helper is renamed requireMcpAuth (was withMcpAuth), and the remote client is createMcpResourceClient (was createMcpAuthClient). requireMcpAuth verifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.

    To migrate, install @better-auth/mcp, add the jwt() plugin (now required for token signing), and move options that were nested under oidcConfig to flat options on mcp({ ... }). The database models change: oauthApplication becomes oauthClient, with new oauthRefreshToken and oauthClientAssertion tables. Regenerate or migrate your schema with npx auth migrate or npx auth generate.

  • feat(oauth-provider)!: add OIDC back-channel logout (#9304)

    When a user's session ends at the OP (sign-out, /oauth2/end-session, admin revoke, ban), @better-auth/oauth-provider now notifies every Relying Party that holds tokens for that session. The user's API access is cut off right away, instead of access tokens staying usable until their own TTL. Each client opts in by registering a backchannel_logout_uri (and optionally backchannel_logout_session_required) via DCR or the admin client-create endpoint. The provider signs a logout+jwt Logout Token per client and POSTs it to that client in parallel, with a short per-RP timeout.

    Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns { active: false }, and /oauth2/userinfo rejects it with invalid_token. Previously the token stayed active until its own TTL. If you relied on access tokens outliving the user's session, that no longer holds.

    Refresh tokens without offline_access are revoked on session end; offline_access refresh tokens are preserved so long-lived API access can survive the browser session (OIDC Back-Channel Logout 1.0 §2.7). Access-token invalidation on session end is an additional OP hardening choice beyond §2.7, enforced by session liveness, so it holds even when the JWT plugin is disabled.

    Delivery runs through the host's background task handler when one is configured (Vercel waitUntil, Cloudflare ctx.waitUntil); without a handler it completes inline so notifications are not lost on request teardown. Configure advanced.backgroundTasks.handler on serverless runtimes to keep sign-out fast.

    Discovery at /.well-known/openid-configuration and /.well-known/oauth-authorization-server advertises backchannel_logout_supported: true and backchannel_logout_session_supported: true when the JWT plugin is enabled. Registering a backchannel_logout_uri rejects fragments, non-http(s) schemes, and non-HTTPS targets on confidential clients. Its SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, now also covers a private_key_jwt client's jwks_uri.

    Schema changes on @better-auth/oauth-provider:

    • oauthClient.backchannelLogoutUri: string | null
    • oauthClient.backchannelLogoutSessionRequired: boolean
    • oauthAccessToken.revoked: Date | null

    better-auth's signJWT gains an optional header argument, forwarded to custom remote signers. JWT profiles that need an explicit media type, such as typ: "logout+jwt", can now set it without reaching for the low-level signing primitives.

  • feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)

    validAudiences is removed. Move each existing resource identifier into resources; link clients that should be limited to specific resources through oauthClientResource or Dynamic Client Registration resources.

    Access-token issuance now applies resource policy to the requested RFC 8707 resource values. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emits jti, and keeps repeated resource form parameters.

    Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource refreshTokenTtl longer than refreshTokenExpiresIn will see refresh tokens expire at the provider default instead of the longer resource value.

    JWT signing can now honor per-resource pins. signJWT() accepts signingKeyId and signingAlgorithm; JWKS adapters expose getKeyById() and getLatestKeyByAlg(). The jwks table adds nullable alg and crv columns, and keyPairConfigs can provision multiple algorithms in one keyring.

    After upgrading, run npx @better-auth/cli generate and apply the migration before deploying. The migration adds oauthResource, oauthClientResource, and the new jwks columns. Without it, resources using signingAlgorithm cannot find matching keys.

    Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.

    @better-auth/mcp now requires an explicit resource option. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existing mcp({ loginPage, consentPage }) setups should add a protected MCP resource identifier, for example resource: "https://api.example.com/mcp".

  • feat(two-factor)!: add OTP enablement and discriminated response (#9057)

    enableTwoFactor now accepts a method parameter ("otp" | "totp", default "totp") and returns a discriminated response with a method field.

    method: "otp"
    • Sets twoFactorEnabled: true immediately.
    • Returns { method: "otp" }.
    • Requires otpOptions.sendOTP to be configured on the server; rejects with OTP_NOT_CONFIGURED otherwise.
    method: "totp" (default)
    • Returns { method: "totp", totpURI, backupCodes }.
    • Rejects with TOTP_NOT_CONFIGURED if totpOptions.disable is set.
    Breaking changes
    • Removed skipVerificationOnEnable: use method: "otp" for immediate activation, or the standard TOTP verification flow.
    • Response shape changed: enableTwoFactor includes a method field in the response ("otp" or "totp").
  • fix(auth)!: ignore x-forwarded headers by default on dynamic baseURL (#9134)

    Requests using baseURL: { allowedHosts } now resolve the auth origin from Host by default, so forwarded headers cannot select another allowed host unless trusted proxy headers are enabled.

    Breaking change: if your proxy exposes the public hostname only through x-forwarded-host, set advanced.trustedProxyHeaders: true. Deployments where the proxy rewrites Host to the public hostname (nginx default, Vercel, Cloudflare, and Netlify) are unaffected.

    Migration:

    betterAuth({
      baseURL: { allowedHosts: [...] },
      advanced: {
        trustedProxyHeaders: true,
      },
    });
    
  • fix(electron)!: enforce S256 PKCE and harden origin checks (#9645)

    The Electron sign-in flow now mandates PKCE S256. Plain PKCE is rejected: the code_challenge_method parameter is gone and every authorization code is verified by hashing the verifier with SHA-256. The server no longer trusts an electron-origin header to set the request Origin. The Electron client now sends a real Origin (for example myapp:/), so upgrade the @better-auth/electron client and server together and make sure your app's scheme is in trustedOrigins. The unused disableOriginOverride option is removed.

    Custom-scheme entries in trustedOrigins now match by scheme and authority instead of string prefix. A host-less entry such as myapp:// or exp:// still trusts every host of that scheme, but a host-bearing entry such as myapp://callback matches that host exactly, so it is no longer satisfied by myapp://callback.attacker.tld.

  • fix(one-tap)!: require client id for audience validation (#10036)

  • refactor!: remove deprecated oidc-provider plugin (#10031)

  • refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)

    Breaking changes:

    • signIn.oauth2({ providerId }) replaced by signIn.social({ provider })
    • oauth2.link() replaced by linkSocial()
    • Callback URL changed from /api/auth/oauth2/callback/:id to /api/auth/callback/:id
    • genericOAuthClient() removed; generic OAuth providers now use the standard social client APIs
    • pkce defaults to true (was false); set pkce: false for providers that reject PKCE
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discovery
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>
  • refactor(oauth)!: verify provider id_tokens with a single shared verifier (#9828)

    Client-submitted id_token sign-in (signIn.social({ idToken }) and account linking) is verified by one function instead of a per-provider verifyIdToken method. Each provider declares an idToken config with a JWKS source, issuer, and audience, and the core verifier runs the signature, issuer, audience, and nonce checks. A provider that declares no config rejects the client id_token path.

    PayPal previously accepted any decodable id_token without verifying its signature. PayPal derives identity from the access token, so it now declares no idToken config, and the client id_token path returns ID_TOKEN_NOT_SUPPORTED. PayPal sign-in through the redirect flow is unchanged.

    Custom providers that implement UpstreamProvider directly replace the removed verifyIdToken method with an idToken config:

    idToken: {
    	jwks: createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json")),
    	issuer: "https://issuer.example",
    	audience: clientId,
    },
    

    For verification that cannot use a local JWKS, pass idToken: { verify: async (token, nonce) => boolean }. The verifyIdToken and disableIdTokenSignIn provider options are unchanged.

Features
  • feat: add clientAssertion support to the Microsoft Entra ID social provider (#9898)
  • feat: make Auth instance fetchable (#9431)
  • feat(auth): add per-provider requireEmailVerification for social sign-in (#9929)
  • feat(auth): add user.validateUserInfo provisioning gate (#9864)
  • feat(client): add hydrateSession for SSR session hydration (#8733)
  • feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
  • feat(generic-oauth): forward refreshTokenParams to token endpoint (#9948)
  • feat(generic-oauth): verify discovery id_tokens and enable id_token sign-in (#9966)
  • feat(oauth-provider): add DPoP support (#10039)
  • feat(oauth-provider): compute at_hash in id tokens per OIDC Core §3.1.3.6 (#9079)
  • feat(oauth): add private_key_jwt client authentication (RFC 7523) (#8836)
  • feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
  • feat(oauth): per-request additionalParams and loginHint (#9305)
  • feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
  • feat(org): allow passing userId and organizationId to listUserTeams API (#8977)
  • feat(phone-number): add server-side OTP consumption API (#9766)
  • feat(session): support JWKS-backed JWT session cookie cache (#8931)
  • feat(username): add immutable username option (#9240)
Bug Fixes
  • Bundled dependencies were refreshed to their latest compatible releases, including jose, nanostores, the noble crypto packages, and SimpleWebAuthn. These updates are backward compatible and require no changes to existing projects.
  • fix(generic-oauth): bind id token nonce in redirect flow (#10095)
  • fix(oauth): create new oauth account in transaction (#10125)
  • fix(oauth): derive redirect URI from per-request baseURL (#10127)
  • fix(oauth): preserve account.scope across re-auth and refresh (#10128)
  • fix(oauth): preserve user on null profile override (#10124)
  • fix(session): fire session-delete hooks for preserved sessions on secondaryStorage (#9969)
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

For detailed changes, see CHANGELOG

@better-auth/oauth-provider
❗ Breaking Changes
  • feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)

    The route helper is renamed requireMcpAuth (was withMcpAuth), and the remote client is createMcpResourceClient (was createMcpAuthClient). requireMcpAuth verifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.

    To migrate, install @better-auth/mcp, add the jwt() plugin (now required for token signing), and move options that were nested under oidcConfig to flat options on mcp({ ... }). The database models change: oauthApplication becomes oauthClient, with new oauthRefreshToken and oauthClientAssertion tables. Regenerate or migrate your schema with npx auth migrate or npx auth generate.

  • feat(oauth-provider)!: add OIDC back-channel logout (#9304)

    When a user's session ends at the OP (sign-out, /oauth2/end-session, admin revoke, ban), @better-auth/oauth-provider now notifies every Relying Party that holds tokens for that session. The user's API access is cut off right away, instead of access tokens staying usable until their own TTL. Each client opts in by registering a backchannel_logout_uri (and optionally backchannel_logout_session_required) via DCR or the admin client-create endpoint. The provider signs a logout+jwt Logout Token per client and POSTs it to that client in parallel, with a short per-RP timeout.

    Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns { active: false }, and /oauth2/userinfo rejects it with invalid_token. Previously the token stayed active until its own TTL. If you relied on access tokens outliving the user's session, that no longer holds.

    Refresh tokens without offline_access are revoked on session end; offline_access refresh tokens are preserved so long-lived API access can survive the browser session (OIDC Back-Channel Logout 1.0 §2.7). Access-token invalidation on session end is an additional OP hardening choice beyond §2.7, enforced by session liveness, so it holds even when the JWT plugin is disabled.

    Delivery runs through the host's background task handler when one is configured (Vercel waitUntil, Cloudflare ctx.waitUntil); without a handler it completes inline so notifications are not lost on request teardown. Configure advanced.backgroundTasks.handler on serverless runtimes to keep sign-out fast.

    Discovery at /.well-known/openid-configuration and /.well-known/oauth-authorization-server advertises backchannel_logout_supported: true and backchannel_logout_session_supported: true when the JWT plugin is enabled. Registering a backchannel_logout_uri rejects fragments, non-http(s) schemes, and non-HTTPS targets on confidential clients. Its SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, now also covers a private_key_jwt client's jwks_uri.

    Schema changes on @better-auth/oauth-provider:

    • oauthClient.backchannelLogoutUri: string | null
    • oauthClient.backchannelLogoutSessionRequired: boolean
    • oauthAccessToken.revoked: Date | null

    better-auth's signJWT gains an optional header argument, forwarded to custom remote signers. JWT profiles that need an explicit media type, such as typ: "logout+jwt", can now set it without reaching for the low-level signing primitives.

  • feat(oauth-provider)!: enforce max_age (#9936)

  • feat(oauth-provider)!: make id-token claim authority explicit (#10140)

    customIdTokenClaims, extension ID-token claims, and per-issuance idTokenClaims can no longer set OIDC/JWT protocol claims such as issuer, subject, audience, token lifetime, nonce, session or hash binding, auth_time, acr, amr, or azp. Namespaced custom claims still appear in ID tokens.

  • feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)

    validAudiences is removed. Move each existing resource identifier into resources; link clients that should be limited to specific resources through oauthClientResource or Dynamic Client Registration resources.

    Access-token issuance now applies resource policy to the requested RFC 8707 resource values. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emits jti, and keeps repeated resource form parameters.

    Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource refreshTokenTtl longer than refreshTokenExpiresIn will see refresh tokens expire at the provider default instead of the longer resource value.

    JWT signing can now honor per-resource pins. signJWT() accepts signingKeyId and signingAlgorithm; JWKS adapters expose getKeyById() and getLatestKeyByAlg(). The jwks table adds nullable alg and crv columns, and keyPairConfigs can provision multiple algorithms in one keyring.

    After upgrading, run npx @better-auth/cli generate and apply the migration before deploying. The migration adds oauthResource, oauthClientResource, and the new jwks columns. Without it, resources using signingAlgorithm cannot find matching keys.

    Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.

    @better-auth/mcp now requires an explicit resource option. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existing mcp({ loginPage, consentPage }) setups should add a protected MCP resource identifier, for example resource: "https://api.example.com/mcp".

  • fix(oauth-provider)!: bind client authentication to the issuing grant (#10063)

  • fix(oauth-provider)!: bind RFC 8707 resource indicators to the authorization grant (#9836)

    Breaking change: when the authorization includes a resource, the token and refresh requests may only narrow it. A request for a resource the authorization did not cover returns invalid_target. The customAccessTokenClaims callback now receives a resources array in place of the resource string.

    Migration: run the schema migration (npx @better-auth/cli migrate, or generate if you manage the schema yourself) to add the new resource columns.

  • fix(oauth-provider)!: return RFC-compliant error envelopes from validation failures (#9277)

    An internal createOAuthEndpoint wrapper now translates zod validation failures into the envelope required by RFC 6749 §5.2, 7009 §2.2.1, 7662 §2.3, and 7591 §3.2.2. Failing issues are routed per field:

    • an absent required value maps to errorCodesByField[name].missing or the endpoint's defaultError.
    • an unsupported value (unknown enum member) maps to errorCodesByField[name].invalid or defaultError.
    • any other failure (wrong type, duplicated query params, invalid format, refinement) maps to defaultError, so RFC 6749 §3.1 malformed requests emit the endpoint's default code regardless of field.

    All six OAuth endpoints (/oauth2/token, /oauth2/authorize, /oauth2/revoke, /oauth2/introspect, /oauth2/register, /oauth2/end-session) now return RFC-compliant errors for malformed requests. /oauth2/authorize validation failures redirect to the relying party with error, error_description, echoed state, and iss whenever client_id and redirect_uri resolve against the registered client; requests without a trusted RP fall back to the server error page.

    Additional RFC compliance fixes on the same endpoints:

    • /oauth2/revoke and /oauth2/introspect now ignore an unknown token_type_hint instead of rejecting it. RFC 7009 §2.2.1 and RFC 7662 §2.1 reserve unsupported_token_type for the token itself, not the hint value; servers MAY ignore unrecognized hints and search across supported token types.
    • /oauth2/authorize error redirects now respect OIDC Core 1.0 §5 response modes. Errors for response_type=token or id_token are delivered in the URL fragment per RFC 6749 §4.2.2.1; an explicit response_mode=query overrides the default.
  • refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)

    Breaking changes:

    • signIn.oauth2({ providerId }) replaced by signIn.social({ provider })
    • oauth2.link() replaced by linkSocial()
    • Callback URL changed from /api/auth/oauth2/callback/:id to /api/auth/callback/:id
    • genericOAuthClient() removed; generic OAuth providers now use the standard social client APIs
    • pkce defaults to true (was false); set pkce: false for providers that reject PKCE
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discovery
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>
Features
  • feat: add token endpoint client authentication (#9625)
  • feat(cimd): add Client ID Metadata Document plugin (#9159)
  • feat(oauth-provider): add DPoP support (#10039)
  • feat(oauth-provider): add extension surface (#10030)
  • feat(oauth-provider): add refresh token reuse interval (#10145)
  • feat(oauth-provider): allow confidential DCR clients without PKCE (#10146)
  • feat(oauth-provider): compute at_hash in id tokens per OIDC Core §3.1.3.6 (#9079)
  • feat(oauth-provider): consistent and audience-scoped token introspection (#10045)
  • feat(oauth-provider): expose sessionId to id_token claim contributors (#10113)
  • feat(oauth-provider): honor requested UserInfo claims via a claim registry (#10156)
  • feat(oauth-provider): support protected dynamic client registration (#10037)
  • feat(oauth): add private_key_jwt client authentication (RFC 7523) (#8836)
  • feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
  • feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
Bug Fixes
  • fix(oauth-provider): accept UserInfo form-body tokens (#10155)
  • fix(oauth-provider): allow nonce-bound offline access without PKCE (#10153)
  • fix(oauth-provider): challenge invalid userinfo tokens (#10068)
  • fix(oauth-provider): handle OIDC authorization request inputs (#10151)
  • fix(oauth-provider): keep OIDC scope claims on UserInfo (#10152)
  • fix(oauth-provider): make private_key_jwt jti single-use atomic across processes (#9964)
  • fix(oauth-provider): make redirect_uri conditional at the token endpoint (#10159)
  • fix(oauth-provider): preserve dcr client key metadata (#10144)
  • fix(oauth-provider): redirect missing response_type errors (#10149)
  • fix(oauth-provider): reject authorization code replay correctly (#10150)
  • fix(oauth-provider): report unsupported_token_type for JWT access-token revocation (#9970)
  • fix(oauth-provider): return invalid_grant for cross-client refresh tokens (#10154)
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

For detailed changes, see CHANGELOG

@better-auth/sso
❗ Breaking Changes
  • feat(sso)!: support multiple IdP signing certificates (#8805)

    SAML signing certificates now accept an array of PEM strings, so administrators can publish a new IdP cert alongside the old one and complete the rotation without forcing every active session to re-authenticate. Responses signed by any listed cert are accepted.

    samlConfig: {
        idpMetadata: {
            cert: [currentPem, nextPem],
        },
    }
    

    Both samlConfig.cert and samlConfig.idpMetadata.cert accept either a single PEM string or an array. When both are set, idpMetadata.cert wins.

    Breaking: response shape

    The management endpoints (getSSOProvider, listSSOProviders, updateSSOProvider) now return samlConfig.certificate as an array of parsed certificates in every case, even when a single cert is configured. The field is absent only when certs live inside idpMetadata.metadata. Update consumers to read an array; no more Array.isArray branching.

    Validation

    Registration now rejects SAML configs that supply no signing-cert source. samlify needs either an idpMetadata.metadata XML document (which embeds the certs) or an explicit PEM under cert or idpMetadata.cert. Configs missing both fail with CERT_SOURCE_MISSING.

    Fix

    SAML Single Logout could fail to decrypt encrypted LogoutResponse payloads because the IdP entity was constructed without privateKey, encPrivateKey, or encPrivateKeyPass on that code path. All three are now applied on every IdP construction.

  • fix(auth)!: harden validateUserInfo source contract (#9940)

  • fix(sso)!: harden SAML response validation (InResponseTo, Audience, SessionIndex) (#9055)

    Breaking Changes
    • allowIdpInitiated now defaults to false — IdP-initiated SSO (unsolicited SAML responses) is disabled by default. Set saml.allowIdpInitiated: true to restore the previous behavior. This aligns with the SAML2Int interoperability profile which recommends against IdP-initiated SSO due to its susceptibility to injection attacks.
    Bug Fixes
    • InResponseTo validation was completely non-functional — The code read extract.inResponseTo (always undefined) instead of samlify's actual path extract.response.inResponseTo. SP-initiated InResponseTo validation now works as intended in both ACS handlers.
    • Audience Restriction was never validated — SAML assertions issued for a different service provider were accepted without checking the <AudienceRestriction> element. Audience is now validated against the configured samlConfig.audience value per SAML 2.0 Core §2.5.1.
    • SessionIndex stored as object instead of string — samlify returns sessionIndex from login responses as { authnInstant, sessionNotOnOrAfter, sessionIndex }, but the code stored the whole object. SLO session-index comparisons always failed silently. The correct inner sessionIndex string is now extracted.
    Improvements
    • Extracted shared validateInResponseTo() and validateAudience() into packages/sso/src/saml/response-validation.ts, eliminating ~160 lines of duplicated validation logic between the two ACS handlers.
    • Fixed SAMLAssertionExtract type to match samlify's actual extractor output shape.
  • refactor(sso)!: remove callbackUrl, consolidate ACS endpoint, fix SLO (#9117)

    callbackUrl removed from samlConfig. The ACS URL is now always derived from your baseURL and providerId. Remove callbackUrl from your SAML provider configuration. The post-login redirect destination is set per sign-in via callbackURL in signIn.sso():

    await authClient.signIn.sso({
      providerId: "my-provider",
      callbackURL: "/dashboard",
    });
    

    /sso/saml2/callback/:providerId endpoint removed. Update your IdP's ACS URL to /sso/saml2/sp/acs/:providerId. This endpoint handles both GET and POST requests.

    spMetadata is now optional. You no longer need to pass spMetadata: {} when registering a provider. SP metadata is auto-generated from your configuration.

    Removed unused fields from SAMLConfig: decryptionPvk, additionalParams, idpMetadata.entityURL, idpMetadata.redirectURL. These were stored but never read. Remove them from your configuration if present.

    Bug fixes
    • Fix SLO SessionIndex matching: LogoutRequests with a SessionIndex were silently failing to delete the correct session.
    • Audience validation now defaults to the SP entity ID when audience is not configured, per SAML Core section 2.5.1.
    • Restore AllowCreate in AuthnRequests, required by IdPs that use JIT provisioning.
    • SP metadata endpoint now reflects actual SP capabilities (encryption, signing, SLO).
Features
  • feat(auth): add user.validateUserInfo provisioning gate (#9864)
  • feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
  • feat(oauth): add private_key_jwt client authentication (RFC 7523) (#8836)
  • feat(oauth): per-request additionalParams and loginHint (#9305)
  • feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
  • feat(sso): support additionalFields on ssoProvider (#9445)
Bug Fixes
  • fix(sso): reject OIDC endpoint redirects portably (#10072)
  • fix(sso): update samlify to 2.13.1 for signed-assertion XML injection (#9821)
  • fix(sso): upgrade samlify to 2.12.0 with XPath injection and XXE fixes (#9121)
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

For detailed changes, see CHANGELOG

@better-auth/mcp
❗ Breaking Changes
  • feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)

    The route helper is renamed requireMcpAuth (was withMcpAuth), and the remote client is createMcpResourceClient (was createMcpAuthClient). requireMcpAuth verifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.

    To migrate, install @better-auth/mcp, add the jwt() plugin (now required for token signing), and move options that were nested under oidcConfig to flat options on mcp({ ... }). The database models change: oauthApplication becomes oauthClient, with new oauthRefreshToken and oauthClientAssertion tables. Regenerate or migrate your schema with npx auth migrate or npx auth generate.

  • feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)

    validAudiences is removed. Move each existing resource identifier into resources; link clients that should be limited to specific resources through oauthClientResource or Dynamic Client Registration resources.

    Access-token issuance now applies resource policy to the requested RFC 8707 resource values. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emits jti, and keeps repeated resource form parameters.

    Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource refreshTokenTtl longer than refreshTokenExpiresIn will see refresh tokens expire at the provider default instead of the longer resource value.

    JWT signing can now honor per-resource pins. signJWT() accepts signingKeyId and signingAlgorithm; JWKS adapters expose getKeyById() and getLatestKeyByAlg(). The jwks table adds nullable alg and crv columns, and keyPairConfigs can provision multiple algorithms in one keyring.

    After upgrading, run npx @better-auth/cli generate and apply the migration before deploying. The migration adds oauthResource, oauthClientResource, and the new jwks columns. Without it, resources using signingAlgorithm cannot find matching keys.

    Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.

    @better-auth/mcp now requires an explicit resource option. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existing mcp({ loginPage, consentPage }) setups should add a protected MCP resource identifier, for example resource: "https://api.example.com/mcp".

Features
  • feat(oauth-provider): add DPoP support (#10039)
  • feat(oauth-provider): add refresh token reuse interval (#10145)

For detailed changes, see CHANGELOG

@better-auth/scim
❗ Breaking Changes
  • feat(scim)!: isolate provider connections by organization (#10249)

    SCIM-managed accounts now use namespaced provider IDs (scim:{organizationId}:{providerId} or scim:{providerId} for app-level static providers). Migrate only known SCIM-managed account rows before upgrading; leave non-SCIM accounts unchanged even when they share the same provider ID.

    Organization-scoped active: false now makes a user inactive in that organization while keeping SCIM group and team associations available for reactivation. Use DELETE to fully deprovision organization-scoped SCIM state.

    defaultSCIM has been replaced by staticProviders. linkExistingUsers.trustedDomains has been removed; use requireExistingOrgMembership, shouldLinkUser, or explicit true instead.

  • fix(scim)!: always bind personal SCIM connections to their creator (#9840)

    generateSCIMToken now records the creator's userId on every personal connection. The generate-token, list-provider-connections, get-provider-connection, and delete-provider-connection endpoints grant access only to that owner. Organization-scoped connections keep their existing behavior and continue to use organization membership and the configured requiredRole checks.

    This release is breaking. It removes the providerOwnership option, and owner binding can no longer be disabled. The scimProvider.userId column is now a permanent part of the schema, so run a migration after upgrading with npx auth migrate or npx auth generate.

    Connections created before this release carry no owner. Access now fails closed, so those connections are no longer reachable through the management endpoints, including token regeneration. Reclaim them at the database level: delete scimProvider rows that have neither organizationId nor userId, or set userId to the intended owner, then regenerate tokens as needed. Organization-scoped connections are not affected.

Features
  • feat(auth): add user.validateUserInfo provisioning gate (#9864)
  • feat(scim): add durable group resources (#10018)

For detailed changes, see CHANGELOG

@better-auth/electron
❗ Breaking Changes
  • fix(electron)!: enforce S256 PKCE and harden origin checks (#9645)

    The Electron sign-in flow now mandates PKCE S256. Plain PKCE is rejected: the code_challenge_method parameter is gone and every authorization code is verified by hashing the verifier with SHA-256. The server no longer trusts an electron-origin header to set the request Origin. The Electron client now sends a real Origin (for example myapp:/), so upgrade the @better-auth/electron client and server together and make sure your app's scheme is in trustedOrigins. The unused disableOriginOverride option is removed.

    Custom-scheme entries in trustedOrigins now match by scheme and authority instead of string prefix. A host-less entry such as myapp:// or exp:// still trusts every host of that scheme, but a host-bearing entry such as myapp://callback matches that host exactly, so it is no longer satisfied by myapp://callback.attacker.tld.

  • refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)

    Breaking changes:

    • signIn.oauth2({ providerId }) replaced by signIn.social({ provider })
    • oauth2.link() replaced by linkSocial()
    • Callback URL changed from /api/auth/oauth2/callback/:id to /api/auth/callback/:id
    • genericOAuthClient() removed; generic OAuth providers now use the standard social client APIs
    • pkce defaults to true (was false); set pkce: false for providers that reject PKCE
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discovery
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>

For detailed changes, see CHANGELOG

@better-auth/stripe
❗ Breaking Changes
  • fix(stripe)!: make onSubscriptionCancel.event required (#9531)
  • fix(stripe)!: remove optional marker from onSubscriptionCancel event (#9359)

For detailed changes, see CHANGELOG

@better-auth/core
❗ Breaking Changes
  • refactor(oauth)!: verify provider id_tokens with a single shared verifier (#9828)

    Client-submitted id_token sign-in (signIn.social({ idToken }) and account linking) is verified by one function instead of a per-provider verifyIdToken method. Each provider declares an idToken config with a JWKS source, issuer, and audience, and the core verifier runs the signature, issuer, audience, and nonce checks. A provider that declares no config rejects the client id_token path.

    PayPal previously accepted any decodable id_token without verifying its signature. PayPal derives identity from the access token, so it now declares no idToken config, and the client id_token path returns ID_TOKEN_NOT_SUPPORTED. PayPal sign-in through the redirect flow is unchanged.

    Custom providers that implement UpstreamProvider directly replace the removed verifyIdToken method with an idToken config:

    idToken: {
    	jwks: createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json")),
    	issuer: "https://issuer.example",
    	audience: clientId,
    },
    

    For verification that cannot use a local JWKS, pass idToken: { verify: async (token, nonce) => boolean }. The verifyIdToken and disableIdTokenSignIn provider options are unchanged.

Features
  • feat: add clientAssertion support to the Microsoft Entra ID social provider (#9898)
  • feat(auth): add per-provider requireEmailVerification for social sign-in (#9929)
  • feat(auth): add user.validateUserInfo provisioning gate (#9864)
  • feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
  • feat(generic-oauth): forward refreshTokenParams to token endpoint (#9948)
  • feat(google): add includeGrantedScopes option (#10129)
  • feat(oauth-provider): add DPoP support (#10039)
  • feat(oauth): add private_key_jwt client authentication (RFC 7523) (#8836)
  • feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
  • feat(oauth): per-request additionalParams and loginHint (#9305)
Bug Fixes
  • fix(cimd): route client_id SSRF checks through the shared host classifier (#10126)
  • fix(oauth): derive redirect URI from per-request baseURL (#10127)
  • fix(oauth): preserve account.scope across re-auth and refresh (#10128)
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

For detailed changes, see CHANGELOG

auth
❗ Breaking Changes
  • feat(oauth)!: accumulate granted scopes as grantedScopes string[] (#9825)
Features
  • feat(cli): add create-admin command (#9547)
Bug Fixes
  • refactor(cli): leverage c12 v4 resolveModule for auth config loading (#9477)
  • revert(oauth): remove granted scopes architecture (#10123)

For detailed changes, see CHANGELOG

@better-auth/api-key
❗ Breaking Changes
  • feat(auth)!: harden atomic state transitions (#10000)
Bug Fixes
  • chore: sync main to next (#9533)

For detailed changes, see CHANGELOG

@better-auth/expo
❗ Breaking Changes
  • refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)

    Breaking changes:

    • signIn.oauth2({ providerId }) replaced by signIn.social({ provider })
    • oauth2.link() replaced by linkSocial()
    • Callback URL changed from /api/auth/oauth2/callback/:id to /api/auth/callback/:id
    • genericOAuthClient() removed; generic OAuth providers now use the standard social client APIs
    • pkce defaults to true (was false); set pkce: false for providers that reject PKCE
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discovery
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>

For detailed changes, see CHANGELOG

@better-auth/cimd
Features
  • feat(cimd): add Client ID Metadata Document plugin (#9159)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter
Features
  • feat(drizzle-adapter): support Drizzle Relations v2 (#9489)

For detailed changes, see CHANGELOG

@better-auth/i18n
Features
  • feat(i18n): add built-in translations for 22 languages (#9157)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@adrianmxb, @app/better-release, @brentmitchell25, @bytaesu, @GautamBytes, @gustavovalverde, @ItalyPaleAle, @OscarCornish, @pi0, @ping-maxwell, @ruban-s, @sovetski, @yordis

Full changelog: v1.6.22...v1.7.0-rc.0

v1.7.0-beta.10

Pre-release
Fixed 20
  • Bundled dependencies were refreshed to their latest compatible releases, including jose, nanostores, the noble crypto packages, and SimpleWebAuthn
  • Rate limiting is now applied before plugin request handlers run
  • Unproven credentials are now revoked when signing in via magic link or email OTP
  • Account-linking logs are now routed through the configured logger
  • Admin authorization now uses authoritative session reads
  • TypeScript inference errors from inherited APIError properties have been resolved
better-auth
Bug Fixes
  • Bundled dependencies were refreshed to their latest compatible releases, including jose, nanostores, the noble crypto packages, and SimpleWebAuthn. These updates are backward compatible and require no changes to existing projects.
  • Fixed rate limiting to be applied before plugin request handlers run (#10191)
  • Fixed unproven credentials to be revoked when signing in via magic link or email OTP (#10239)
  • Fixed account-linking logs to be routed through the configured logger (#10121)
  • Fixed admin authorization to use authoritative session reads (#10187)
  • Fixed TypeScript inference errors by declaring inherited APIError properties (#8734)
  • Fixed server-side OAuth requests to no longer follow redirects (#10241)
  • Fixed the schema option in device authorization to be optional under Zod v4 (#9939)
  • Fixed hosted-domain validation to be applied consistently across all Google sign-in flows (#10197)
  • Fixed OAuth proxy to reject profile callbacks when OAuth state is missing or expired (#10183)
  • Fixed OAuth provider profiles to respect user input rules (#10196)
  • Fixed PayPal userinfo subject to be bound to the verified ID token subject (#10192)
  • Fixed refresh cookie Max-Age to be capped at the configured expiresIn value (#9621)
  • Fixed SIWE sign-in to reject when the provided email already belongs to another account (#10228)
  • Fixed TOTP and backup code verification to cap the number of allowed attempts (#10210)
  • Fixed username storage to only accept valid displayUsername fallbacks (#10182)

For detailed changes, see CHANGELOG

@better-auth/sso
Bug Fixes
  • Fixed SSO provider deletion to also remove associated linked account rows (#10224)
  • Fixed SSO provider domain verification to require DNS proof for every listed domain (#10227)
  • Fixed SAML SLO POST form action to be restricted to http and https schemes (#10225)
  • Fixed SAML response binding to be validated against the Service Provider configuration (#10226)

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Fixed disableMigration to be honored for plugin schema tables (#10198)
  • Fixed generated BETTER_AUTH_SECRET to use 32 characters instead of 16 (#10186)
  • Fixed two-factor verification to enforce account-level lockout after repeated failed attempts (#10240)

For detailed changes, see CHANGELOG

@better-auth/api-key
Bug Fixes
  • Fixed client IP resolution from forwarded headers to be more robust (#10203)
  • Refactored IP resolution logic into a shared core utility (#10216)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter
Features
  • Added support for Drizzle Relations v2 via a new @better-auth/drizzle-adapter/relations-v2 entry point (#9489)

For detailed changes, see CHANGELOG

@better-auth/i18n
Bug Fixes
  • Fixed the English language fallback and updated i18n documentation (#9872)

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter
Bug Fixes
  • Fixed the Kysely adapter to return null when an update matches no rows (#10180)

For detailed changes, see CHANGELOG

@better-auth/mcp
Features
  • Added a refreshTokenReuseInterval option, defaulting to 30 seconds, so native/public clients can retry a refresh if a prior token rotation raced against it (#10145)

For detailed changes, see CHANGELOG

@better-auth/oauth-provider
Features
  • Added refreshTokenReuseInterval to allow the OAuth provider to replay refresh token responses for duplicate requests within a configurable time window (#10145)

For detailed changes, see CHANGELOG

@better-auth/scim
Bug Fixes
  • Fixed SCIM write operations to be properly scoped and to honor the active attribute (#10242)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@adityachaudhary99, @Bekacru, @benpsnyder, @bytaesu, @dipan-ck, @gustavovalverde, @moonevm, @Paola3stefania, @ping-maxwell, @rachit367, @sleepe229, @WilsonnnTan

Full changelog: v1.7.0-beta.9...v1.7.0-beta.10

v1.6.22

Added 1
  • Account-level verification lockout for two-factor authentication
Fixed 4
  • Unproven credentials are now revoked during magic link and email OTP sign-in
  • Server-side OAuth requests now refuse redirect responses instead of following them
  • SCIM write-path operations are now properly scoped and correctly honor the active attribute
  • Organization subscription actions (cancel, upgrade, restore, and the billing portal) no longer act on the wrong organization
better-auth
Bug Fixes
  • Fixed unproven credentials not being revoked during magic link and email OTP sign-in (#10239)
  • Fixed server-side OAuth requests to refuse redirect responses instead of following them (#10241)

For detailed changes, see CHANGELOG

@better-auth/scim
Bug Fixes
  • Fixed SCIM write-path operations to be properly scoped and to correctly honor the active attribute (#10242)

For detailed changes, see CHANGELOG

@better-auth/stripe
Bug Fixes
  • Fixed organization subscription actions (cancel, upgrade, restore, and the billing portal) that could act on the wrong organization.

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Added account-level verification lockout for two-factor authentication (#10240)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@gustavovalverde

Full changelog: v1.6.21...v1.6.22

v1.6.21

Fixed 19
  • Rate limits are now enforced before plugin request handlers run
  • Admin permission changes and bans now take effect immediately even when session cookie cache is enabled
  • deviceAuthorization() no longer throws a ZodError when called without a schema option under Zod v4
  • Google hosted-domain validation now applies consistently across all sign-in flows including Google One Tap
  • OAuth proxy now rejects profile callbacks that do not match an issued OAuth state, preventing session creation with stale state
  • OAuth sign-up and account linking now ignore provider profile values for fields marked input: false
better-auth
Bug Fixes
  • Fixed rate limits to be enforced before plugin request handlers run (#10191)
  • Fixed admin permission changes and bans to take effect immediately, even when session cookie cache is enabled (#10187)
  • Fixed deviceAuthorization() throwing a ZodError when called without a schema option under Zod v4 (#9939)
  • Fixed Google hosted-domain validation to apply consistently across all sign-in flows, including Google One Tap (#10197)
  • Fixed OAuth proxy to reject profile callbacks that do not match an issued OAuth state, preventing session creation with stale state (#10183)
  • Fixed OAuth sign-up and account linking to ignore provider profile values for fields marked input: false (#10196)
  • Fixed PayPal sign-in to validate user info against the verified ID token subject (#10192)
  • Fixed SIWE sign-in to reject emails that already belong to another account, preventing one email from being attached to two accounts (#10228)
  • Fixed two-factor verification to lock out after five wrong codes for TOTP and backup codes, returning TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE (#10210)
  • Fixed the username plugin to only store displayUsername fallbacks that pass username validation during email sign-up (#10182)

For detailed changes, see CHANGELOG

@better-auth/sso
Bug Fixes
  • Fixed SSO provider deletion to also remove linked accounts, preventing reuse by a later provider with the same ID (#10224)
  • Fixed SSO domain verification to require DNS proof for every domain listed on a provider (#10227)
  • Fixed SAML single logout to reject IdP SLO POST URLs that use non-http(s) schemes such as javascript: or data: (#10225)
  • Fixed SAML SSO to reject responses whose audience, recipient, or destination does not match the configured Service Provider (#10226)

For detailed changes, see CHANGELOG

@better-auth/api-key
Bug Fixes
  • Fixed client IP resolution to prevent X-Forwarded-For spoofing in multi-hop proxy chains (#10203)
  • Refactored request IP resolution into a centralized core resolver (#10216)

For detailed changes, see CHANGELOG

auth
Bug Fixes
  • Fixed disableMigration: true to be respected on plugin schema tables during generation and runtime migration (#10198)
  • Fixed the CLI to generate BETTER_AUTH_SECRET values with 32 characters instead of 16 (#10186)

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter
Bug Fixes
  • Fixed adapter.update to return null when no matching row is found (#10180)

For detailed changes, see CHANGELOG

@better-auth/stripe
Bug Fixes
  • Fixed organization subscription actions (cancel, upgrade, restore, and the billing portal) that could act on the wrong organization.

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@Bekacru, @benpsnyder, @bytaesu, @gustavovalverde, @moonevm, @Paola3stefania, @ping-maxwell, @rachit367

Full changelog: v1.6.20...v1.6.21

v1.7.0-beta.9

Pre-release
Added 2
  • Added support for confidential DCR clients to complete authorization-code flows without PKCE when clientRegistrationRequirePKCE: false is set
  • Added support for the claims.userinfo authorization request parameter, allowing clients to request specific standard claims from the UserInfo endpoint
Fixed 9
  • Fixed the UserInfo endpoint to accept bearer tokens in application/x-www-form-urlencoded POST request bodies
  • Fixed confidential clients that opted out of PKCE to successfully request offline_access when the authorization includes both openid scope and a nonce
  • Fixed the OIDC authorization endpoint to accept form-encoded POST requests and return proper errors for unsupported request and request_uri parameters
  • Fixed the UserInfo endpoint to correctly return profile and email scope claims, and added rejection of unsupported acr_values in authorization requests
  • Fixed the token endpoint to only require redirect_uri when the original authorization request included one, and to return invalid_grant on mismatches
  • Fixed Dynamic Client Registration to preserve client key metadata across updates
Removed 1
  • Restricted customIdTokenClaims, extension ID-token claims, and per-issuance idTokenClaims from overriding protected OIDC/JWT protocol claims (iss, sub, aud, exp, nonce, auth_time, acr, amr, azp)
@better-auth/oauth-provider
❗ Breaking Changes
  • Restricted customIdTokenClaims, extension ID-token claims, and per-issuance idTokenClaims from overriding protected OIDC/JWT protocol claims (#10140)

Migration: Remove any iss, sub, aud, exp, nonce, auth_time, acr, amr, or azp fields from customIdTokenClaims, extension ID-token claims, and per-issuance idTokenClaims. Use namespaced custom claims (e.g., "https://example.com/role") for application-specific data instead.

Features
  • Added support for confidential DCR clients to complete authorization-code flows without PKCE when clientRegistrationRequirePKCE: false is set (#10146)
  • Added support for the claims.userinfo authorization request parameter, allowing clients to request specific standard claims from the UserInfo endpoint (#10156)
Bug Fixes
  • Fixed the UserInfo endpoint to accept bearer tokens in application/x-www-form-urlencoded POST request bodies (#10155)
  • Fixed confidential clients that opted out of PKCE to successfully request offline_access when the authorization includes both openid scope and a nonce (#10153)
  • Fixed the OIDC authorization endpoint to accept form-encoded POST requests and return proper errors for unsupported request and request_uri parameters (#10151)
  • Fixed the UserInfo endpoint to correctly return profile and email scope claims, and added rejection of unsupported acr_values in authorization requests (#10152)
  • Fixed the token endpoint to only require redirect_uri when the original authorization request included one, and to return invalid_grant on mismatches (#10159)
  • Fixed Dynamic Client Registration to preserve client key metadata across updates (#10144)
  • Fixed authorization requests missing response_type to redirect errors to the client redirect URI instead of the provider error page (#10149)
  • Fixed authorization code replay to correctly return invalid_grant and revoke all tokens previously issued from the replayed code (#10150)
  • Fixed refresh token validation to return invalid_grant when a client attempts to use a refresh token issued to a different client (#10154)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

@gustavovalverde

Full changelog: v1.7.0-beta.8...v1.7.0-beta.9

Discussion