Changelog

All changes, fixes, and updates

Every release shipped to Better Auth, straight from GitHub.

CHANGELOG

Blog post: Better Auth 1.7

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 requires Account.issuer but preserves Account.accountId as the provider-assigned account identifier. Account-specific APIs select the local Account.id through the accountId request property; 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.accountId 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(captcha)!: support wildcard endpoint matching (#10004)

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

    The shared-auth route helper is renamed from withMcpAuth to requireMcpAuth. The standalone protected-resource factory is renamed from mcpHandler to createMcpProtectedRequestHandler; pass one flat McpProtectedRequestHandlerOptions object with issuer, a single audience, optional jwtVerifyOptions, token-verification fields, and challenge fields. Its callback receives accessTokenClaims. requireMcpAuth verifies the access token against the published JWKS, validates DPoP proofs for DPoP-bound tokens, and passes the verified access-token claims to your handler.

    createInsufficientScopeError now validates a custom description against the RFC 6750 error_description character set when the error is constructed. Invalid descriptions throw TypeError("invalid error_description") before an error can reach resource-challenge serialization.

    MCP 2026-07-28 uses a stateless request and response transport. Serve MCP routes with version 2 of @modelcontextprotocol/server, configure createMcpHandler with legacy: "reject", wrap it with requireMcpAuth, and export only POST. Remove MCP-route GET and DELETE exports and session-store options such as redisUrl. OAuth clients, consent, authorization codes, refresh tokens, and security records remain durable authorization state.

    To migrate, install @better-auth/mcp, @better-auth/cimd, and the official version 2 MCP client or server package needed by your application; add the jwt() plugin, which is 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. Every registered backchannel_logout_uri must be a credential-free public HTTPS URL without a fragment; loopback HTTP is rejected for both public and confidential clients. CIMD documents cannot register back-channel logout metadata. The SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, 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(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.

  • 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

method: "totp" (default)

  • Returns { method: "totp", totpURI, backupCodes }.
  • Rejects with TOTP_NOT_CONFIGURED if totpOptions.disable is set.

The existing skipVerificationOnEnable option remains supported for TOTP enrollment.

Breaking changes

  • 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(device-authorization)!: add lookup indexes (#10059)

    Generated codes are limited to 191 characters. Issuance makes up to 3 attempts to overcome unique-key collisions, then returns server_error if it cannot create a unique deviceCode and userCode. Default-generated user codes accept case changes and readability separators during verification, approval, and denial; custom codes outside the default alphabet are matched exactly. The /device limiter allows 5 requests over a window equal to the configured code lifetime, while /device/token polling keeps its separate interval behavior.

  • 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(microsoft)!: use oid as account id (#10204)

  • 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
    • pkce defaults to true (was false)
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>
  • refactor(oauth-provider)!: separate device grant ownership (#10746)

    The OAuth integration replaces the optional resource column with oauthClientId and resources. Regenerate and apply the schema when using it. Before upgrading from an earlier 1.7 prerelease, let pending OAuth device codes expire or delete them because they cannot be exchanged through the new integration.

  • 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(db): add compound table indexes (#10402)
  • feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
  • feat(generic-oauth): add RP-initiated logout support (#9368)
  • 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 device authorization grant (RFC 8628) (#10135)
  • 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
  • feat(org): allow passing userId and organizationId to listUserTeams API (#8977)
  • feat(organization): add getOrganization for metadata-only fetches (#10397)
  • feat(phone-number): add server-side OTP consumption API (#9766)
  • feat(session): support JWKS-backed JWT session cookie cache (#8931)
  • feat(sso): add transactional OIDC user resolution (#10473)
  • feat(username): add immutable username option (#9240)
  • feat(username): disable display-name (#10330)

Bug Fixes

  • Allow test instances to enable native database transactions for postgres and mysql.
  • 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.
  • chore: widen drizzle-kit peer dependency range (#10299)
  • fix(cookies): decouple cookie cache from JWT plugin internals (#10666)
  • fix(db): don't abort auth migrate when adding required or unique columns (#10293)
  • fix(generic-oauth): bind id token nonce in redirect flow (#10095)
  • fix(kysely-adapter): report native transaction support for auto-detected dialects (#10622)
  • 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)
  • fix(siwe): issue addressless nonces (#10234)
  • fix(types): expand workspace and consumer type checking (#10505)
  • 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 shared-auth route helper is renamed from withMcpAuth to requireMcpAuth. The standalone protected-resource factory is renamed from mcpHandler to createMcpProtectedRequestHandler; pass one flat McpProtectedRequestHandlerOptions object with issuer, a single audience, optional jwtVerifyOptions, token-verification fields, and challenge fields. Its callback receives accessTokenClaims. requireMcpAuth verifies the access token against the published JWKS, validates DPoP proofs for DPoP-bound tokens, and passes the verified access-token claims to your handler.

    createInsufficientScopeError now validates a custom description against the RFC 6750 error_description character set when the error is constructed. Invalid descriptions throw TypeError("invalid error_description") before an error can reach resource-challenge serialization.

    MCP 2026-07-28 uses a stateless request and response transport. Serve MCP routes with version 2 of @modelcontextprotocol/server, configure createMcpHandler with legacy: "reject", wrap it with requireMcpAuth, and export only POST. Remove MCP-route GET and DELETE exports and session-store options such as redisUrl. OAuth clients, consent, authorization codes, refresh tokens, and security records remain durable authorization state.

    To migrate, install @better-auth/mcp, @better-auth/cimd, and the official version 2 MCP client or server package needed by your application; add the jwt() plugin, which is 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. Every registered backchannel_logout_uri must be a credential-free public HTTPS URL without a fragment; loopback HTTP is rejected for both public and confidential clients. CIMD documents cannot register back-channel logout metadata. The SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, 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)!: align MCP authorization with 2026-07-28 (#10577)

    OAuthClient no longer has a catch-all string index. Model custom wire extensions explicitly with a named intersection such as OAuthClient & YourExtensionMetadata; legacy type and public fields no longer type-check as unknown baggage.

    • Dynamic, administrative, and user-managed registrations default an omitted application_type to web. Client ID Metadata Documents preserve an omitted value as null.
    • Web redirects require HTTPS on a non-loopback host. Native redirects accept claimed HTTPS URLs, exact HTTP loopback hosts, or reverse-domain private-use schemes.
    • Registration resource options control resource links. mcp() contributes its protected resource by default, so standards-based clients no longer need a resources extension.
    • mcp() no longer enables unauthenticated Dynamic Client Registration. Compose mcp() with cimd() for Client ID Metadata Documents, or enable both DCR flags explicitly.

    This release requires a database migration. Add applicationType and nullable clientDiscoveryId; map old web and native values directly, map user-agent-based to NULL for manual reclassification, and never derive it from public. Set clientDiscoveryId only from known discovery provenance, never by inspecting an HTTPS client ID. Deduplicate existing (clientId, resourceId) links before adding the new compound unique index, then drop the legacy columns. Deployments with custom schema mappings must apply this backfill manually.

    Machine-to-machine scope authority is now stored separately in nullable oauthClient.clientCredentialsScopes. Missing, NULL, and empty values deny client_credentials token issuance. Only the administrative create and update endpoints expose client_credentials_scopes, and assigning a non-empty value requires clientPrivileges to approve the new configure-client-credentials-scopes action. DCR, CIMD, and user-managed registration cannot assign this field; CIMD refresh preserves an existing administrator-owned value. Remove clientCredentialGrantDefaultScopes, backfill every existing client to [], configure [] as the default for new rows, then explicitly assign every approved machine scope after auditing the client.

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

  • feat(oauth-provider)!: make id-token claim authority explicit (#10140) ISO/IEC 29115 level 1, and OpenID discovery advertises only "0". Because acr_values is voluntary, requests for other classes continue instead of failing. Essential claims.id_token.acr requests in OpenID Connect flows still fail when their required value or values cannot be met.

    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)

    Authorization errors redirect to a registered client's trusted redirect URI with state and iss. The response uses the URL fragment for implicit token and id_token responses unless the client explicitly requests query mode. Requests without a trusted redirect URI continue to use the server error page.

    Token, introspection, and revocation requests now treat empty credential values as omitted, reject repeated non-empty client credentials, and require confidential clients to use their registered token_endpoint_auth_method. Introspection and revocation requests also ignore unrecognized token_type_hint values instead of rejecting the request.

  • 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
    • pkce defaults to true (was false)
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>
  • refactor(oauth-provider)!: separate device grant ownership (#10746)

    The OAuth integration replaces the optional resource column with oauthClientId and resources. Regenerate and apply the schema when using it. Before upgrading from an earlier 1.7 prerelease, let pending OAuth device codes expire or delete them because they cannot be exchanged through the new integration.

Features

  • feat: add token endpoint client authentication (#9625)
  • feat(cimd): add Client ID Metadata Document plugin (#9159)
  • feat(oauth-provider): add device authorization grant (RFC 8628) (#10135)
  • 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): remove silenceWarnings config and well-known endpoint warnings (#10703)
  • 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

Bug Fixes

  • fix(device-authorization): enforce RFC device flow requirements (#10752)
  • fix(oauth-provider): accept issuer audience for client assertions (#10811)
  • 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): complete RP-initiated logout flow (#10812)
  • fix(oauth-provider): defer logout effects until commit (#10472)
  • fix(oauth-provider): handle OIDC authorization request inputs (#10151)
  • fix(oauth-provider): handle voluntary and essential ACR requests (#10790)
  • 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): require openid for claims requests (#10791)
  • fix(oauth-provider): return invalid_grant for cross-client refresh tokens (#10154)
  • MCP clients that hit a scope wall now learn exactly which scopes to ask for. Missing protected scopes produce a 403 with an RFC 6750 insufficient_scope WWW-Authenticate challenge that names every missing scope. Clients can union those scopes into one authorization request instead of opening one browser redirect per scope.
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

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 requires Account.issuer but preserves Account.accountId as the provider-assigned account identifier. Account-specific APIs select the local Account.id through the accountId request property; 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.accountId 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.

  • fix(microsoft)!: use oid as account id (#10204)

  • 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(db): add compound table indexes (#10402)
  • feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
  • feat(generic-oauth): add RP-initiated logout support (#9368)
  • 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)
  • feat(session): support JWKS-backed JWT session cookie cache (#8931)
  • feat(sso): add transactional OIDC user resolution (#10473)

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)
  • fix(types): expand workspace and consumer type checking (#10505)
  • refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)

For detailed changes, see CHANGELOG

@better-auth/sso

❗ Breaking Changes

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

    This release requires Account.issuer but preserves Account.accountId as the provider-assigned account identifier. Account-specific APIs select the local Account.id through the accountId request property; 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.accountId 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(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 no longer configures the ACS URL. The default ACS URL is derived from baseURL and providerId. Use callbackUrl as the provider-level post-auth redirect, or pass callbackURL to signIn.sso() for an SP-initiated request:

    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
  • feat(sso): add transactional OIDC user resolution (#10473)
  • feat(sso): extend resolveUser to SAML and harden provider lifecycle (#10621)
  • feat(sso): support additionalFields on ssoProvider (#9445)

Bug Fixes

  • Allow SSO provider registration to reuse a SCIM connection ID. SCIM connections no longer participate in the authentication provider namespace.
  • 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)
  • Verify SAML assertion signatures directly instead of trusting an already-parsed response, and enforce a signing policy and size limit on SP metadata the same way IdP metadata is already enforced. wantAssertionsSigned now controls whether the SP requires signed assertions instead of signed response messages, matching how IdPs sign SAML responses in practice.

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.

  • feat(scim)!: isolate provider connections by organization (#10249) them statically, resolve them with authentication.verifyBearerToken, or use the optional managedConnections catalog.

    Legacy connection management, organization-scoped configuration, and SCIM-created authentication accounts are removed. Use identity and projection callbacks to connect SCIM resources to application users and roles.

    Legacy SCIM state is not migrated. Back it up, issue new credentials, and fully reprovision Users and Groups after upgrading.

  • fix(scim)!: always bind personal SCIM connections to their creator (#9840) providerOwnership. Applications now authorize their own SCIM administration workflows instead of relying on Better Auth user ownership.

    Legacy scimProvider rows and credentials are not migrated. Follow the 1.7 SCIM upgrade guide, issue new credentials, and fully reprovision Users and Groups.

Features

  • feat(auth): add user.validateUserInfo provisioning gate (#9864)
  • feat(scim): add durable group resources (#10018)
  • feat(scim): add enterprise user attributes and interop conformance (#10620)
  • feat(scim): add managed connection catalog and runtime connection resolution (#10592)
  • feat(scim): expose active provisioned user links (#10474)

Bug Fixes

  • Accept exact case-insensitive string Boolean values for SCIM User active and the primary sub-attribute of emails, phoneNumbers, addresses, roles, and entitlements at the HTTP ingress for Microsoft Entra interoperability.
  • Add an optional SCIM-owned connection and credential catalog. Configure managedConnections to let trusted server code create runtime tenant connections and issue, rotate, and revoke their bearer credentials through server-only auth.api methods, without a code-defined connection or an application-owned verifier.
  • Allow trusted server code to retain a terminal connection binding before a dynamic SCIM connection's first authenticated request by supplying its provisioning domain during decommissioning.
  • fix(scim): create filtered PATCH values when no target matches (#10682)

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 shared-auth route helper is renamed from withMcpAuth to requireMcpAuth. The standalone protected-resource factory is renamed from mcpHandler to createMcpProtectedRequestHandler; pass one flat McpProtectedRequestHandlerOptions object with issuer, a single audience, optional jwtVerifyOptions, token-verification fields, and challenge fields. Its callback receives accessTokenClaims. requireMcpAuth verifies the access token against the published JWKS, validates DPoP proofs for DPoP-bound tokens, and passes the verified access-token claims to your handler.

    createInsufficientScopeError now validates a custom description against the RFC 6750 error_description character set when the error is constructed. Invalid descriptions throw TypeError("invalid error_description") before an error can reach resource-challenge serialization.

    MCP 2026-07-28 uses a stateless request and response transport. Serve MCP routes with version 2 of @modelcontextprotocol/server, configure createMcpHandler with legacy: "reject", wrap it with requireMcpAuth, and export only POST. Remove MCP-route GET and DELETE exports and session-store options such as redisUrl. OAuth clients, consent, authorization codes, refresh tokens, and security records remain durable authorization state.

    To migrate, install @better-auth/mcp, @better-auth/cimd, and the official version 2 MCP client or server package needed by your application; add the jwt() plugin, which is 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)!: align MCP authorization with 2026-07-28 (#10577)

    OAuthClient no longer has a catch-all string index. Model custom wire extensions explicitly with a named intersection such as OAuthClient & YourExtensionMetadata; legacy type and public fields no longer type-check as unknown baggage.

    • Dynamic, administrative, and user-managed registrations default an omitted application_type to web. Client ID Metadata Documents preserve an omitted value as null.
    • Web redirects require HTTPS on a non-loopback host. Native redirects accept claimed HTTPS URLs, exact HTTP loopback hosts, or reverse-domain private-use schemes.
    • Registration resource options control resource links. mcp() contributes its protected resource by default, so standards-based clients no longer need a resources extension.
    • mcp() no longer enables unauthenticated Dynamic Client Registration. Compose mcp() with cimd() for Client ID Metadata Documents, or enable both DCR flags explicitly.

    This release requires a database migration. Add applicationType and nullable clientDiscoveryId; map old web and native values directly, map user-agent-based to NULL for manual reclassification, and never derive it from public. Set clientDiscoveryId only from known discovery provenance, never by inspecting an HTTPS client ID. Deduplicate existing (clientId, resourceId) links before adding the new compound unique index, then drop the legacy columns. Deployments with custom schema mappings must apply this backfill manually.

    Machine-to-machine scope authority is now stored separately in nullable oauthClient.clientCredentialsScopes. Missing, NULL, and empty values deny client_credentials token issuance. Only the administrative create and update endpoints expose client_credentials_scopes, and assigning a non-empty value requires clientPrivileges to approve the new configure-client-credentials-scopes action. DCR, CIMD, and user-managed registration cannot assign this field; CIMD refresh preserves an existing administrator-owned value. Remove clientCredentialGrantDefaultScopes, backfill every existing client to [], configure [] as the default for new rows, then explicitly assign every approved machine scope after auditing the client.

  • 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/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
    • pkce defaults to true (was false)
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>

Bug Fixes

  • fix(types): expand workspace and consumer type checking (#10505)

For detailed changes, see CHANGELOG

@better-auth/expo

❗ Breaking Changes

  • fix(expo)!: use async secure storage access (#10438)

  • 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
    • pkce defaults to true (was false)
    • authorizationUrlParams and tokenUrlParams only accept Record<string, string>
    • issuer and requireIssuerValidation config fields removed
    • mapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>

Bug Fixes

  • fix(types): expand workspace and consumer type checking (#10505)

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

auth

❗ Breaking Changes

  • feat(oauth)!: accumulate granted scopes as grantedScopes string[] (#9825)

Features

  • feat(cli): add create-admin command (#9547)
  • feat(db): add compound table indexes (#10402)

Bug Fixes

  • fix(core): preserve issuer-scoped account identities (#10668)
  • fix(drizzle): export generated pgSchema for drizzle-kit (#10770)
  • 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/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-adapter): support Drizzle Relations v2 (#9489)
  • feat(drizzle): generate drizzle schema with schema namespace (#7169)

Bug Fixes

  • fix(drizzle): export generated pgSchema for drizzle-kit (#10770)

For detailed changes, see CHANGELOG

@better-auth/cimd

❗ Breaking Changes

  • feat(oauth-provider)!: align MCP authorization with 2026-07-28 (#10577)

    OAuthClient no longer has a catch-all string index. Model custom wire extensions explicitly with a named intersection such as OAuthClient & YourExtensionMetadata; legacy type and public fields no longer type-check as unknown baggage.

    • Dynamic, administrative, and user-managed registrations default an omitted application_type to web. Client ID Metadata Documents preserve an omitted value as null.
    • Web redirects require HTTPS on a non-loopback host. Native redirects accept claimed HTTPS URLs, exact HTTP loopback hosts, or reverse-domain private-use schemes.
    • Registration resource options control resource links. mcp() contributes its protected resource by default, so standards-based clients no longer need a resources extension.
    • mcp() no longer enables unauthenticated Dynamic Client Registration. Compose mcp() with cimd() for Client ID Metadata Documents, or enable both DCR flags explicitly.

    This release requires a database migration. Add applicationType and nullable clientDiscoveryId; map old web and native values directly, map user-agent-based to NULL for manual reclassification, and never derive it from public. Set clientDiscoveryId only from known discovery provenance, never by inspecting an HTTPS client ID. Deduplicate existing (clientId, resourceId) links before adding the new compound unique index, then drop the legacy columns. Deployments with custom schema mappings must apply this backfill manually.

    Machine-to-machine scope authority is now stored separately in nullable oauthClient.clientCredentialsScopes. Missing, NULL, and empty values deny client_credentials token issuance. Only the administrative create and update endpoints expose client_credentials_scopes, and assigning a non-empty value requires clientPrivileges to approve the new configure-client-credentials-scopes action. DCR, CIMD, and user-managed registration cannot assign this field; CIMD refresh preserves an existing administrator-owned value. Remove clientCredentialGrantDefaultScopes, backfill every existing client to [], configure [] as the default for new rows, then explicitly assign every approved machine scope after auditing the client.

Features

  • feat(cimd): add Client ID Metadata Document plugin (#9159)

Bug Fixes

  • Client ID Metadata Documents now follow shared-cache freshness rules and fail closed when freshness is ambiguous. The plugin prefers s-maxage over max-age and Expires, honors s-maxage=0, conditionally revalidates with ETag or Last-Modified, and treats invalid or duplicate freshness directives as immediately stale. Concurrent refreshes converge on one client-resource link instead of failing on its unique constraint.

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/kysely-adapter

Bug Fixes

  • fix(kysely-adapter): restore local migration constants (#10377)
  • Raw database instances (better-sqlite3, node:sqlite, bun:sqlite, mysql2, pg) passed directly as database now get native adapter transactions automatically, matching the behavior of the explicit { db }/{ dialect } config shapes. This unblocks plugins that require native transactions (such as @better-auth/scim) when the database is provided in the quickstart database: new Database(...) shape.

For detailed changes, see CHANGELOG

@better-auth/i18n

Features

  • feat(i18n): add built-in translations for 22 languages (#9157)

For detailed changes, see CHANGELOG

@better-auth/mongo-adapter

Features

  • feat(db): add compound table indexes (#10402)

For detailed changes, see CHANGELOG

@better-auth/passkey

Features

  • feat(passkey): create session during passkey registration (#9873)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

adrianmxbbrentmitchell25bytaesuGautamBytesgustavovalverdeItalyPaleAlemomomuchuOscarCornishpi0ping-maxwellruban-ssovetskiyordis

Full changelog: v1.6.30...v1.7.0

better-auth

Bug Fixes

  • Fixed concurrent cold-start requests from intermittently losing authentication or transaction context due to an async storage initialization race (#10833)

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed automatic organization assignment via email domain to require both a verified provider domain and a verified stored user email, preventing social sign-in from joining an organization whose SSO provider merely claims that domain.
  • Fixed domain verification to snapshot the provider's domains at request start, returning 409 with SSO_PROVIDER_CHANGED if the provider changes during DNS resolution so callers can reload and retry.

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

bytaesu

Full changelog: v1.6.29...v1.6.30

better-auth

Bug Fixes

  • Improved deleteSessions performance by running deletes in parallel instead of sequentially (#10805)

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed automatic email-domain organization assignment to require both a verified provider domain and a verified user email, preventing social sign-in from granting access when an SSO provider merely claims a domain.
  • Fixed domain verification to snapshot the provider's domains at request start, returning 409 with SSO_PROVIDER_CHANGED if the provider changes during DNS verification so callers can reload and retry.

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

Emmaccen

Full changelog: v1.6.28...v1.6.29

better-auth

Bug Fixes

  • Prevented duplicate session requests during React Suspense retries while preserving revalidation for interrupted refreshes (#10769)
  • Restored client plugin declaration compatibility for downstream TypeScript consumers (#10794)

For detailed changes, see the CHANGELOG.

@better-auth/electron

Bug Fixes

  • Restored client plugin declaration compatibility for downstream TypeScript consumers (#10794)

For detailed changes, see the CHANGELOG.

@better-auth/expo

Bug Fixes

  • Restored client plugin declaration compatibility for downstream TypeScript consumers (#10794)

For detailed changes, see the CHANGELOG.

Contributors

Thanks to everyone who contributed to this release:

bytaesu

Full changelog: v1.6.27...v1.6.28

better-auth

Bug Fixes

  • Fixed duplicate session requests being made across Suspense retries (#10676)

For detailed changes, see CHANGELOG

@better-auth/scim

Bug Fixes

  • Fixed auth endpoint types to align with better-call (#10657)

For detailed changes, see CHANGELOG

auth

Bug Fixes

  • Fixed the CLI to align installed packages with the running CLI version (#10743)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

bytaesu

Full changelog: v1.6.26...v1.6.27

better-auth

Bug Fixes

  • Fixed session cleanup on user deletion to also remove sessions from secondary storage (#10520)
  • Fixed findSessions to skip invalid secondary-storage session entries without discarding other valid sessions (#10580)
  • Fixed email OTP sign-up to pass the verification type to custom OTP generators (#10608)
  • Fixed email OTP password reset to allow retrying after entering an invalid password (#10552)
  • Fixed email OTP verification to no longer reveal whether an email is registered before the OTP is verified (#10605)
  • Fixed jwtClient() collapsing createAuthClient type inference when combined with other client plugins (#10513)
  • Fixed JWT key minting inside database transactions to use the transaction-scoped adapter, preventing deadlocks on SQLite and ensuring keys commit with their surrounding transaction on Postgres and MySQL (#10623)
  • Fixed oAuthProxy to preserve Apple user data from form_post callbacks (#10599)
  • Fixed oneTapClient() collapsing createAuthClient type inference when combined with other client plugins (#10635)
  • Fixed database rate-limit cleanup to complete when no background task handler is configured (#10619)
  • Improved nextCookies performance in instrumented Next.js applications by reusing the next/headers import promise (#10467)

For detailed changes, see CHANGELOG

@better-auth/core

Features

  • Added a utility for creating stable, namespaced placeholder emails on the reserved placeholder.invalid domain (#10576)

For detailed changes, see CHANGELOG

@better-auth/redis-storage

Bug Fixes

  • Fixed listKeys() and clear() to use SCAN instead of KEYS so large keyspaces no longer block the Redis server (#10507)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

bytaesuEmmaccengustavovalverdejashkarangiyajeroenvandermerwejlucaso1krish-vachhanimrosberghausXXMOHAMED012

Full changelog: v1.6.25...v1.6.26

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:

birkskyumjsjkrish-vachhani

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

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-bruhayushman46c-nicolgaurav-initgaurav0107GautamBytesmomomuchuOrangeManLipaoloricciutiping-maxwellshiminshenswithekTushar-Khandelwal-2004vinay-oppuri

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

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:

bytaesuvladflotsky

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

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

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:

BekacrubenpsnyderbytaesugustavovalverdemoonevmPaola3stefaniaping-maxwellrachit367

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

better-auth

Bug Fixes

  • Fixed account-linking logs to route through the configured logger (#10121)
  • Fixed TypeScript inference errors by declaring inherited APIError properties (#8734)
  • Fixed refresh cookie Max-Age to be capped at expiresIn (#9621)

For detailed changes, see CHANGELOG

@better-auth/i18n

Bug Fixes

  • Fixed English language fallback behavior and improved i18n documentation (#9872)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

adityachaudhary99dipan-cksleepe229WilsonnnTan

Full changelog: v1.6.19...v1.6.20

better-auth

Features

  • Added support for pre-binding device codes to a specific user in the device authorization plugin (#9995)

Bug Fixes

  • Fixed headerless session checks (#10053)
  • Fixed cookie cache fallback lookup (#9348)
  • Fixed sendVerificationEmail errors not being surfaced to the client (#8863)
  • Fixed auth client return types not being emitted correctly in TypeScript declaration builds (#10071)
  • Fixed session and account cache cookies being silently dropped when near the browser's per-cookie size limit by splitting them into chunks (#10088)
  • Fixed single-use verification flows (such as magic-link) hanging on connection-limited database adapters by reusing active transactions (#10070)
  • Fixed the domain not being included when clearing cross-subdomain cookies in the last-login-method plugin (#9319)
  • Fixed the oauth-popup plugin leaking internal OAuth state keys into additionalData (#10067)
  • Reverted the headerless session check fix (#10074)

For detailed changes, see CHANGELOG

auth

Bug Fixes

  • Fixed the generate command not handling a directory path passed to --output (#9564)
  • Fixed array additionalField default values not being serialized correctly in the Drizzle schema generator (#10048)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter

Bug Fixes

  • Fixed password reset tokens not working with the Drizzle MySQL adapter after being consumed (#10081)

For detailed changes, see CHANGELOG

@better-auth/mongo-adapter

Bug Fixes

  • Fixed guarded state transitions (token rotation, revocation, two-factor backup-code regeneration, device-code claiming, and organization invitation acceptance) failing on Prisma and on MongoDB servers older than 5.0 (#10086)

For detailed changes, see CHANGELOG

@better-auth/passkey

Bug Fixes

  • Fixed invalid OpenAPI output for callback, session, and passkey routes so client generators can consume the schema (#9555)

For detailed changes, see CHANGELOG

@better-auth/scim

Bug Fixes

  • Stopped logging SCIM user filter values when listing users (#10087)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

brone1323bytaesuChrisMGeoElGauchooooogustavovalverdeping-maxwelltsushanthTushar-Khandelwal-2004

Full changelog: v1.6.18...v1.6.19

better-auth

Bug Fixes

  • Fixed getCookieCache to return null for expired sessions instead of treating stale signed cookies as live sessions.
  • Fixed the delete-account confirmation link to prevent duplicate account deletions from concurrent callback requests.
  • Fixed one-time tokens from being redeemable multiple times under concurrent requests.
  • Fixed password reset tokens from changing a password more than once under concurrent requests.
  • Fixed Reddit sign-in to assign a non-routable placeholder address (<id>@reddit.invalid) to users with no email, preventing accidental matches with real mailboxes.
  • Fixed Sign-In with Ethereum nonces from being accepted multiple times under concurrent sign-in requests.
  • Added internalAdapter.reserveVerificationValue to atomically record single-use markers, ensuring only one concurrent caller succeeds for replay-protected operations.
  • Added the incrementOne adapter method and SecondaryStorage.increment for atomic counter updates, enabling strict rate-limit and usage-counter enforcement under concurrent load.
  • Fixed expired two-factor challenges from completing login and prevented duplicate session creation from concurrent verifications.
  • Fixed captcha verification to time out after 10 seconds, preventing slow or unreachable captcha providers from hanging requests indefinitely.
  • Fixed /delete-user/callback to reject account deletion when the session has been revoked server-side (cookie-only session deployments are unaffected).
  • Fixed rate limiting to prevent concurrent requests from slipping past configured limits, with a new optional consume method for custom storage backends to opt into strict enforcement.
  • Fixed team deletion to preserve pending invitations by removing only the deleted team's reference rather than invalidating the invitations entirely.
  • Fixed expected authentication validation failures to log as warnings instead of errors.
  • Fixed MCP bearer token validation to reject expired access tokens and require the offline_access scope for refresh token usage.
  • Fixed plugin API inference in composite monorepo setups where the core package resolved through multiple paths (#9583)
  • Fixed OpenAPI generation to accurately serialize Zod request schemas, including optional, nullable, intersected, and record-shaped types (#9315)
  • Fixed a memory leak where the JWKS cache could grow on every access token verification.
  • Fixed Google One Tap to require a configured client ID (set via the oneTap plugin or socialProviders.google) and reject tokens issued for other applications.
  • Fixed device-authorization token polling to prevent the same approved device code from being redeemed multiple times under concurrent polls.
  • Fixed account cookie preservation when switching users in the same browser session.
  • Fixed email OTP sign-in to prevent concurrent requests from signing in multiple times or exceeding the attempt limit.
  • Fixed phone-number OTP sign-in to prevent concurrent requests from signing in multiple times or exceeding the attempt limit.
  • Fixed two-factor OTP sign-in to prevent concurrent requests from signing in multiple times or exceeding the attempt limit.
  • Fixed the Have I Been Pwned plugin to check breached passwords on additional endpoints, including email-OTP and phone-number reset-password routes and admin password-setting routes.
  • Fixed the multi-session set-active and revoke endpoints to only act on sessions the caller holds a signed cookie for, preventing unauthorized session manipulation.
  • Fixed the OIDC /oauth2/endsession endpoint to reject cross-site logout requests that carry only a session cookie without a valid id_token_hint.
  • Fixed WeChat sign-in to work without an email address by assigning a stable placeholder email, with mapProfileToUser available to supply a real one.

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed SAML assertion replay protection to hold under concurrent requests, preventing a duplicate submission from being accepted more than once.
  • Fixed organization admins and owners to verify domain ownership for SSO providers their organization owns, not just the member who originally registered the provider.
  • Fixed trustEmailVerified to treat only a boolean true or the string "true" as a verified email, rejecting the string "false" as unverified.

For detailed changes, see CHANGELOG

@better-auth/memory-adapter

Bug Fixes

  • Fixed the memory adapter to not discard concurrent writes when a transaction fails, and made update and delete no-ops on empty filters instead of modifying every row.
  • Fixed counter updates on the memory, Kysely, Drizzle, Prisma, and MongoDB adapters to be atomic on the default configuration, preventing race conditions in rate limiting and API-key usage limits.

For detailed changes, see CHANGELOG

@better-auth/oauth-provider

Bug Fixes

  • Fixed signed OAuth redirect parameters to be canonicalized by key and value, preventing CDN or proxy reordering from breaking signature verification (#9941)
  • Fixed token introspection and revocation endpoints to cache signing keys per auth instance rather than fetching them from the database on every request.

For detailed changes, see CHANGELOG

@better-auth/scim

Bug Fixes

  • Fixed organization-scoped SCIM deletes to remove user membership through the organization adapter, so team memberships and member-removal hooks are applied correctly.
  • Fixed SCIM bearer token comparison to use constant-time comparison during request authentication, closing a timing side channel across all storage modes.

For detailed changes, see CHANGELOG

@better-auth/api-key

Bug Fixes

  • Fixed concurrent API key verification to prevent the remaining-uses count from going below zero or the rate limit from being exceeded.

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter

Bug Fixes

  • Fixed updateMany to return the number of rows it affected, as the adapter contract specifies.

For detailed changes, see CHANGELOG

@better-auth/electron

Bug Fixes

  • Fixed Electron authorization codes from being exchangeable for a session more than once under concurrent exchange attempts.

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter

Bug Fixes

  • Fixed SQLite mutations through the Bun and Node drivers to correctly report affected row counts and inserted row IDs, fixed multi-parameter binding on the Bun driver, and fixed consumeOne compatibility with SQL Server.

For detailed changes, see CHANGELOG

@better-auth/passkey

Bug Fixes

  • Fixed passkey challenge validation to reject cross-purpose challenges, preventing an authentication challenge from being used to complete registration and vice versa.

For detailed changes, see CHANGELOG

@better-auth/prisma-adapter

Bug Fixes

  • Fixed the Prisma adapter's delete operation to surface errors instead of silently reporting success when the failure is not a missing-record error.

For detailed changes, see CHANGELOG

@better-auth/redis-storage

Bug Fixes

  • Fixed Redis-backed rate-limit windows to set expiry only when the window first opens, preventing continued traffic from extending the window, and added an atomic increment method for strict enforcement.

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

GautamBytes

Full changelog: v1.6.17...v1.6.18

better-auth

Features

  • Added an experimental oauthPopup plugin for popup-based OAuth sign-in, enabling sign-in inside cross-site iframes by completing the OAuth flow in a popup and passing the session token back via the bearer plugin (#9890)

Bug Fixes

  • Fixed getCookieCache to return null for an expired session instead of stale data, so middleware no longer treats an expired signed cookie as a live session.
  • Fixed a race condition where a delete-account confirmation link could delete the account more than once when its callback was opened concurrently.
  • Fixed a race condition where a one-time token could be redeemed for a session more than once when redeemed concurrently.
  • Fixed a race condition where a password reset token could change the password more than once when used from concurrent requests.
  • Fixed Reddit sign-in to assign a non-routable placeholder address (<id>@reddit.invalid) to users with no email, instead of one on the real reddit.com domain, preventing accidental mailbox matches. The address stays unverified, and mapProfileToUser can supply a real email.
  • Fixed Sign-In with Ethereum to prevent a nonce from being used to sign in more than once when submitted from concurrent requests.
  • Added internalAdapter.reserveVerificationValue for atomic single-use markers, ensuring exactly one concurrent caller succeeds and the rest see the marker as already taken, hardening replay protection across all verification flows. Database-backed storage is atomic
  • Added the optional incrementOne adapter method and SecondaryStorage.increment for atomic counter updates with conditional row guards, enabling strict enforcement of rate limits and usage counters. Adapters without native support fall back to a transaction-based approach.
  • Fixed expired two-factor sign-in challenges from completing login, and prevented the same challenge from creating more than one session when verified concurrently.
  • Fixed captcha provider verification to time out after 10 seconds and fail closed, preventing a slow or unreachable provider from blocking requests indefinitely.
  • Fixed /delete-user/callback to reject account deletion when the session has been revoked server-side, instead of proceeding within the cookie-cache window. Deployments that keep sessions only in the cookie are unaffected.
  • Fixed concurrent requests from slipping past the configured rate limit, resolved unbounded memory growth in the in-memory rate-limit store, and made the database backend remove expired entries automatically. A custom rate-limit storage may implement a new optional consume method for strict enforcement.
  • Fixed team deletion to preserve pending invitations, which now drop the removed team and remain valid for their remaining teams or as organization-level invitations.
  • Downgraded expected auth validation failures from error logs to warnings.
  • Fixed expired MCP access tokens from being accepted, and restricted refresh token acceptance to authorizations that included the offline_access scope.
  • Fixed team member limits to be enforced on addMember and add-team-member paths, preventing teams from exceeding their maximumMembersPerTeam cap, and ensured a rejected addMember does not create the organization member (#10002)
  • Fixed generic OAuth sign-in for providers whose userinfo response lacks a sub or id field when mapProfileToUser derives the account id (#9987)
  • Fixed single-use credentials, counters, and replay markers to be handled atomically under concurrent requests (#9993)
  • Fixed stateless OAuth deployments to correctly read account info and tokens when different server instances handle sign-in and subsequent requests (#9979)
  • Fixed admin.setUserPassword to create a credential account for users who only have social or magic-link accounts, enabling direct password assignment without manually modifying the account table (#9482)
  • Fixed updateSession to accept custom session fields inferred from inferAdditionalFields (#9777)
  • Fixed duplicate /get-session requests triggered by focus and other browser events, stabilized client hook data references to reduce unnecessary re-renders, and resolved session state getting stuck loading after unmounting during an in-flight request (#8760)
  • Fixed the OpenAPI schema to mark model id fields as required (#9704)
  • Fixed updateMemberRole to reject unknown or malformed role values, validating them against configured static and dynamic roles (#9962)
  • Fixed a memory leak where the JWKS cache grew on every access token verification.
  • Fixed Google One Tap to require a configured client ID and reject ID tokens issued for a different application, preventing unauthorized sign-ins.
  • Fixed a race condition where polling for a device-authorization token could redeem the same approved device code more than once.
  • Fixed account cookie handling when switching users in the same browser, preserving the fresh cookie instead of expiring it from stale request state.
  • Refactored role.authorize control flow without changing existing authorization behavior (#9677)
  • Fixed a race condition where submitting the same email OTP from concurrent requests could sign in more than once or exceed the attempt limit.
  • Fixed a race condition where submitting the same phone-number OTP from concurrent requests could sign in more than once or exceed the attempt limit.
  • Fixed a race condition where submitting the same two-factor OTP from concurrent requests could sign in more than once or exceed the attempt limit.
  • Improved the Have I Been Pwned plugin to check submitted passwords on more endpoints by default, including email-OTP and phone-number reset-password routes and admin create-user and set-user-password routes.
  • Fixed multi-session set-active and revoke endpoints to only act on sessions the caller holds a signed cookie for, preventing unauthorized activation or revocation of other sessions.
  • Fixed the OIDC /oauth2/endsession endpoint to reject cross-site GET logout requests carrying only a session cookie, while leaving logout authenticated by a valid id_token_hint unaffected.
  • Fixed WeChat sign-in to succeed without an email address by assigning a stable placeholder, matching the behavior expected from the default configuration.

For detailed changes, see CHANGELOG

@better-auth/api-key

Bug Fixes

  • Fixed API key updates to fail when the caller's session has been revoked server-side, instead of succeeding within the cookie-cache window (#9991)
  • Prevented server-only endpoints from being accidentally exposed over HTTP (#9835)
  • Fixed concurrent API key verification from driving the remaining-uses count below zero or exceeding the rate limit. Secondary-storage-only deployments remain best-effort for these counters.

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed SAML replay protection to hold under concurrent requests, preventing a SAML assertion submitted twice simultaneously from being accepted more than once.
  • Fixed SSO domain verification to allow organization admins and owners to verify domains for providers their organization owns, not just the member who originally registered the provider.
  • Fixed trustEmailVerified to no longer treat the string "false" as a verified email, accepting only a boolean true or the string "true" as confirmation.

For detailed changes, see CHANGELOG

auth

Bug Fixes

  • Fixed the CLI to resolve SvelteKit ($app/*, $env/*), Vite asset imports (?raw, ?url), and Cloudflare Workers (cloudflare:workers) virtual-module imports when loading the auth config (#9834)
  • Fixed the CLI to skip Unsupported() fields when regenerating the Prisma schema (#10011)
  • Fixed the CLI to update existing Prisma field types when regenerating the schema, such as correctly emitting BigInt or Int when bigint configuration changes (#9729)

For detailed changes, see CHANGELOG

@better-auth/expo

Bug Fixes

  • Hardened request trust validation for the Expo authorization proxy, including rejecting redirect and callback targets not in trustedOrigins (#9990)
  • Fixed Expo social account linking to include the stored session cookie when using an ID token with linkSocial (#9953)

For detailed changes, see CHANGELOG

@better-auth/memory-adapter

Bug Fixes

  • Fixed the memory adapter to not discard writes from concurrent operations on a failed transaction, made update and delete with an empty filter a no-op instead of affecting all rows, and made updateMany return the number of affected rows.
  • Fixed counter updates on the memory, Kysely, Drizzle, Prisma, and MongoDB adapters to be atomic by default, ensuring correct rate limiting and API-key usage limit enforcement.

For detailed changes, see CHANGELOG

@better-auth/scim

Bug Fixes

  • Fixed organization-scoped SCIM deletes to remove members through the organization adapter, ensuring team memberships and member-removal hooks are applied correctly.
  • Fixed SCIM bearer token comparison to use constant-time evaluation, closing a timing side channel that could help an attacker recover a valid token.

For detailed changes, see CHANGELOG

@better-auth/core

Bug Fixes

  • Fixed provider identity validation for Google One Tap, Microsoft Entra ID, SSO, WeChat, and Reddit sign-in to enforce tenant restrictions and reject tokens issued for other applications (#10003)

For detailed changes, see CHANGELOG

@better-auth/drizzle-adapter

Bug Fixes

  • Fixed updateMany to return the number of rows it affected, as the adapter contract specifies.

For detailed changes, see CHANGELOG

@better-auth/electron

Bug Fixes

  • Fixed a race condition where an Electron authorization code could be exchanged for a session more than once when the exchange was attempted concurrently.

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter

Bug Fixes

  • Fixed SQLite mutations through the Bun and Node Kysely drivers to correctly report affected row counts and inserted row IDs, fixed multiple query parameter binding in the Bun driver, and made consumeOne work on SQL Server.

For detailed changes, see CHANGELOG

@better-auth/oauth-provider

Bug Fixes

  • Fixed token introspection and revocation to cache signing keys per auth instance instead of fetching them from the database on every request.

For detailed changes, see CHANGELOG

@better-auth/passkey

Bug Fixes

  • Fixed passkey challenge validation to reject registration challenges used for authentication (and vice versa), and to fail when the target user cannot be resolved.

For detailed changes, see CHANGELOG

@better-auth/prisma-adapter

Bug Fixes

  • Fixed the Prisma adapter to surface delete errors instead of silently reporting success when a deletion fails for any reason other than the record being absent.

For detailed changes, see CHANGELOG

@better-auth/redis-storage

Bug Fixes

  • Fixed Redis-backed rate-limit windows to set expiry once when the window opens instead of extending it with continued traffic, and added an atomic increment method to Redis secondary storage.

For detailed changes, see CHANGELOG

@better-auth/stripe

Bug Fixes

  • Fixed several Stripe subscription issues: reused existing customers by email only when verified, synced subscription status from the checkout session on success, scoped cancellation and restoration to the targeted subscription, validated returnUrl against trustedOrigins, and checked all subscriptions on organization deletion (#9971)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

arnnvvBekacrubytaesuGautamBytesgustavovalverdeSferaDev

Full changelog: v1.6.16...v1.6.17

better-auth

Bug Fixes

  • Fixed SIWE verification to bind the signed message to server state before creating a session, preventing acceptance of signatures produced for a different message, earlier nonce, or unrelated domain.
  • Fixed PayPal ID token verification to validate the signature, issuer, audience, expiration, and nonce against PayPal's JWKS (RS256) or client secret (HS256), rejecting tokens that pass only structural checks.
  • Fixed Google hd (hosted domain) enforcement to verify the hd claim on the verified ID token and callback profile, preventing accounts outside the configured Workspace domain from signing in.
  • Fixed verifyAccessToken remote introspection to reject tokens with a missing or mismatching aud claim
  • Fixed the admin plugin to enforce permissions on role, ban, and email fields in /admin/create-user and /admin/update-user, and prevent data from overriding protected fields. (#9974)
  • Fixed email sign-in and sign-up to validate Origin and Referer headers against trustedOrigins even when requests carry no cookies. (#9973)
  • Fixed /update-session to reject plugin-managed fields (activeOrganizationId, activeTeamId, impersonatedBy) with a 400 error
  • Fixed /update-session and account token routes to immediately reject deleted sessions when cookie cache is enabled alongside database or secondary storage. (#9967)
  • Fixed /refresh-token to only trust the account cookie when its userId, providerId, and accountId match the resolved session user.
  • Fixed generic OAuth sign-in to reject sign-ins when no account ID can be resolved from the provider response, preventing account collisions on providers that omit sub.
  • Fixed createInvitation and acceptInvitation to validate that all requested team IDs belong to the invitation's organization, preventing cross-organization team membership.
  • Fixed the JWKS cache to be scoped per verification source with a TTL, preventing key cross-contamination when verifying tokens against multiple issuers simultaneously.
  • Fixed the Reddit provider to stop storing oauth_client_id as the user email, preventing all users of the same app from sharing a single email address
  • Fixed Facebook token verification to validate tokens against the configured app via the debug_token endpoint, requiring is_valid, a matching app_id, and a client secret for direct sign-in.

For detailed changes, see CHANGELOG

@better-auth/oauth-provider

Bug Fixes

  • Fixed the token endpoint to enforce per-client grant types, preventing clients registered only for authorization_code from requesting client_credentials tokens.
  • Fixed /oauth2/continue to derive post-login gate completion from a server-issued session marker rather than the client-submitted postLogin flag.
  • Fixed token introspection to require an azp claim and a valid client on JWT access tokens, preventing session JWTs from being reported as active access tokens.

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed SAML AuthnRequest handling to consume the request atomically, preventing replay attacks on concurrent requests. (#9972)
  • Fixed SSO provider IDs to be isolated from the OAuth/social account-linking namespace, preventing unintended account linking when an SSO provider ID matches a trusted OAuth provider name.
  • Fixed OIDC endpoint validation to reject server-side requests resolving to non-publicly-routable addresses, protecting against SSRF on token, userinfo, and JWKS endpoints.

For detailed changes, see CHANGELOG

@better-auth/api-key

Bug Fixes

  • Fixed API key verification to persist only the fields it mutates rather than the full record, preventing concurrent disables, permission changes, or expiry updates from being reverted by an in-flight verification.
  • Fixed /api-key/create to verify the session against the authoritative store with disableCookieCache: true, preventing revoked sessions from being accepted within the cookie-cache window.

For detailed changes, see CHANGELOG

@better-auth/electron

Bug Fixes

  • Fixed Electron auth transfers to require S256 PKCE at both minting and exchange, rejecting plain and missing code_challenge_method values.

For detailed changes, see CHANGELOG

@better-auth/scim

Bug Fixes

  • Fixed SCIM user provisioning to return 409 when a user with the same email already exists unless linkExistingUsers is set, changed org-scoped DELETE to deprovision the user rather than delete the global account, and added canGenerateToken to control SCIM token creation.

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

Bekacrugustavovalverde

Full changelog: v1.6.15...v1.6.16

better-auth

Bug Fixes

  • Fixed the listSessions endpoint to properly enforce fresh-age session checks (#9865)
  • Fixed unbanUser, setRole, and adminUpdateUser to return USER_NOT_FOUND instead of a generic 500 when the target user does not exist (#9875)
  • Fixed Kysely migration constant import path to restore Kysely 0.28 and 0.29 compatibility (#9811)
  • Improved cookie regex character ranges for more accurate cookie parsing (#9879)

For detailed changes, see CHANGELOG

@better-auth/oauth-provider

Features

  • Added POST support to the /oauth2/userinfo endpoint, allowing the access token to be passed in the Authorization header (#9937)

Bug Fixes

  • Fixed hooks.before and hooks.after to run correctly when OAuth authorization resumes after sign-in, account selection, or consent (#9919)

For detailed changes, see CHANGELOG

@better-auth/kysely-adapter

Bug Fixes

  • Fixed Turbopack build failures by inlining migration table constants, also restoring compatibility with Kysely 0.28 and 0.29 (#9933)

For detailed changes, see CHANGELOG

@better-auth/passkey

Features

  • Added automatic resolution of authenticator names from AAGUID, exposing getAuthenticatorName(aaguid) and commonAuthenticatorNames so passkeys can display a friendly provider name like "1Password" or "Google Password Manager" (#9927)

For detailed changes, see CHANGELOG

@better-auth/sso

Bug Fixes

  • Fixed ERR_SUBJECT_UNCONFIRMED errors caused by clockSkew not being forwarded to samlify's ServiceProvider when validating SAML responses (#9748)

For detailed changes, see CHANGELOG

Contributors

Thanks to everyone who contributed to this release:

bytaesugustavovalverdeping-maxwellseebykilianWilsonnnTanzeroknowledge0x

Full changelog: v1.6.14...v1.6.15