NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
npm · #4985 most downloaded on npm
The most comprehensive authentication framework for TypeScript.
Last release 4 days ago
30 Sep 2026
Ships fairly regularly
a new release about every 9 days
Most releases are documented
notes for 40 of the last 60 stable releases
Nothing withdrawn
no release was ever pulled
2 years old
935 releases · first in 2024
Fixed a critical Magic Link account-takeover vulnerability.
better-authMagic Link upgrade: Upgrade servers sharing verification storage together, request new Magic Links, and restart pending OAuth/SAML sign-ins. No database migration is required. See the critical advisory for affected configurations and custom storage changes.
disableSignUp setting. (#11491)Content-Type header. (#11476)Content-Type header. (#11469)For detailed changes, see CHANGELOG
@better-auth/oauth-providervalidateRedirectUri validation for trusted deployments with dynamic OAuth redirect URIs. (#8686)verifyOAuthQueryParams to verify signed authorization queries before rendering a custom consent page. (#11402)For detailed changes, see CHANGELOG
@better-auth/drizzle-adapterFor detailed changes, see CHANGELOG
@better-auth/kysely-adapterconsumeOne deleting a record after a concurrent write invalidates its original condition. (#11495)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@aryan1306, @bytaesu, @gitmotion, @gustavovalverde, @lennondotw
Full changelog: v1.7.6...v1.7.7
One column per month.
#11476 4186e36 Thanks @bytaesu! - Return CAPTCHA errors with the correct JSON Content-Type header.
#11469 8620aa9 Thanks @aryan1306! - Return rate limit errors with a JSON Content-Type header.
#11491 55cb92e Thanks @bytaesu! - Respect social provider disableSignUp when signing in with an ID token.
#11375 69defbc Thanks @bytaesu! - Refresh the active organization after sign-in when a session hook selects the initial organization.
#11494 ac54bfd Thanks @gustavovalverde! - Isolate OAuth state cookies and each OAuth Proxy payload with purpose-specific encryption keys. The oAuthProxy options and supported configuration remain unchanged.
Upgrade all Better Auth nodes that handle the same cookie-backed OAuth or SAML relay-state flow together. Upgrade every OAuth Proxy participant, including production and preview or development deployments, in the same cutover. OAuth sign-in, account-linking, and cookie-backed SAML sign-in flows started before the upgrade must be restarted. Mixed old and new participants cannot exchange existing state or proxy payloads, and there is no fallback to the previous shared key.
#11494 ac54bfd Thanks @gustavovalverde! - Magic Link verification now accepts only records issued for Magic Link. Magic
Link records and database-backed OAuth or SAML state use separate verification
identifier prefixes. Links and database-backed sign-ins started before the
upgrade cannot complete; request new Magic Links and restart those sign-ins.
Upgrade servers sharing verification storage together, and update
verification.storeIdentifier.overrides rules for these flows to match the new
magic-link: and auth-state: prefixes. The link token, callback state,
endpoints, and public option types are unchanged.
Upgrade installed Better Auth adapters, plugins, and integrations released
with better-auth alongside it so participating packages use the same release
version.
Updated dependencies [35d7cd3, 07bdf7e]:
Added support for a bannedUserMessage function that receives the banned user, allowing sign-in errors to include details such as the ban reason.
better-authbannedUserMessage function that receives the banned user, allowing sign-in errors to include details such as the ban reason. (#11325)maxPasswordLength are now rejected with PASSWORD_TOO_LONG before hashing or verification. (#11324)For detailed changes, see CHANGELOG
@better-auth/kysely-adapterINTEGER PRIMARY KEY columns without AUTOINCREMENT. (#11374)For detailed changes, see CHANGELOG
@better-auth/prisma-adapterFor detailed changes, see CHANGELOG
@better-auth/coreFor detailed changes, see CHANGELOG
@better-auth/drizzle-adapterFor detailed changes, see CHANGELOG
@better-auth/memory-adapterFor detailed changes, see CHANGELOG
@better-auth/mongo-adapterFor detailed changes, see CHANGELOG
@better-auth/stripeFor detailed changes, see CHANGELOG
authcheck and check schema commands to validate your configured adapter schema on demand. (#11314)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @davbrito, @rwieruch, @Smidge, @Wadiou, @XXMOHAMED012
Full changelog: v1.7.5...v1.7.6
#11325 af88385 Thanks @Wadiou! - Admin plugin bannedUserMessage can now be a function that receives the banned user, so sign-in errors can include details such as the ban reason.
#11268 2fa501c Thanks @bytaesu! - Support linking social accounts through the OAuth Proxy plugin.
#11366 d41e2ca Thanks @bytaesu! - Use targeted PRAGMA queries when a Kysely dialect cannot introspect Cloudflare D1.
#11016 3d0efa3 Thanks @davbrito! - Support Vercel BotID checks on protected authentication routes in Vercel-hosted applications.
#11333 631ac29 Thanks @bytaesu! - Preserve logical model identity when a custom model name matches another schema key.
#11324 8853419 Thanks @XXMOHAMED012! - Passwords longer than maxPasswordLength are now rejected with PASSWORD_TOO_LONG before hashing on sign-in (email, username, phone number), verify-password, change-password (currentPassword), delete-user, the two-factor endpoints that take a password, and admin create-user, matching what sign-up and password reset already did.
#11316 2ee1545 Thanks @Smidge! - Fix React hydration mismatches when a session or plugin auth query resolves before a streamed component hydrates. Preserve the server-rendered pending state during hydration, then update to the current client state without changing ordinary or computed plugin stores.
#11376 fc45d08 Thanks @bytaesu! - Prevent older auth-query responses from replacing newer results when requests overlap.
Updated dependencies [41b7dc1, d41e2ca, 631ac29, 2b13e01]:
Added database.schemaName support for direct PostgreSQL connections.
better-authdatabase.schemaName support for direct PostgreSQL connections. (#11203)better-sqlite3 peer dependency to prevent installation conflicts. (#11209)For detailed changes, see CHANGELOG
@better-auth/coredatabase.schemaName support for direct PostgreSQL connections. (#11203)For detailed changes, see CHANGELOG
@better-auth/cimdFor detailed changes, see CHANGELOG
@better-auth/drizzle-adapterFor detailed changes, see CHANGELOG
@better-auth/kysely-adapterdatabase.schemaName support for direct PostgreSQL connections. (#11203)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @dshukertjr, @siam923
Full changelog: v1.7.4...v1.7.5
#11283 e56c45b Thanks @bytaesu! - Log Cloudflare Turnstile error codes and binding mismatches on the server so CAPTCHA verification failures can be diagnosed.
#11209 8d37cc3 Thanks @siam923! - Remove the unused optional better-sqlite3 peer dependency to prevent installation conflicts.
#11272 348fc26 Thanks @bytaesu! - Use MySQL's reported byte lengths when validating indexes on existing string columns.
#11203 cb627eb Thanks @dshukertjr! - Add a database.schemaName option for direct PostgreSQL connections. When set, the adapter and the CLI qualify every statement with that schema, so auth generate writes a schema-qualified migration that creates the schema before its tables instead of relying on the connection's search_path.
#11270 133f6a2 Thanks @bytaesu! - Prevent PostgreSQL migrations from treating tables in other schemas or views in the active schema as Better Auth tables.
Updated dependencies [e18bc83, cb627eb, dae97ed]:
Added experimental.instrumentation.enabled to disable OpenTelemetry span creation per auth instance.
better-authexperimental.instrumentation.enabled to disable OpenTelemetry span creation per auth instance. (#11224)testUtils auth helpers. (#11217)For detailed changes, see CHANGELOG
@better-auth/coreexperimental.instrumentation.enabled to disable OpenTelemetry span creation per auth instance. (#11224)For detailed changes, see CHANGELOG
@better-auth/expoFor detailed changes, see CHANGELOG
@better-auth/drizzle-adapterFor detailed changes, see CHANGELOG
@better-auth/test-utilsFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
Full changelog: v1.7.3...v1.7.4
#11205 3f890eb Thanks @bytaesu! - Support Vitest 5 in the testing utilities while retaining support for previously supported Vitest versions.
#11224 c1756a2 Thanks @bytaesu! - Add experimental.instrumentation.enabled to disable Better Auth OpenTelemetry span creation per auth instance. Instrumentation remains enabled by default and independent of usage reporting.
#11217 9b9638e Thanks @onmax! - Allow testUtils auth helpers to accept additional session fields through the session option, including required fields without defaults and per-session overrides of configured defaults.
Updated dependencies [3ff842a, b905bfe, c1756a2]:
The legacy /oauth-proxy-callback endpoint is deprecated and will be removed in the next minor release.
Upgrading from 1.7.0–1.7.2:
We restored the 1.6 account core schema to avoid requiring a disruptive backfill for existing users. We recognize the cost to users who already migrated and are committed to keeping the core schema stable throughout v1.
If you applied the 1.7 issuer schema, follow the upgrade guide for the required cleanup. No backfill is needed.
better-authisPasswordCompromised for checking passwords against Have I Been Pwned in custom server-side flows. (#11147)getSession failures when cookie caching is disabled and cached session cookies remain. (#11120)useFetch. (#11084)useFetch integration for the Vue client's useSession hook. (#11085)(providerId, accountId) instead of issuer. (#11153)For detailed changes, see CHANGELOG
@better-auth/coreconsumeOne and incrementOne methods for custom database adapters. (#11189)clientKey and clientSecret options. (#11102)(providerId, accountId) instead of issuer. (#11153)For detailed changes, see CHANGELOG
authauth info to report installed dependency versions instead of declared specifiers. (#11126)auth init to complete when generated setup groups have no dependencies. (#11140)auth upgrade with a clear warning. (#11127)For detailed changes, see CHANGELOG
@better-auth/drizzle-adapterFor detailed changes, see CHANGELOG
@better-auth/test-utilsFor detailed changes, see CHANGELOG
@better-auth/cimdERR_INVALID_IP_ADDRESS on supported Node.js versions. (#10730)For detailed changes, see CHANGELOG
@better-auth/expoFor detailed changes, see CHANGELOG
@better-auth/kysely-adapterFor detailed changes, see CHANGELOG
@better-auth/oauth-providerlocalhost loopback redirects to use ephemeral callback ports. (#11090)For detailed changes, see CHANGELOG
@better-auth/prisma-adapterFor detailed changes, see CHANGELOG
@better-auth/sso(providerId, accountId) instead of issuer. (#11153)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@BetterAndBetterII, @bytaesu, @erikpr1994, @gustavovalverde, @harshil1712, @onmax, @Salman-Arshad, @starslingdev[bot], @supercell02, @thisismert
Full changelog: v1.7.2...v1.7.3
#11060 3660f06 Thanks @bytaesu! - Handle malformed custom-scheme callback URLs without excessive processing.
#11037 5bd7096 Thanks @bytaesu! - Prevent repeated TOTP enrollment from replacing an active authenticator and its backup codes.
#11120 7ec7146 Thanks @onmax! - Prevent getSession from failing when cookie caching is disabled while clients still have cached session cookies.
#9908 76d311f Thanks @harshil1712! - Add Cloudflare as a built-in social provider, with support for client-secret authentication and PKCE clients without a secret.
#11188 c47b765 Thanks @bytaesu! - Normalize Auth0 domains without a potentially slow trailing-slash regular expression.
#11084 2d5c63d Thanks @bytaesu! - Prevent duplicate session requests and hydration mismatches when using the Vue client with Nuxt useFetch.
#11147 a9d8c12 Thanks @bytaesu! - Add isPasswordCompromised for checking passwords against Have I Been Pwned in custom server-side flows, while ignoring padded response entries with zero occurrences.
#10988 9fc7498 Thanks @bytaesu! - Run callback hooks after proxied OAuth sign-ins and preserve server state when callback cookies are unavailable. The legacy /oauth-proxy-callback endpoint is deprecated and will be removed in the next minor release.
#11178 be0e007 Thanks @bytaesu! - Report missing tables, missing columns, and required columns Better Auth never writes during initialization, with guidance for fixing them. Kysely checks the live database schema. Authentication requests await the same check and are rejected if the schema does not match.
Validation is enabled by default, including in production. Set advanced.database.validateSchema: false to disable runtime validation. auth migrate refuses to apply changes when required unwritten columns need manual repair.
#11069 0bb0dbf Thanks @bytaesu! - Improve dynamic organization role permission check performance.
#11153 2220ee7 Thanks @bytaesu! - Restore sign-in compatibility with 1.6 databases by identifying accounts with (providerId, accountId) and removing the issuer requirement introduced in 1.7.0. Upgrading from 1.6 no longer requires an account schema migration. Ambiguous account keys are rejected instead of selecting an arbitrary account.
If you applied the 1.7.0 through 1.7.2 account schema, remove its issuer unique index before upgrading. For SQL databases, also make issuer nullable or remove the column so sign-ups and account linking can succeed. auth migrate does not perform this cleanup. Follow the upgrade guide for database-specific steps.
#10978 5fe5bc2 Thanks @BetterAndBetterII! - Skip a generic OAuth provider when discovery fails instead of taking down the rest of the auth API.
#10963 74a7369 Thanks @thisismert! - Track email OTP sign-ins in the last login method plugin.
#11085 e16b40a Thanks @bytaesu! - Provide type-safe Nuxt useFetch integration for the Vue client's useSession hook.
#11066 c0444dc Thanks @bytaesu! - Upgrade the packaged Zod dependency to 4.5. Generated OpenAPI schemas now mark required request fields consistently with runtime validation, including passkey registration responses.
Updated dependencies [352d012, 76d311f, 3e9e197, 157ec8d, baa08f4, 9e36635, be0e007, a2bae0c, 1a1b7d5, 2220ee7]:
Fixed permanent user bans to clear expiration dates from previous temporary bans.
better-auth~ in relative callback URLs validated by trusted-origin checks. (#10041)Referrer-Policy: no-referrer while continuing to reject untrusted origins. (#10959)getTestInstance performance with a faster default password hasher. (#10879){identifier}@{namespace}.placeholder.invalid format. (#10982)For detailed changes, see CHANGELOG
@better-auth/core{identifier}@{namespace}.placeholder.invalid format. (#10982)For detailed changes, see CHANGELOG
@better-auth/oauth-providerFor detailed changes, see CHANGELOG
@better-auth/drizzle-adapterusePlural is enabled. (#10941)where clauses. (#10859)For detailed changes, see CHANGELOG
@better-auth/kysely-adapterFor detailed changes, see CHANGELOG
@better-auth/ssoFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @GautamBytes, @heliohm, @sosyz, @starslingdev[bot]
Full changelog: v1.7.1...v1.7.2
#10875 d5d889b Thanks @bytaesu! - Fix programmatic migrations failing on Cloudflare D1 while preserving existing-index validation across supported databases.
#10982 b4ad5a1 Thanks @bytaesu! - Built-in placeholder emails now consistently use the namespaced {identifier}@{namespace}.placeholder.invalid format.
#10934 c7a5c1a Thanks @bytaesu! - Cookie-cache reads now warn when signed session data is invalid instead of silently appearing as a signed-out session.
#10879 78f0c39 Thanks @starslingdev! - Test suites using getTestInstance now run faster because the shared fixture avoids production password-hashing costs by default. Custom emailAndPassword.password implementations continue to take precedence.
#10823 ce8a3ab Thanks @sosyz! - Ensure permanently banning a user clears any expiration from a previous temporary ban.
#10907 a021eaf Thanks @heliohm! - A client created with more plugins is again assignable to a client type declaring fewer plugins, as in 1.6.
#10959 c8dcfa5 Thanks @bytaesu! - Allow same-origin form submissions from pages using Referrer-Policy: no-referrer while continuing to reject untrusted request origins.
#10979 fced1a5 Thanks @bytaesu! - Allow relative callback and redirect URLs to use standard path, query, and fragment syntax while preserving open-redirect protections.
#10041 f6891a2 Thanks @GautamBytes! - Allow ~ in relative callback URLs validated by trusted origin checks.
#10877 649818a Thanks @bytaesu! - Prevent disabled MyISAM indexes from satisfying migration index checks.
Updated dependencies [557e19b, 64da15b, d5d889b, b4ad5a1, ea77118, 5aea9f7, fced1a5, e1d4011]:
Added native database transaction support to test instances for PostgreSQL and MySQL.
better-authjose, nanostores, noble crypto packages, SimpleWebAuthn) to their latest compatible releases, with no changes required to existing projects.For detailed changes, see CHANGELOG
@better-auth/scimactive and the primary sub-attribute of emails, phoneNumbers, addresses, roles, and entitlements at the HTTP ingress, improving Microsoft Entra interoperability.managedConnections to allow trusted server code to create runtime tenant connections and issue, rotate, and revoke bearer credentials through server-only auth.api methods, without a code-defined connection or an application-owned verifier.For detailed changes, see CHANGELOG
@better-auth/ssowantAssertionsSigned now correctly controls whether the SP requires signed assertions, matching real-world IdP signing behavior.For detailed changes, see CHANGELOG
@better-auth/cimds-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 now converge on a single client-resource link instead of failing on a unique constraint.For detailed changes, see CHANGELOG
@better-auth/kysely-adapternode:sqlite, bun:sqlite, mysql2, pg) passed directly as database, matching the behavior of the explicit { db }/{ dialect } config shapes. Plugins requiring native transactions (such as @better-auth/scim) now work correctly when using the quickstart database: new Database(...) form.For detailed changes, see CHANGELOG
@better-auth/oauth-provider403 with an RFC 6750 insufficient_scope WWW-Authenticate challenge naming every missing scope, allowing clients to request all needed scopes in a single authorization request.For detailed changes, see CHANGELOG
authFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
Full changelog: v1.7.0...v1.7.1
#10863 845bbd1 Thanks @gustavovalverde! - auth migrate no longer attempts to add a required column with no default value to a table that already has rows. It stops with an error naming the column and the backfill to run first. Previously the generated statement failed on SQLite, Postgres, and SQL Server; on MySQL it filled the new column with an empty string for every existing row and reported success. If auth migrate already ran against a MySQL database on 1.7, run the check in the upgrade guide's account identity section.
getMigrations throws the new UnsafeMigrationError (exported from better-auth/db/migration) for this refusal, so callers can distinguish it from other migration errors such as an index-definition conflict.
auth generate still emits the statements for external migration tooling, with a comment banner naming any column that needs a manual backfill first.
A required field whose database column is still nullable logs a warning instead of blocking the migration.
A CLI command that fails now prints its error and exits with a non-zero code instead of an unhandled promise rejection.
Updated dependencies []:
Removed the deprecated oidcProvider plugin
Blog post: Better Auth 1.7
better-authexperimental into the stable advanced.database.joins option (#10359)
Migration: Replace
experimental: { joins: true }withadvanced: { database: { joins: true } }. Drizzle and Prisma users should regenerate their schema (npx auth@latest generate) so it includes the required relations.
(issuer, accountId) (#10403)
Migration: Accounts now require
Account.issuer. Read provider identity fromaccountInfo.account.accountId, dropmapping.idfrom SSO configs, and give themicrosoftEntraIdhelper a concrete tenant GUID. Apply the account-identity backfill in the 1.7 upgrade guide before deploying.
Migration: Replace partial paths such as
/sign-inwith explicit wildcards like/sign-in/*or/sign-in/**.
@better-auth/mcp package built on the OAuth provider (#9992)
Migration: Install
@better-auth/mcpand@better-auth/cimd, add the now-requiredjwt()plugin, and move options nested underoidcConfigto flatmcp({ ... })options. RenamewithMcpAuthtorequireMcpAuthandmcpHandlertocreateMcpProtectedRequestHandler. Regenerate the schema (npx auth migrate):oauthApplicationbecomesoauthClient, plus newoauthRefreshTokenandoauthClientAssertiontables.
Migration: Introspecting an access token whose session has ended now returns
{ active: false }, and/oauth2/userinforejects it. Clients opt into notifications by registeringbackchannel_logout_uri. Run the schema migration for the newoauthClientandoauthAccessTokencolumns.
Migration:
validAudiencesis removed: move each resource identifier intoresourcesand link restricted clients throughoauthClientResource.@better-auth/mcpnow requires an explicitresource. Runnpx @better-auth/cli generateand apply the migration before deploying.
Migration: SCIM configuration, client APIs, database schema, and the Group model are all replaced, and provisioning state cannot migrate in place. Follow the SCIM cutover in the 1.7 upgrade guide, including a full directory reprovision, before resuming traffic.
enableTwoFactor response (#9057)
Migration:
enableTwoFactornow returns amethodfield ("otp"or"totp"); narrow on it before readingtotpURIandbackupCodes. Passmethod: "otp"for OTP enrollment, which requiresotpOptions.sendOTP.
Host by default when using a dynamic baseURL (#9134)
Migration: If your proxy exposes the public hostname only through
x-forwarded-host, setadvanced.trustedProxyHeaders: true. Deployments where the proxy rewritesHost(nginx default, Vercel, Cloudflare, Netlify) are unaffected.
deviceCode and userCode columns (#10059)
Migration: Resolve duplicate code values before applying the migration. MySQL and SQL Server installations must also convert both columns to bounded strings and clean up values longer than 191 characters.
Migration: Upgrade the
@better-auth/electronclient and server together and add your app's scheme totrustedOrigins. Thecode_challenge_methodparameter anddisableOriginOverrideoption are removed, and host-bearing custom-scheme entries now match that host exactly.
oid claim (#10204)
Migration: Migrate existing Microsoft account rows created from
subbefore upgrading. Tokens without a validoidare rejected.
Migration: Configure
oneTap({ clientId })orsocialProviders.google.clientId.
oidcProvider plugin (#10031)
Migration: Move OIDC authorization-server integrations to
@better-auth/oauth-provider.
Migration: Replace
signIn.oauth2({ providerId })withsignIn.social({ provider }),oauth2.link()withlinkSocial(), and dropgenericOAuthClient(). Callbacks move to/api/auth/callback/:id,pkcenow defaults totrue, andissuerandrequireIssuerValidationare removed in favor of OIDC discovery.
oauthDeviceAuthorization() (#10746)
Migration: The OAuth integration replaces the optional
resourcecolumn withoauthClientIdandresources, so regenerate and apply the schema. Let pending device codes expire before upgrading from an earlier 1.7 prerelease.
id_tokens with a single shared verifier (#9828)
Migration: Custom
UpstreamProviderimplementations replace the removedverifyIdTokenmethod with anidTokenconfig carrying a JWKS source, issuer, and audience. PayPal clientid_tokensign-in now returnsID_TOKEN_NOT_SUPPORTED; its redirect flow is unchanged.
clientAssertion support to the Microsoft Entra ID social provider (#9898)Auth instance directly fetchable (#9431)requireEmailVerification for social sign-in (#9929)user.validateUserInfo gate for rejecting an identity before a user is created or linked (#9864)hydrateSession so useSession returns server-fetched data on the first render (#8733)allowIdpInitiated support for IdP-initiated flows through a secure server-side bounce (#9301)signOut() can also sign users out of the OpenID provider (#9368)refreshTokenParams for forwarding extra parameters on generic OAuth token refresh (#9948)id_tokens against the provider JWKS and enabled id_token sign-in for generic OAuth (#9966)at_hash claim to ID tokens issued alongside an access token, per OIDC Core §3.1.3.6 (#9079)private_key_jwt client authentication for token endpoint requests (RFC 7523) (#8836)Cache-Control: no-store on every OAuth response that carries credentials (#10065)additionalParams and loginHint across signIn.social, linkSocial, and signIn.sso (#9305)userId and organizationId to the listUserTeams API (#8977)organization.getOrganization() for metadata-only fetches (#10397)consumePhoneNumberOTP API for custom phone OTP flows (#9766)displayUsername field (#10330)drizzle-kit peer dependency range (#10299)auth migrate to add required columns with static defaults and nullable unique columns to existing tables (#10293)nonce to the authorization request in the generic OAuth redirect flow (#10095)redirect_uri from the per-request base URL in multi-host deployments (#10127)account.scope values across re-authentication and token refresh (#10128)overrideUserInfo returns null (#10124)secondaryStorage (#9969)createAuthClient (#10505)For detailed changes, see CHANGELOG
@better-auth/oauth-provider@better-auth/mcp package built on the OAuth provider (#9992)
Migration: Install
@better-auth/mcpand@better-auth/cimd, add the now-requiredjwt()plugin, and move options nested underoidcConfigto flatmcp({ ... })options. RenamewithMcpAuthtorequireMcpAuthandmcpHandlertocreateMcpProtectedRequestHandler. Regenerate the schema (npx auth migrate):oauthApplicationbecomesoauthClient, plus newoauthRefreshTokenandoauthClientAssertiontables.
Migration: Introspecting an access token whose session has ended now returns
{ active: false }, and/oauth2/userinforejects it. Clients opt into notifications by registeringbackchannel_logout_uri. Run the schema migration for the newoauthClientandoauthAccessTokencolumns.
Migration: Add
applicationTypeand nullableclientDiscoveryIdcolumns, deduplicate existing(clientId, resourceId)links before the new compound unique index, then drop the legacytypeandpubliccolumns. ReplaceclientCredentialGrantDefaultScopeswith per-clientclientCredentialsScopes, backfilling every client to[]and reassigning approved machine scopes after an audit.mcp()no longer enables unauthenticated DCR: compose it withcimd()or enable both DCR flags explicitly.
max_age authorization request parameter (#9936)
Migration: Users who authenticated longer ago than the requested
max_ageare now sent back to log in, and the ID token'sauth_timereflects the fresh login. Flows that relied onmax_agebeing ignored will prompt again.
Migration:
customIdTokenClaims, extension claims, and per-issuanceidTokenClaimscan no longer set protocol claims such as issuer, subject, audience,nonce,auth_time,acr,amr, orazp; use namespaced custom claims instead. ID tokens now carryacr: "0"and discovery advertises only"0".
Migration:
validAudiencesis removed: move each resource identifier intoresourcesand link restricted clients throughoauthClientResource.@better-auth/mcpnow requires an explicitresource. Runnpx @better-auth/cli generateand apply the migration before deploying.
Migration: Remove
grantTypefromprovider.authenticateClient(...), and return{ clientId, confirmation? }from a customOAuthClientAuthenticationStrategy.authenticateinstead of a client record.
Migration: Token and refresh requests may only narrow the authorization's
resource; a broader request returnsinvalid_target.customAccessTokenClaimsnow receives aresourcesarray in place of theresourcestring. Run the schema migration to add the new resource columns.
Migration: Authorization errors now redirect to a registered client's trusted redirect URI with
stateandissinstead of rendering the server error page, and confidential clients must use their registeredtoken_endpoint_auth_method.
Migration: Replace
signIn.oauth2({ providerId })withsignIn.social({ provider }),oauth2.link()withlinkSocial(), and dropgenericOAuthClient(). Callbacks move to/api/auth/callback/:id,pkcenow defaults totrue, andissuerandrequireIssuerValidationare removed in favor of OIDC discovery.
oauthDeviceAuthorization() (#10746)
Migration: The OAuth integration replaces the optional
resourcecolumn withoauthClientIdandresources, so regenerate and apply the schema. Let pending device codes expire before upgrading from an earlier 1.7 prerelease.
@better-auth/cimd Client ID Metadata Document plugin (#9159)at_hash claim to ID tokens issued alongside an access token, per OIDC Core §3.1.3.6 (#9079)sessionId to id_token claim contributors (#10113)silenceWarnings option and the well-known endpoint warnings it suppressed (#10703)private_key_jwt client authentication for token endpoint requests (RFC 7523) (#8836)Cache-Control: no-store on every OAuth response that carries credentials (#10065)aud of private_key_jwt client assertions (#10811)offline_access without PKCE (#10153)401 invalid_token challenge from /oauth2/userinfo for invalid tokens (#10068)POST support and confirmation pages (#10812)acr claim requests per OIDC Core (#10790)profile and email scope claims on the UserInfo response instead of ID tokens (#10152)private_key_jwt jti single-use atomic across processes (#9964)redirect_uri conditional at the token endpoint, required only when the authorization included one (#10159)response_type to the verified client redirect URI (#10149)invalid_grant and revoked tokens issued from that code (#10150)unsupported_token_type when revoking a JWT access token (#9970)openid scope for authorization requests that use the claims parameter (#10791)invalid_grant when a client presents a refresh token issued to another client (#10154)403 with an RFC 6750 insufficient_scope challenge naming every one of them, so clients can request them in a single authorization redirect.For detailed changes, see CHANGELOG
@better-auth/coreexperimental into the stable advanced.database.joins option (#10359)
Migration: Replace
experimental: { joins: true }withadvanced: { database: { joins: true } }. Drizzle and Prisma users should regenerate their schema (npx auth@latest generate) so it includes the required relations.
(issuer, accountId) (#10403)
Migration: Accounts now require
Account.issuer. Read provider identity fromaccountInfo.account.accountId, dropmapping.idfrom SSO configs, and give themicrosoftEntraIdhelper a concrete tenant GUID. Apply the account-identity backfill in the 1.7 upgrade guide before deploying.
Migration: SCIM configuration, client APIs, database schema, and the Group model are all replaced, and provisioning state cannot migrate in place. Follow the SCIM cutover in the 1.7 upgrade guide, including a full directory reprovision, before resuming traffic.
oid claim (#10204)
Migration: Migrate existing Microsoft account rows created from
subbefore upgrading. Tokens without a validoidare rejected.
id_tokens with a single shared verifier (#9828)
Migration: Custom
UpstreamProviderimplementations replace the removedverifyIdTokenmethod with anidTokenconfig carrying a JWKS source, issuer, and audience. PayPal clientid_tokensign-in now returnsID_TOKEN_NOT_SUPPORTED; its redirect flow is unchanged.
clientAssertion support to the Microsoft Entra ID social provider (#9898)requireEmailVerification for social sign-in (#9929)user.validateUserInfo gate for rejecting an identity before a user is created or linked (#9864)allowIdpInitiated support for IdP-initiated flows through a secure server-side bounce (#9301)signOut() can also sign users out of the OpenID provider (#9368)refreshTokenParams for forwarding extra parameters on generic OAuth token refresh (#9948)includeGrantedScopes option to the Google provider (#10129)private_key_jwt client authentication for token endpoint requests (RFC 7523) (#8836)Cache-Control: no-store on every OAuth response that carries credentials (#10065)additionalParams and loginHint across signIn.social, linkSocial, and signIn.sso (#9305)client_id SSRF checks through the shared host classifier, which now rejects IPv4-compatible IPv6, the 6to4 relay prefix, and site-local addresses (#10126)redirect_uri from the per-request base URL in multi-host deployments (#10127)account.scope values across re-authentication and token refresh (#10128)createAuthClient (#10505)For detailed changes, see CHANGELOG
@better-auth/sso(issuer, accountId) (#10403)
Migration: Accounts now require
Account.issuer. SSO subjects are protocol-defined (subfor OIDC, signedNameIDfor SAML) andmapping.idis removed; a manual SAML config without metadata XML must setidpMetadata.entityID. Apply the account-identity backfill in the 1.7 upgrade guide before deploying.
Migration:
getSSOProvider,listSSOProviders, andupdateSSOProvidernow always returnsamlConfig.certificateas an array, so drop anyArray.isArraybranching. Registration rejects SAML configs with no signing-cert source withCERT_SOURCE_MISSING.
validateUserInfo source contract so it cannot be bypassed or spoofed (#9940)
Migration:
createUsernow fails closed whenvalidateUserInfois configured but no endpoint context or provisioning source is available. Read SSO metadata fromsource.ssoinstead ofsource.oauth, and handle thesource.methodvaluessso-oidcandsso-saml.
InResponseTo, audience restriction, and SessionIndex (#9055)
Migration:
allowIdpInitiatednow defaults tofalse. Setsaml.allowIdpInitiated: trueto keep accepting unsolicited SAML responses.
spMetadata optional, and fixed Single Logout (#9117)
Migration: Point your IdP's ACS URL at
/sso/saml2/sp/acs/:providerId;/sso/saml2/callback/:providerIdis removed.callbackUrlis now the post-auth redirect only, and the unuseddecryptionPvk,additionalParams,idpMetadata.entityURL, andidpMetadata.redirectURLfields are gone.
user.validateUserInfo gate for rejecting an identity before a user is created or linked (#9864)allowIdpInitiated support for IdP-initiated flows through a secure server-side bounce (#9301)private_key_jwt client authentication for token endpoint requests (RFC 7523) (#8836)additionalParams and loginHint across signIn.social, linkSocial, and signIn.sso (#9305)resolveUser to SAML sign-ins and hardened the provider lifecycle (#10621)additionalFields support on ssoProvider (#9445)wantAssertionsSigned now controls whether signed assertions are required rather than signed response messages, matching how IdPs sign SAML responses in practice.For detailed changes, see CHANGELOG
@better-auth/scimMigration: SCIM configuration, client APIs, database schema, and the Group model are all replaced, and provisioning state cannot migrate in place. Follow the SCIM cutover in the 1.7 upgrade guide, including a full directory reprovision, before resuming traffic.
Migration: Define connections statically, resolve them with
authentication.verifyBearerToken, or use the optionalmanagedConnectionscatalog, and connect SCIM resources to users and roles through identity and projection callbacks. Legacy SCIM state is not migrated: back it up, issue new credentials, and fully reprovision Users and Groups.
Migration: The legacy connection management endpoints and
providerOwnershipare gone, so authorize SCIM administration in your own application. LegacyscimProviderrows and credentials are not migrated: follow the 1.7 SCIM upgrade guide, issue new credentials, and reprovision Users and Groups.
user.validateUserInfo gate for rejecting an identity before a user is created or linked (#9864)acquireActiveSCIMUserLink for transaction-safe authentication of provisioned users (#10474)active and the primary sub-attribute of emails, phoneNumbers, addresses, roles, and entitlements at the HTTP ingress, for Microsoft Entra interoperability.managedConnections so trusted server code can 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.For detailed changes, see CHANGELOG
@better-auth/mcp ✨@better-auth/mcp package built on the OAuth provider (#9992)
Migration: Install
@better-auth/mcpand@better-auth/cimd, add the now-requiredjwt()plugin, and move options nested underoidcConfigto flatmcp({ ... })options. RenamewithMcpAuthtorequireMcpAuthandmcpHandlertocreateMcpProtectedRequestHandler. Regenerate the schema (npx auth migrate):oauthApplicationbecomesoauthClient, plus newoauthRefreshTokenandoauthClientAssertiontables.
Migration: Add
applicationTypeand nullableclientDiscoveryIdcolumns, deduplicate existing(clientId, resourceId)links before the new compound unique index, then drop the legacytypeandpubliccolumns. ReplaceclientCredentialGrantDefaultScopeswith per-clientclientCredentialsScopes, backfilling every client to[]and reassigning approved machine scopes after an audit.mcp()no longer enables unauthenticated DCR: compose it withcimd()or enable both DCR flags explicitly.
Migration:
mcp()now requires an explicitresourceidentifier, for exampleresource: "https://api.example.com/mcp".validAudiencesis removed: move each resource identifier intoresources. Runnpx @better-auth/cli generateand apply the migration before deploying.
For detailed changes, see CHANGELOG
@better-auth/electronMigration: Upgrade the
@better-auth/electronclient and server together and add your app's scheme totrustedOrigins. Thecode_challenge_methodparameter anddisableOriginOverrideoption are removed, and host-bearing custom-scheme entries now match that host exactly.
Migration: Replace
signIn.oauth2({ providerId })withsignIn.social({ provider }),oauth2.link()withlinkSocial(), and dropgenericOAuthClient(). Callbacks move to/api/auth/callback/:id,pkcenow defaults totrue, andissuerandrequireIssuerValidationare removed in favor of OIDC discovery.
createAuthClient (#10505)For detailed changes, see CHANGELOG
@better-auth/expoMigration:
getCookie()now returns a promise, and custom storage implementations must provide both synchronous and asynchronous SecureStore methods.storageAdapter.setItem()stays synchronous, so usesetItemAsync()when the write must be awaited.
Migration: Replace
signIn.oauth2({ providerId })withsignIn.social({ provider }),oauth2.link()withlinkSocial(), and dropgenericOAuthClient(). Callbacks move to/api/auth/callback/:id,pkcenow defaults totrue, andissuerandrequireIssuerValidationare removed in favor of OIDC discovery.
createAuthClient (#10505)For detailed changes, see CHANGELOG
@better-auth/stripeevent parameter of onSubscriptionCancel required (#9531)
Migration: Declare
eventas a required parameter in your callback and remove anyundefinedguards around it.
onSubscriptionCancel event parameter (#9359)
Migration:
eventis always supplied, so dropundefinedhandling from the callback.
For detailed changes, see CHANGELOG
authgrantedScopes string array (#9825)
Migration:
grantedScopesreplaces the comma-joinedaccount.scopestring with no read-time fallback, so backfill it from the existing values. The client provider contract is renamed fromOAuthProvidertoUpstreamProvider.
create-admin command for creating an initial admin user (#9547)Account.accountId alongside the required issuer (#10668)pgSchema binding so drizzle-kit can emit CREATE SCHEMA for custom PostgreSQL namespaces (#10770)resolveModule, adding support for the export default { auth } shape (#9477)account.scope storage (#10123)For detailed changes, see CHANGELOG
@better-auth/drizzle-adapterexperimental into the stable advanced.database.joins option (#10359)
Migration: Replace
experimental: { joins: true }withadvanced: { database: { joins: true } }, and regenerate the schema (npx auth@latest generate) so it includes the required relations.
relations-v2 entry point for projects using Drizzle Relations v2 (#9489)schemaName option that generates the Drizzle schema inside a pgSchema namespace (#7169)pgSchema binding so drizzle-kit can emit CREATE SCHEMA for custom PostgreSQL namespaces (#10770)For detailed changes, see CHANGELOG
@better-auth/cimd ✨Migration: Add
applicationTypeand nullableclientDiscoveryIdcolumns, deduplicate existing(clientId, resourceId)links before the new compound unique index, then drop the legacytypeandpubliccolumns. Client ID Metadata Documents preserve an omittedapplication_typeasnulland cannot assignclientCredentialsScopes. Composemcp()withcimd()for metadata-document clients.
@better-auth/cimd Client ID Metadata Document plugin (#9159)s-maxage over max-age and Expires, honors s-maxage=0, revalidates conditionally 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-keyMigration: Custom adapters must implement native
consumeOneandincrementOne, secondary storage needs atomic consume and increment operations, and custom rate-limit storage makes oneconsumedecision per request. The read-then-delete and read-then-update fallbacks are removed.
For detailed changes, see CHANGELOG
@better-auth/kysely-adapternode:sqlite, bun:sqlite, mysql2, pg) passed directly as database now get native adapter transactions automatically, matching the behavior of the explicit { db } and { 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/i18nFor detailed changes, see CHANGELOG
@better-auth/mongo-adapterFor detailed changes, see CHANGELOG
@better-auth/passkeycreateSession setting that signs the user in on successful passkey registration (#9873)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@adrianmxb, @brentmitchell25, @bytaesu, @dvanmali,
Note truncated.
#8733 4e8e4c7 Thanks @bytaesu! - Add hydrateSession to seed the client with a server-fetched session so useSession returns data on the first render.
#9930 0cbaf81 Thanks @gustavovalverde! - Anonymous account linking now works after social and generic OAuth sign-in in Expo and other in-app browsers, where the OAuth callback returns without the session cookie. onLinkAccount fires and the anonymous user is migrated; before, it was silently skipped.
Plugins can now carry server-trusted data across an OAuth redirect with the new addOAuthServerContext API, read back on the callback via getOAuthState().serverContext. Unlike additionalData, it cannot be set from the request body, so it is the right place for values the server must trust.
For @better-auth/oauth-provider, the post-login authorization query now travels through that server-only channel, so it can no longer be injected through additionalData.
#10004 b36c38f Thanks @bytaesu! - The captcha plugin now requires endpoint entries to match full auth paths unless they use wildcard patterns. This prevents requests like /sign-in//email from bypassing captcha while preserving trailing-slash matches like /sign-in/email/. To protect multiple routes, replace partial paths like /sign-in with explicit wildcards such as /sign-in/* or /sign-in/**.
#10746 6782647 Thanks @gustavovalverde! - OAuth device grants now use oauthDeviceAuthorization() alongside oauthProvider() or mcp(). This single integration replaces both the standalone deviceCodeGrant() plugin and the shared-grant configuration. Standalone Device Authorization no longer accepts or stores RFC 8707 resources, and onDeviceAuthRequest receives only clientId and scope. The OAuth integration rejects resource indicators that are not absolute, fragment-free URIs.
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.
#10402 763a267 Thanks @gustavovalverde! - Plugin database schemas can now define named or generated table-level indexes across multiple fields. SQL migrations and generated Drizzle or Prisma schemas resolve configured table and column names consistently, while the MongoDB adapter creates the same indexes before the first index-enforcing write.
#9766 bf39cbf Thanks @GautamBytes! - Add a server-only auth.api.consumePhoneNumberOTP API for custom phone OTP flows that need to verify and consume a code without creating or updating users or sessions.
#10330 081d3c3 Thanks @ping-maxwell! - Allow the username plugin's separate displayUsername field to be omitted by
setting displayUsername: false on both the server and client plugins.
#10059 49b5cf6 Thanks @GautamBytes! - Device Authorization now creates unique database indexes for deviceCode and userCode, so each generated code must be unique in its column. Existing installations on every adapter must resolve duplicate values before applying the migration. MySQL and SQL Server installations must also convert both columns to bounded strings and clean up values longer than 191 characters before running it.
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.
#9645 e014029 Thanks @ping-maxwell! - Harden the Electron OAuth flow and tighten custom-scheme trusted-origin matching.
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.
#9948 3d04fab Thanks @yordis! - feat(generic-oauth): add refreshTokenParams config to forward extra params on token refresh
Multi-tenant OIDC providers (Zitadel multi-org, Auth0 with audience) need to send extra body params on the refresh call to rescope tokens without a full authorization redirect. The generic-oauth plugin now accepts a refreshTokenParams option (object or sync/async function) that is merged into the refresh request body, with grant_type and refresh_token protected from override. The function form receives request metadata for the request that triggered the refresh, so request-scoped data (headers, cookies) is available without out-of-band state like AsyncLocalStorage.
UpstreamProvider.refreshAccessToken now accepts an optional second ctx argument; the change is backwards compatible because existing implementations that take only refreshToken remain valid. See #7554.
#9069 c7d2253 Thanks @gustavovalverde! - Rewrite the generic OAuth plugin as a first-class social provider with OAuth 2.1 security defaults. Providers now use signIn.social + callback/:id instead of dedicated plugin endpoints, with PKCE required by default (OAuth 2.1), RFC 9207 issuer validation, OIDC auto-discovery with openid scope injection, and typed provider IDs.
Breaking changes:
signIn.oauth2({ providerId }) replaced by signIn.social({ provider })oauth2.link() replaced by linkSocial()/api/auth/oauth2/callback/:id to /api/auth/callback/:idgenericOAuthClient() removed; generic OAuth providers now use the standard social client APIspkce defaults to true (was false); set pkce: false for providers that reject PKCEauthorizationUrlParams and tokenUrlParams only accept Record<string, string>issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discoverymapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>#9966 ec8a38c Thanks @gustavovalverde! - genericOAuth providers configured with a discoveryUrl now verify the provider's id_token against its published JWKS (signature, issuer, audience, and advertised algorithms) and bind it to the authorization request with a server-generated OIDC nonce. A sign-in whose id_token fails verification, or does not echo the expected nonce, is rejected.
Set disableIdTokenNonceBinding: true on a provider that does not return the nonce claim in the authorization-code flow.
These providers also accept client-submitted id_token sign-in through signIn.social({ idToken }), which previously returned ID_TOKEN_NOT_SUPPORTED.
Providers configured with explicit endpoints instead of discoveryUrl are unchanged.
#9368 430c895 Thanks @GautamBytes! - Generic OAuth users can now sign out from the configured OpenID provider when they call authClient.signOut(). When a provider exposes a discovered or configured logout endpoint, Better Auth redirects to it and includes the stored id_token_hint when available. Pass callbackURL or configure postLogoutRedirectURI for the return flow, with optional state, or set disableRedirect to handle the returned url yourself. When multiple linked providers support logout, Better Auth selects the most recently updated account. Set disableProviderLogout: true to keep sign-out local.
#9431 523f95c Thanks @pi0! - feat: make Auth instance fetchable
#10577 5c45abc Thanks @gustavovalverde! - 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.
requiredScopes through RequireMcpAuthOptions or the matching createMcpProtectedRequestHandler verifier option. Exact membership remains the default; isScopeSatisfied can define hierarchical policies.createInsufficientScopeError when an operation determines its required scopes dynamically. createResourceServerChallenge converts that signal and recognized token failures into safe RFC 6750 challenges.challengeScopes only as the unauthenticated challenge hint.Handler-produced responses, ordinary permission denials, configuration failures, and unrelated thrown values keep their original status and identity.
#10403 dbd302e Thanks @gustavovalverde! - Scope account identity by trusted issuer instead of provider configuration. Accounts now use the unique (issuer, accountId) key, so aliases for one OpenID Connect issuer deduplicate one external identity while equal subjects from different issuers remain separate. This identity deduplication does not introduce independent grant or provider lifecycle records for aliases.
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.
#10359 8784c1c Thanks @ping-maxwell! - Database joins have moved out of experimental into a stable option at advanced.database.joins (default: false).
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).
#9992 e53582c Thanks @gustavovalverde! - The MCP plugin moves out of better-auth into its own package, @better-auth/mcp, built on @better-auth/oauth-provider. Import the authorization plugin and protected-request helpers from the package root. The in-core MCP client (createMcpAuthClient and its adapters) is removed; MCP protocol and transport clients come from the official version 2 @modelcontextprotocol/client and @modelcontextprotocol/server packages. The OAuth endpoints move from /mcp/* to /oauth2/*, with discovery at /.well-known/oauth-authorization-server and protected resource metadata at /.well-known/oauth-protected-resource. Discovery-based MCP clients pick up the new locations on their own.
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.
#10204 0683a5f Thanks @GautamBytes! - Microsoft sign-in now identifies Entra accounts with the stable oid claim in both the built-in microsoft provider and the Generic OAuth microsoftEntraId helper. Tokens without a valid oid are rejected, and the Generic OAuth helper refuses to initialize unless Microsoft discovery provides ID-token verification metadata. Existing Microsoft account rows created from sub must be migrated before upgrading.
#9305 e7eb45b Thanks @gustavovalverde! - feat(oauth): per-request additionalParams and loginHint parity across signIn.social, linkSocial, and signIn.sso
Unified escape hatch for customizing the provider authorization URL on a per-request basis. Previously, dynamic parameters like Google's access_type=offline / prompt=consent, Cognito's identity_provider=Google, or Microsoft's domain_hint could only be set as static server configuration.
signIn.social, linkSocial, and signIn.sso accept additionalParams: Record<string, string>. Values are appended to the authorization URL as query parameters.linkSocial also accepts loginHint, matching the surface of signIn.social and signIn.sso.OAuthProvider.createAuthorizationURL gains additionalParams in its input contract; every built-in provider forwards it to the shared helper.additionalParams with the config-level authorizationUrlParams; call-time wins on key collision.identityProvider?: string config option that maps to the identity_provider query parameter, avoiding magic strings.createAuthorizationURL helper silently drops any caller-supplied key in RESERVED_AUTHORIZATION_PARAMS (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, nonce, scope). The request-body Zod schema rejects the same keys with 400, so misuse is visible at the edge rather than silently overriding security-critical parameters. nonce is reserved so a caller cannot replace the OIDC nonce Better Auth generates when binding a discovery provider's id_token to the authorization request.wechat → appid, tiktok → client_key) additionally filter those keys so a caller cannot swap the configured OAuth app.atlassian → audience, notion → owner) are merged last so caller-supplied additionalParams cannot override them. Configured defaults that represent operator intent (e.g. Google include_granted_scopes, Cognito identityProvider) remain caller-overridable.signIn.sso rejects additionalParams with 400 when the resolved provider is SAML; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters, so silently dropping them would mislead integrators.ZodRecord handling to the OpenAPI generator so z.record() fields emit type: object with typed additionalProperties. Incidentally fixes a long-standing bug where additionalData was rendered as type: string.discord, roblox, zoom, and slack providers now delegate to the shared createAuthorizationURL helper and inherit its RFC behavior and reserved-key guard.tiktok and wechat keep their manual URL construction (non-standard OAuth2 parameter names and URL fragment requirements) but thread additionalParams with the same reserved-key filter.Closes #2351. Closes #5441. Closes #5592. Closes #5604. Supersedes #4992 and #5443.
#10127 7c7313c Thanks @gustavovalverde! - OAuth sign-in, account linking, callback, and proxy flows now build redirect_uri from the current request base URL when baseURL.allowedHosts is configured. Built-in social providers and generic OAuth providers now use the resolved request host for redirects in multi-host deployments.
Custom OAuthProvider implementations can omit callbackPath when using the shared /callback/<provider-id> route. Set callbackPath only for custom callback routes.
#10039 aedcb97 Thanks @gustavovalverde! - feat(oauth-provider)!: DPoP-bound access tokens (RFC 9449)
OAuth provider integrations can issue and verify DPoP sender-constrained tokens. Clients request them with dpop_bound_access_tokens at registration, dpop_jkt on the authorization request, or by targeting a resource configured with dpopBoundAccessTokensRequired. Issued tokens carry cnf.jkt, return token_type: "DPoP", and stay bound through refresh-token rotation, introspection, and userinfo.
Resource servers verify DPoP requests with verifyAccessTokenRequest, which checks the Authorization: DPoP scheme, the proof, the request target, the access-token hash, and proof replay. The MCP package advertises DPoP in protected resource metadata and verifies DPoP-bound requests. Proof replay is rejected through the database-backed verification store, so anti-replay holds across instances. verifyAccessTokenRequest and requireMcpAuth use that store by default; build one with createDpopReplayStore(internalAdapter) or pass a custom dpop.replayStore. This needs database-backed verification storage: a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
Breaking: the raw-token verifier verifyAccessToken is renamed to verifyBearerToken, both in better-auth/oauth2 and as the oauthProviderResourceClient action, and it rejects DPoP-bound tokens. Use verifyAccessTokenRequest on any endpoint that may receive them. The resource-request input type is renamed from AccessTokenRequestInput to ResourceRequestInput, and the DPoP algorithm option is signingAlgorithms everywhere.
Run a schema migration for the DPoP token-binding fields: the confirmation column on the access-token and refresh-token tables. DPoP-bound clients also gain dpopBoundAccessTokens and resources dpopBoundAccessTokensRequired. No dedicated replay table is added; proof replay reuses the verification store.
#9828 4f53b61 Thanks @gustavovalverde! - Verify social-provider id_tokens with a single shared verifier.
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.
#9079 6f2948e Thanks @gustavovalverde! - feat(oauth-provider): compute at_hash in ID tokens per OIDC Core §3.1.3.6
ID tokens issued alongside an access token now include the at_hash claim, which cryptographically binds the two tokens to prevent token substitution attacks. The hash algorithm is selected based on the actual signing key's algorithm (EdDSA/Ed25519 uses SHA-512, RS/ES/PS384 uses SHA-384, RS/ES/PS512 uses SHA-512, all others use SHA-256).
A new resolveSigningKey() export is available from better-auth/plugins to resolve the current JWKS signing key (including its algorithm). When using a custom jwt.sign callback, the signed ID token's header is validated against the declared algorithm to prevent at_hash mismatches.
#10135 f68044d Thanks @brentmitchell25! - Registered OAuth clients can now use the RFC 8628 device flow to obtain OAuth access tokens. Add oauthDeviceAuthorization() alongside oauthProvider() or mcp(), request a code at /device/code, and exchange it at /oauth2/token after the user approves it. OAuth and OpenID discovery advertise the device_authorization_endpoint.
Device authorization requests can bind RFC 8707 resource indicators. GET /device returns the requested client, scopes, and resources to the authenticated user who owns the request. Token requests can reuse or narrow the approved resources, but cannot add new ones. Existing first-party device clients continue to receive Better Auth session tokens from /device/token.
Enabling oauthDeviceAuthorization() adds nullable oauthClientId and resources fields to deviceCode. Regenerate and apply the database schema after adding the integration.
Confidential clients authenticate at /device/code with their registered method, while public clients send client_id. Empty client_id, scope, user_id, and authentication values are treated as omitted; multiple non-empty values for any of these parameters return invalid_request, while multiple resource values remain supported. Unknown OAuth client IDs enter the standalone device flow only when oauthDeviceAuthorization({ validateClient }) accepts them.
#9929 91f235f Thanks @gustavovalverde! - Add requireEmailVerification to OAuth provider options, for built-in social providers and the Generic OAuth plugin. When a provider reports an unverified email, the user and account are still created or linked, but no session is issued: the OAuth callback redirects with ?error=email_not_verified, and ID token and One Tap sign-in return 403 EMAIL_NOT_VERIFIED. Verification emails follow the existing emailVerification.sendOnSignUp / sendOnSignIn settings.
It is opt-in per provider and does not inherit emailAndPassword.requireEmailVerification, so existing social logins keep working. The gate checks the local user's verification state, so a user verified through another method keeps access. Only enable it for providers that report a trustworthy email_verified signal.
#9648 d2a79ba Thanks @brentmitchell25! - OAuth provider now models protected resources explicitly. Configure them with resources or create them through the oauthResource admin API. Each resource can define token TTLs, allowed scopes, custom JWT claims, and JWT signing pins.
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 auth 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".
#10397 bb6c102 Thanks @ping-maxwell! - Add organization.getOrganization() to fetch organization metadata without members or invitations.
#8931 34558bc Thanks @GautamBytes! - Add opt-in JWKS-backed asymmetric JWT support for session_data cookie cache tokens, so services can verify cookie-cache JWTs with public keys instead of shared secrets. Enable it with jwt({ sessionCookieCache: true }) alongside session.cookieCache.strategy = "jwt".
#8977 954b664 Thanks @ruban-s! - allow passing userId and organizationId to the listUserTeams API. userId lets callers list teams for another member of an organization (gated behind the member:update permission). organizationId scopes the result to a specific organization without needing to switch the session's active organization, matching the pattern used by addTeamMember/removeTeamMember.
#9969 76a3342 Thanks @gustavovalverde! - Signing out with secondaryStorage and session.preserveSessionInDatabase now runs your configured session.delete hooks and marks the preserved session row as ended. OAuth Provider access and refresh tokens bound to that session are revoked and back-channel logout is dispatched on sign-out. Previously these hooks were skipped in this setup, so the tokens stayed valid until they expired.
#9657 1e5b808 Thanks @gustavovalverde! - Harden private_key_jwt and token endpoint client authentication, and add the helpers that make the fix structural.
@better-auth/core/oauth2 now exposes encodeBasicCredentials and decodeBasicCredentials, a round-trip-tested pair that follows RFC 6749 §2.3.1 (application/x-www-form-urlencoded each value, split on the first : only). The decoder accepts the scheme case-insensitively and tolerates one or more spaces before the credentials per RFC 7235 §2.1. client_secret_basic on the client side and the Better Auth OAuth provider on the server side both go through these helpers, so credentials containing reserved characters round-trip cleanly across the stack and headers like basic xxx or Basic xxx are accepted.
createPrivateKeyJwtClientAssertionGetter validates options eagerly. Unsupported algorithms (HS256, none), a JWK with no key material, and disagreement between an explicit algorithm and the JWK-embedded alg all throw at construction rather than on the first token request. signPrivateKeyJwtClientAssertion enforces the same checks for direct callers. Breaking: configurations that paired an unsupported JWK alg with a different explicit algorithm used to silently sign with the explicit option; they now fail at construction.
Breaking: @better-auth/oauth-provider accepts client jwks metadata only as an RFC 7517 JWK Set object with a non-empty keys array. Replace jwks: [key] with jwks: { keys: [key] } in DCR payloads, administrative and user client creation, Client ID Metadata Documents, test fixtures, and generated client code. Remotely fetched jwks_uri responses must use the same object shape. EC keys must use P-256, P-384, or P-521; OKP keys must use Ed25519. When a key declares alg, it must be a supported private_key_jwt algorithm that matches the key type and curve; omit alg when the client chooses the algorithm in its assertion header. OAuth client rows previously written through oauthToSchema are already stored as JWK Set objects, so this is a request, configuration, and type migration rather than another database rewrite; audit rows written outside Better Auth separately.
The SSO private_key_jwt flow redirects with error_description=no_private_key_available when a resolvePrivateKey callback returns no privateKeyJwk or privateKeyPem. The redirect path previously short-circuited only when the resolver was absent entirely; an empty resolver return fell through into an internal signing error.
better-auth/test adds getHttpTestInstance, a counterpart to getTestInstance that binds a real HTTP listener on an OS-assigned port and constructs the auth instance against the discovered URL. It removes the temp-server-then-rebind race that test files have been individually copy-pasting.
#8836 93d3871 Thanks @gustavovalverde! - Add client authentication configuration for token endpoint requests across the stack, including private_key_jwt (RFC 7523).
Generic OAuth providers now accept tokenEndpointAuth for token endpoint client authentication. Use tokenEndpointAuth: { method: "private_key_jwt", getClientAssertion } for JWT client assertions, { method: "none" } for public clients, and { method: "client_secret_basic" } or { method: "client_secret_post" } with clientSecret for explicit secret-based client authentication. The existing authentication: "basic" | "post" option remains available for secret-based token requests.
Use createPrivateKeyJwtClientAssertionGetter() to sign RFC 7523 assertions from a private key. The assertion getter receives { clientId, tokenEndpoint, grantType }, so integrations do not duplicate client ID or token endpoint values inside assertion helpers. Core OAuth2 now exports private-key JWT-specific helpers and types: signPrivateKeyJwtClientAssertion, createPrivateKeyJwtClientAssertionGetter, PrivateKeyJwtSigningAlgorithm, and PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.
Token endpoint client authentication parameters are derived from clientId, clientSecret, and tokenEndpointAuth. Configured token endpoint authentication requires clientId; secret-based token endpoint authentication also requires clientSecret. Custom token parameters are for provider-specific fields and do not replace the configured client authentication values.
refreshAccessToken() now forwards resource values to refresh-token requests, so RFC 8707 resource indicators work through both the high-level refresh helper and refreshAccessTokenRequest().
The synchronous OAuth2 request builders createAuthorizationCodeRequest, createRefreshAccessTokenRequest, and createClientCredentialsTokenRequest have been removed. Use the async authorizationCodeRequest, refreshAccessTokenRequest, and clientCredentialsTokenRequest helpers instead.
Servers verify JWT client assertions signed with asymmetric keys, and clients can use the same token endpoint authentication contract for authorization code, refresh, and client credentials token requests.
#9134 652fa53 Thanks @gustavovalverde! - The dynamic baseURL config now ignores x-forwarded-host and x-forwarded-proto unless you set advanced.trustedProxyHeaders: true.
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,
},
});
#9240 729c00d Thanks @adrianmxb! - feat(username): add immutable username option
This allows users to set their username during sign-up or first update, but prevents changing it to a different value afterwards. Users can still update other profile fields.
#10031 6fe9faa Thanks @gustavovalverde! - Remove the deprecated oidcProvider plugin from better-auth/plugins. Migrate OIDC authorization-server integrations to @better-auth/oauth-provider.
#10473 ed61b47 Thanks @gustavovalverde! - Add transactional OIDC user resolution so applications can link verified issuer and subject pairs to exact existing users while preserving or updating the local profile.
#10234 973fdde Thanks @gustavovalverde! - The SIWE plugin now issues nonces before the wallet address or Chain ID is known. authClient.siwe.nonce() and authClient.siwe.getNonce() no longer accept wallet fields, getNonce must return an ERC-4361 nonce (8-250 alphanumeric characters), and SIWE verification now reads the wallet address and Chain ID from the signed ERC-4361 message.
#9864 41cca60 Thanks @GautamBytes! - Add a user.validateUserInfo provisioning gate that lets applications reject an identity before a user is created or a new account is linked. It runs once at the creation step for every method that provisions a user (OAuth, SSO/SAML, email/password, magic link, email OTP, anonymous, SIWE, phone number, admin-created users, and SCIM), including stateless setups with no persistent database.
It also re-runs when an existing OAuth or SSO user signs in again (source.action is "sign-in"), where it receives the fresh provider email and profile so a domain or org policy can reject a user whose provider identity moved out of bounds. Non-provider returning sign-ins are not re-validated.
The callback receives the mapped user plus a source describing the action (create-user, link-account, or sign-in), the method, and provider metadata: source.oauth for OAuth providers and source.sso for OIDC/SAML SSO providers. Return { error, errorDescription } to reject: browser flows redirect to the error URL and programmatic flows return a 403.
#10036 ad35ead Thanks @bytaesu! - Require Google One Tap server callbacks to resolve a Google client ID before verifying ID tokens. Configure oneTap({ clientId }) or socialProviders.google.clientId when using the One Tap plugin.
#9057 544f1c6 Thanks @gustavovalverde! - feat(two-factor)!: add OTP-only enablement and a discriminated response
enableTwoFactor now accepts a method parameter ("otp" | "totp", default "totp") and returns a discriminated response with a method field.
method: "otp"twoFactorEnabled: true immediately.{ method: "otp" }.otpOptions.sendOTP to be configured on the server; rejects with OTP_NOT_CONFIGURED otherwise.method: "totp" (default){ method: "totp", totpURI, backupCodes }.TOTP_NOT_CONFIGURED if totpOptions.disable is set.The existing skipVerificationOnEnable option remains supported for TOTP enrollment.
enableTwoFactor includes a method field in the response ("otp" or "totp").#10014 73541c1 Thanks @gustavovalverde! - Cloudflare Workers apps can now start when importing Better Auth subpaths such as better-auth/db. Beta builds were crashing during module initialization before application code ran.
#10299 cf8eaac Thanks @momomuchu! - widen drizzle-kit peer dependency range
#10501 65fc17c Thanks @KingIronMan2011! - Expand the optional drizzle-orm peer range to ^0.45.2 || >=1.0.0-rc.1 <2.0.0, matching @better-auth/drizzle-adapter and allowing Drizzle ORM v1 RC installations without peer dependency warnings.
#10622 ecd83da Thanks @gustavovalverde! - Sign-up no longer deadlocks when session cookie caching uses the JWT strategy on a single-connection SQLite database with native transactions enabled. JWKS key lookups and creation now resolve the transaction-scoped adapter instead of always querying the root connection, so minting a signing key during sign-up joins the surrounding transaction instead of racing it for the only available connection. On multi-connection databases (Postgres, MySQL) this also fixes a silent atomicity gap where a JWKS key created mid-transaction could commit independently of the transaction it was minted in.
#10293 fe4c820 Thanks @gustavovalverde! - npx auth migrate can now add required columns with static defaults and nullable
unique columns to existing SQLite, PostgreSQL, and MySQL tables. Required unique
columns still need distinct values to be backfilled manually before applying the
unique constraint.
#9898 7fe0e2b Thanks @ItalyPaleAle! - Add clientAssertion support to the Microsoft Entra ID social provider.
#9301 03e6c94 Thanks @gustavovalverde! - Add allowIdpInitiated to GenericOAuthConfig and SSO OIDCConfig to support providers that initiate OAuth without a state parameter (e.g. Clever). When enabled, stateless callbacks restart the OAuth flow server-side with fresh state and PKCE, preserving CSRF protection. Also hardens parseState against undefined request bodies on GET callbacks.
#10065 2196ea6 Thanks @gustavovalverde! - OAuth and device-authorization responses that carry credentials now consistently send Cache-Control: no-store and Pragma: no-cache, so proxies, CDNs, and browsers never cache them. This covers the token, introspection, and userinfo endpoints, dynamic and admin client registration, client secret rotation, and the device code and device token responses, including the error responses from those endpoints.
Endpoints declare this with metadata: { noStore: true }, and the header set is exported from @better-auth/core as NO_STORE_HEADERS for responses built by hand.
#10124 06daf70 Thanks @gustavovalverde! - Preserve the resolved OAuth user when overrideUserInfo returns null during account linking.
#9304 e0d2b9e Thanks @gustavovalverde! - Propagate sign-out to every connected app and cut off API access immediately, via OIDC Back-Channel Logout 1.0.
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 | nulloauthClient.backchannelLogoutSessionRequired: booleanoauthAccessToken.revoked: Date | nullbetter-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.
#10125 a83152e Thanks @gustavovalverde! - Create new OAuth accounts in the user creation transaction. Adapters with native
transaction support roll back the user when the account write fails, while other
adapters still perform the writes sequentially.
#10128 97903c9 Thanks @gustavovalverde! - Preserve previously granted OAuth scopes across sign-in re-authentication and refresh-token requests. account.scope now accumulates monotonically: newly granted scopes are merged in only when added via linkSocial, and providers returning a narrower scope claim than the user has granted no longer shrink the stored value.
#10170 6ddb555 Thanks @gustavovalverde! - 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.
#10390 0de88f5 Thanks @gustavovalverde! - SCIM connections can now provision Users, Groups, and direct memberships into application-defined provisioning domains without the organization or SSO plugins. Applications can map Group membership to validated custom roles through projections. The service also supports SCIM 2.0 discovery, filtering, pagination, response attribute selection, atomic PATCH operations, and common request patterns used by Microsoft Entra ID and Okta.
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.
#10505 d701f90 Thanks @gustavovalverde! - One Tap, Electron, and Expo client plugins now compose with createAuthClient without TypeScript errors, and the resulting client preserves each plugin's inferred actions.
#10621 59c4c83 Thanks @gustavovalverde! - Allow test instances to enable native database transactions for postgres and mysql.
Updated dependencies [5c45abc, 763a267, 5d38b13, 692b22c, ea06c5a, 3d04fab, 430c895, de8394d, dbd302e, 8784c1c, ecd83da, e4818b5, 7fe0e2b, 0683a5f, e7eb45b, 7c7313c, aedcb97, 03e6c94, 4f53b61, 2196ea6, 91f235f, 34558bc, 1e5b808, 93d3871, 97903c9, ed61b47, 0de88f5, 3a79aff, 41cca60, d701f90
Restored client plugin declaration compatibility for downstream TypeScript consumers.
better-authFor detailed changes, see CHANGELOG
@better-auth/oauth-providerprivate_key_jwt client assertions to accept the issuer URL as a valid aud claim (string or array) on token, introspection, and revocation requests. (#10811)POST support, explicit confirmation pages, and strict post_logout_redirect_uri validation. (#10812)openid scope. (#10791)For detailed changes, see CHANGELOG
@better-auth/drizzle-adapterpgSchema export so drizzle-kit can emit CREATE SCHEMA for custom PostgreSQL namespaces. (#10770)For detailed changes, see CHANGELOG
@better-auth/electronFor detailed changes, see CHANGELOG
@better-auth/expoFor detailed changes, see CHANGELOG
authpgSchema export so drizzle-kit can emit CREATE SCHEMA for custom PostgreSQL namespaces. (#10770)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @gustavovalverde, @ping-maxwell
Full changelog: v1.7.0-rc.5...v1.7.0-rc.6
#10769 773de54 Thanks @bytaesu! - Prevent duplicate session requests during transient remounts while ensuring incomplete refreshes are revalidated.
#10794 2ad2928 Thanks @bytaesu! - Restore client plugin declaration compatibility for downstream TypeScript consumers.
Updated dependencies [692b22c]:
Refactored OAuth device grant ownership to use oauthDeviceAuthorization() alongside oauthProvider() or mcp()
better-authoauthDeviceAuthorization() alongside oauthProvider() or mcp() (#10746)Migration: Replace the standalone
deviceCodeGrant()plugin withoauthDeviceAuthorization()used alongsideoauthProvider()ormcp(). Regenerate and apply the schema (resourcecolumn is replaced byoauthClientIdandresources). Let any pending device codes expire or delete them before upgrading, as they cannot be exchanged through the new integration.
displayName in the username plugin (#10330)For detailed changes, see CHANGELOG
@better-auth/oauth-provideroauthDeviceAuthorization() alongside oauthProvider() or mcp() (#10746)Migration: Replace the standalone
deviceCodeGrant()plugin withoauthDeviceAuthorization()used alongsideoauthProvider()ormcp(). Regenerate and apply the schema (resourcecolumn is replaced byoauthClientIdandresources). Let any pending device codes expire or delete them before upgrading, as they cannot be exchanged through the new integration.
silenceWarnings config option and startup warnings for well-known metadata endpoints (#10703)For detailed changes, see CHANGELOG
@better-auth/scimbetter-call (#10657)For detailed changes, see CHANGELOG
authFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @gustavovalverde, @ping-maxwell
Full changelog: v1.7.0-rc.4...v1.7.0-rc.5
#10746 6782647 Thanks @gustavovalverde! - OAuth device grants now use oauthDeviceAuthorization() alongside oauthProvider() or mcp(). This single integration replaces both the standalone deviceCodeGrant() plugin and the shared-grant configuration. Standalone Device Authorization no longer accepts or stores RFC 8707 resources, and onDeviceAuthRequest receives only clientId and scope. The OAuth integration rejects resource indicators that are not absolute, fragment-free URIs.
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.
#10330 081d3c3 Thanks @ping-maxwell! - Allow the username plugin's separate displayUsername field to be omitted by setting displayUsername: false on both the server and client plugins.
Added a placeholder email utility for generating temporary email addresses
better-authfindSessions to skip null-parsed session tokens instead of returning early (#10580)jwtClient (#10513)oneTapClient (#10635)$fetch and $store not being exposed on the Solid client (#10444)next/headers import promise in production (#10467)For detailed changes, see CHANGELOG
@better-auth/expoMigration:
getCookie()now returns a Promise. Custom storage implementations must provide both sync and asyncSecureStoremethods, and should usesetItemAsync()when the write must be awaited.
For detailed changes, see CHANGELOG
@better-auth/redis-storageSCAN instead of KEYS to avoid blocking the server (#10507)For detailed changes, see CHANGELOG
@better-auth/scimnoTarget error (#10682)For detailed changes, see CHANGELOG
authFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@birkskyum, @bytaesu, @Emmaccen, @gustavovalverde, @jashkarangiya, @jeroenvandermerwe, @jlucaso1, @jsj, @krish-vachhani, @mrosberghaus, @XXMOHAMED012
Full changelog: v1.7.0-rc.3...v1.7.0-rc.4
#10676 90b5093 Thanks @bytaesu! - Deduplicate in-flight session requests when React retries a suspended component.
Updated dependencies []:
Configure protected scopes with requiredScopes through RequireMcpAuthOptions or the matching createMcpProtectedRequestHandler verifier option. Exact m
#10059 49b5cf6 Thanks @GautamBytes! - Device Authorization now creates database indexes for device and user code lookups. Codes longer than 191 characters are rejected. Existing MySQL and SQL Server installations must convert these columns to bounded strings and resolve oversized values before running the migration.
#9368 430c895 Thanks @GautamBytes! - Generic OAuth users can now sign out from the configured OpenID provider when they call authClient.signOut(). When a provider exposes a discovered or configured logout endpoint, Better Auth redirects to it and includes the stored id_token_hint when available. Pass callbackURL or configure postLogoutRedirectURI for the return flow, with optional state, or set disableRedirect to handle the returned url yourself. When multiple linked providers support logout, Better Auth selects the most recently updated account. Set disableProviderLogout: true to keep sign-out local.
#10577 5c45abc Thanks @gustavovalverde! - 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.
requiredScopes through RequireMcpAuthOptions or the matching createMcpProtectedRequestHandler verifier option. Exact membership remains the default; isScopeSatisfied can define hierarchical policies.createInsufficientScopeError when an operation determines its required scopes dynamically. createResourceServerChallenge converts that signal and recognized token failures into safe RFC 6750 challenges.challengeScopes only as the unauthenticated challenge hint.Handler-produced responses, ordinary permission denials, configuration failures, and unrelated thrown values keep their original status and identity.
#10204 0683a5f Thanks @GautamBytes! - Microsoft sign-in now identifies Entra accounts with the stable oid claim in both the built-in microsoft provider and the Generic OAuth microsoftEntraId helper. Tokens without a valid oid are rejected, and the Generic OAuth helper refuses to initialize unless Microsoft discovery provides ID-token verification metadata. Existing Microsoft account rows created from sub must be migrated before upgrading.
#10135 f68044d Thanks @brentmitchell25! - Registered OAuth clients can now use the RFC 8628 device flow to obtain provider-managed OAuth tokens. Add deviceCodeGrant() alongside deviceAuthorization() and oauthProvider(); clients request a code at /device/code and exchange it at /oauth2/token after the user approves it. OAuth and OpenID discovery now advertise device_authorization_endpoint.
Device authorization requests can bind RFC 8707 resource indicators. GET /device now returns the requesting client_id, scope, and resource values to the authenticated user who owns the request, and onDeviceAuthRequest receives the resource as its third argument. Token requests can reuse or narrow the approved resource set, but requests that add a resource are rejected. Existing first-party device clients continue to receive Better Auth session tokens from /device/token.
The deviceCode table adds an optional resource field. Run npx @better-auth/cli generate and apply the migration before deploying this update.
#10622 ecd83da Thanks @gustavovalverde! - Sign-up no longer deadlocks when session cookie caching uses the JWT strategy on a single-connection SQLite database with native transactions enabled. JWKS key lookups and creation now resolve the transaction-scoped adapter instead of always querying the root connection, so minting a signing key during sign-up joins the surrounding transaction instead of racing it for the only available connection. On multi-connection databases (Postgres, MySQL) this also fixes a silent atomicity gap where a JWKS key created mid-transaction could commit independently of the transaction it was minted in.
#10505 d701f90 Thanks @gustavovalverde! - One Tap, Electron, and Expo client plugins now compose with createAuthClient without TypeScript errors, and the resulting client preserves each plugin's inferred actions.
#10621 59c4c83 Thanks @gustavovalverde! - Allow test instances to enable native database transactions for postgres and mysql.
Updated dependencies [5c45abc, 430c895, 5c45abc, ecd83da, 0683a5f, d701f90]:
This release is breaking. Account.accountId is renamed to Account.providerAccountId, and Account.issuer is required. Account-specific APIs select the
#10402 763a267 Thanks @gustavovalverde! - Plugin database schemas can now define named or generated table-level indexes across multiple fields. SQL migrations and generated Drizzle or Prisma schemas resolve configured table and column names consistently, while the MongoDB adapter creates the same indexes before the first index-enforcing write.
#10403 dbd302e Thanks @gustavovalverde! - Scope account identity by trusted issuer instead of provider configuration. Accounts now use the unique (issuer, providerAccountId) key, so aliases for one OpenID Connect issuer deduplicate one external identity while equal subjects from different issuers remain separate. This identity deduplication does not introduce independent grant or provider lifecycle records for aliases.
This release is breaking. Account.accountId is renamed to Account.providerAccountId, and Account.issuer is required. Account-specific APIs select the local Account.id through accountId; token and provider-profile APIs can instead select the signed account cookie with useAccountCookie: true. Credential accounts use local:credential and the linked user's stable id as their provider identity.
OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses sub, plain OAuth uses id, and providers can declare accountSubject for another immutable field; Better Auth no longer switches between sub and id at runtime. getUserInfo().user no longer carries provider identity, and mapProfileToUser cannot return id. Read the selected identity from accountInfo.account.providerAccountId instead of accountInfo.user.id. The generic microsoftEntraId helper now requires a concrete tenant GUID; use the built-in Microsoft provider for multi-tenant authorities.
SSO account subjects are now protocol-defined. OIDC uses the verified sub claim, and SAML uses the signed NameID; mapping.id is removed from both configurations. A manual SAML configuration without metadata XML must set idpMetadata.entityID, because samlConfig.issuer identifies the service provider and no longer acts as the IdP identity.
Apply the reviewed account-identity backfill in the Better Auth 1.7 upgrade guide before deploying. The generated schema migration cannot assign trusted issuers or resolve existing identity collisions automatically.
#10359 8784c1c Thanks @ping-maxwell! - Database joins have moved out of experimental into a stable option at advanced.database.joins (default: false).
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).
#10397 bb6c102 Thanks @ping-maxwell! - Add organization.getOrganization() to fetch organization metadata without members or invitations.
#10473 ed61b47 Thanks @gustavovalverde! - Add transactional OIDC user resolution so applications can link verified issuer and subject pairs to exact existing users while preserving or updating the local profile.
#10234 973fdde Thanks @gustavovalverde! - The SIWE plugin now issues nonces before the wallet address or Chain ID is known. authClient.siwe.nonce() and authClient.siwe.getNonce() no longer accept wallet fields, getNonce must return an ERC-4361 nonce (8-250 alphanumeric characters), and SIWE verification now reads the wallet address and Chain ID from the signed ERC-4361 message.
#10299 cf8eaac Thanks @momomuchu! - widen drizzle-kit peer dependency range
#10390 0de88f5 Thanks @gustavovalverde! - SCIM connections can now provision Users, Groups, and direct memberships into application-defined provisioning domains without the organization or SSO plugins. Applications can map Group membership to validated custom roles through projections. The service also supports SCIM 2.0 discovery, filtering, pagination, response attribute selection, atomic PATCH operations, and common request patterns used by Microsoft Entra ID and Okta.
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.
Updated dependencies [763a267, 5d38b13, dbd302e, 8784c1c, e4818b5, ed61b47, 0de88f5]:
@better-auth/drizzle-adapter@1.7.0-rc.1
#10293 fe4c820 Thanks @gustavovalverde! - npx auth migrate no longer aborts when adding a column to an existing table. A required column with a default value, or a unique column, now migrates on SQLite and on populated Postgres and MySQL databases. Upgrading a database that already has organization teams previously failed on the new team.memberCount and teamMember.membershipKey columns.
Updated dependencies []:
@better-auth/drizzle-adapter@1.7.0-rc.0
Updated dependencies [`ea06c5a`]:
#10170 6ddb555 Thanks @gustavovalverde! - 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.
Updated dependencies [ea06c5a]:
@better-auth/drizzle-adapter@1.7.0-beta.9
Custom OAuthProvider implementations can omit callbackPath when using the shared /callback/ route. Set callbackPath only for custom callback routes.
#10127 7c7313c Thanks @gustavovalverde! - OAuth sign-in, account linking, callback, and proxy flows now build redirect_uri from the current request base URL when baseURL.allowedHosts is configured. Built-in social providers and generic OAuth providers now use the resolved request host for redirects in multi-host deployments.
Custom OAuthProvider implementations can omit callbackPath when using the shared /callback/<provider-id> route. Set callbackPath only for custom callback routes.
#10124 06daf70 Thanks @gustavovalverde! - Preserve the resolved OAuth user when overrideUserInfo returns null during account linking.
#10125 a83152e Thanks @gustavovalverde! - Create new OAuth accounts in the user creation transaction so failed account writes roll back the user row.
#10128 97903c9 Thanks @gustavovalverde! - Preserve previously granted OAuth scopes across sign-in re-authentication and refresh-token requests. account.scope now accumulates monotonically: newly granted scopes are merged in only when added via linkSocial, and providers returning a narrower scope claim than the user has granted no longer shrink the stored value.
Updated dependencies [7c7313c, 97903c9, 3a79aff]:
Multi-tenant OIDC providers (Zitadel multi-org, Auth0 with audience) need to send extra body params on the refresh call to rescope tokens without a fu
#9948 3d04fab Thanks @yordis! - feat(generic-oauth): add refreshTokenParams config to forward extra params on token refresh
Multi-tenant OIDC providers (Zitadel multi-org, Auth0 with audience) need to send extra body params on the refresh call to rescope tokens without a full authorization redirect. The generic-oauth plugin now accepts a refreshTokenParams option (object or sync/async function) that is merged into the refresh request body, with grant_type and refresh_token protected from override. The function form receives request metadata for the request that triggered the refresh, so request-scoped data (headers, cookies) is available without out-of-band state like AsyncLocalStorage.
UpstreamProvider.refreshAccessToken now accepts an optional second ctx argument; the change is backwards compatible because existing implementations that take only refreshToken remain valid. See #7554.
3d04fab, de8394d]:
Breaking change: if your proxy exposes the public hostname only through x-forwarded-host, set advanced.trustedProxyHeaders: true. Deployments where th…
#10004 b36c38f Thanks @bytaesu! - The captcha plugin now requires endpoint entries to match full auth paths unless they use wildcard patterns. This prevents requests like /sign-in//email from bypassing captcha while preserving trailing-slash matches like /sign-in/email/. To protect multiple routes, replace partial paths like /sign-in with explicit wildcards such as /sign-in/* or /sign-in/**.
#9766 bf39cbf Thanks @GautamBytes! - Add a server-only auth.api.consumePhoneNumberOTP API for custom phone OTP flows that need to verify and consume a code without creating or updating users or sessions.
#9992 e53582c Thanks @gustavovalverde! - The MCP plugin moves out of better-auth into its own package, @better-auth/mcp, built on @better-auth/oauth-provider. Import the server plugin and its helpers from @better-auth/mcp, and the remote client and adapters from @better-auth/mcp/client and @better-auth/mcp/client/adapters (previously imported from better-auth/plugins and better-auth/plugins/mcp/client). The OAuth endpoints move from /mcp/* to /oauth2/*, with discovery at /.well-known/oauth-authorization-server and protected resource metadata at /.well-known/oauth-protected-resource. Discovery-based MCP clients pick up the new locations on their own.
The route helper is renamed requireMcpAuth (was withMcpAuth), and the remote client is createMcpResourceClient (was createMcpAuthClient). requireMcpAuth verifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.
To migrate, install @better-auth/mcp, add the jwt() plugin (now required for token signing), and move options that were nested under oidcConfig to flat options on mcp({ ... }). The database models change: oauthApplication becomes oauthClient, with new oauthRefreshToken and oauthClientAssertion tables. Regenerate or migrate your schema with npx auth migrate or npx auth generate.
#10039 aedcb97 Thanks @gustavovalverde! - feat(oauth-provider)!: DPoP-bound access tokens (RFC 9449)
OAuth provider integrations can issue and verify DPoP sender-constrained tokens. Clients request them with dpop_bound_access_tokens at registration, dpop_jkt on the authorization request, or by targeting a resource configured with dpopBoundAccessTokensRequired. Issued tokens carry cnf.jkt, return token_type: "DPoP", and stay bound through refresh-token rotation, introspection, and userinfo.
Resource servers verify DPoP requests with verifyAccessTokenRequest, which checks the Authorization: DPoP scheme, the proof, the request target, the access-token hash, and proof replay. The MCP package advertises DPoP in protected resource metadata and verifies DPoP-bound requests. Proof replay is rejected through the database-backed verification store, so anti-replay holds across instances. verifyAccessTokenRequest and requireMcpAuth use that store by default; build one with createDpopReplayStore(internalAdapter) or pass a custom dpop.replayStore. This needs database-backed verification storage: a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
Breaking: the raw-token verifier verifyAccessToken is renamed to verifyBearerToken, both in better-auth/oauth2 and as the oauthProviderResourceClient action, and it rejects DPoP-bound tokens. Use verifyAccessTokenRequest on any endpoint that may receive them. The resource-request input type is renamed from AccessTokenRequestInput to ResourceRequestInput, and the DPoP algorithm option is signingAlgorithms everywhere.
Run a schema migration for the DPoP token-binding fields: the confirmation column on the access-token and refresh-token tables. DPoP-bound clients also gain dpopBoundAccessTokens and resources dpopBoundAccessTokensRequired. No dedicated replay table is added; proof replay reuses the verification store.
#9648 d2a79ba Thanks @brentmitchell25! - OAuth provider now models protected resources explicitly. Configure them with resources or create them through the oauthResource admin API. Each resource can define token TTLs, allowed scopes, custom JWT claims, and JWT signing pins.
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".
#8931 34558bc Thanks @GautamBytes! - Add opt-in JWKS-backed asymmetric JWT support for session_data cookie cache tokens, so services can verify cookie-cache JWTs with public keys instead of shared secrets.
#9134 652fa53 Thanks @gustavovalverde! - The dynamic baseURL config now ignores x-forwarded-host and x-forwarded-proto unless you set advanced.trustedProxyHeaders: true.
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,
},
});
#10031 6fe9faa Thanks @gustavovalverde! - Remove the deprecated oidcProvider plugin from better-auth/plugins. Migrate OIDC authorization-server integrations to @better-auth/oauth-provider.
#10036 ad35ead Thanks @bytaesu! - Require Google One Tap server callbacks to resolve a Google client ID before verifying ID tokens. Configure oneTap({ clientId }) or socialProviders.google.clientId when using the One Tap plugin.
#10014 73541c1 Thanks @gustavovalverde! - Cloudflare Workers apps can now start when importing Better Auth subpaths such as better-auth/db. Beta builds were crashing during module initialization before application code ran.
#10065 2196ea6 Thanks @gustavovalverde! - OAuth and device-authorization responses that carry credentials now consistently send Cache-Control: no-store and Pragma: no-cache, so proxies, CDNs, and browsers never cache them. This covers the token, introspection, and userinfo endpoints, dynamic and admin client registration, client secret rotation, and the device code and device token responses, including the error responses from those endpoints.
Endpoints declare this with metadata: { noStore: true }, and the header set is exported from @better-auth/core as NO_STORE_HEADERS for responses built by hand.
Updated dependencies [aedcb97, 2196ea6]:
Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns { active: false }, and /oauth2/userinfo reje…
#9930 0cbaf81 Thanks @gustavovalverde! - Anonymous account linking now works after social and generic OAuth sign-in in Expo and other in-app browsers, where the OAuth callback returns without the session cookie. onLinkAccount fires and the anonymous user is migrated; before, it was silently skipped.
Plugins can now carry server-trusted data across an OAuth redirect with the new addOAuthServerContext API, read back on the callback via getOAuthState().serverContext. Unlike additionalData, it cannot be set from the request body, so it is the right place for values the server must trust.
For @better-auth/oauth-provider, the post-login authorization query now travels through that server-only channel, so it can no longer be injected through additionalData.
#9645 e014029 Thanks @ping-maxwell! - Harden the Electron OAuth flow and tighten custom-scheme trusted-origin matching.
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.
#9966 ec8a38c Thanks @gustavovalverde! - genericOAuth providers configured with a discoveryUrl now verify the provider's id_token against its published JWKS (signature, issuer, audience, and advertised algorithms). A sign-in whose id_token fails verification is rejected.
These providers also accept client-submitted id_token sign-in through signIn.social({ idToken }), which previously returned ID_TOKEN_NOT_SUPPORTED.
Providers configured with explicit endpoints instead of discoveryUrl are unchanged.
#9828 4f53b61 Thanks @gustavovalverde! - Verify social-provider id_tokens with a single shared verifier.
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 OAuthProvider 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.
#9929 91f235f Thanks @gustavovalverde! - Add requireEmailVerification to OAuth provider options, for built-in social providers and the Generic OAuth plugin. When a provider reports an unverified email, the user and account are still created or linked, but no session is issued: the OAuth callback redirects with ?error=email_not_verified, and ID token and One Tap sign-in return 403 EMAIL_NOT_VERIFIED. Verification emails follow the existing emailVerification.sendOnSignUp / sendOnSignIn settings.
It is opt-in per provider and does not inherit emailAndPassword.requireEmailVerification, so existing social logins keep working. The gate checks the local user's verification state, so a user verified through another method keeps access. Only enable it for providers that report a trustworthy email_verified signal.
#9969 76a3342 Thanks @gustavovalverde! - Signing out with secondaryStorage and session.preserveSessionInDatabase now runs your configured session.delete hooks and marks the preserved session row as ended. OAuth Provider access and refresh tokens bound to that session are revoked and back-channel logout is dispatched on sign-out. Previously these hooks were skipped in this setup, so the tokens stayed valid until they expired.
#9864 41cca60 Thanks @GautamBytes! - Add a user.validateUserInfo provisioning gate that lets applications reject an identity before a user is created or a new account is linked. It runs once at the creation step for every method that provisions a user (OAuth, SSO/SAML, email/password, magic link, email OTP, anonymous, SIWE, phone number, admin-created users, and SCIM), including stateless setups with no persistent database.
It also re-runs when an existing OAuth or SSO user signs in again (source.action is "sign-in"), where it receives the fresh provider email and profile so a domain or org policy can reject a user whose provider identity moved out of bounds. Non-provider returning sign-ins are not re-validated.
The callback receives the mapped user plus a source describing the action (create-user, link-account, or sign-in), the method, and provider metadata: source.oauth for OAuth providers and source.sso for OIDC/SAML SSO providers. Return { error, errorDescription } to reject: browser flows redirect to the error URL and programmatic flows return a 403.
#9898 7fe0e2b Thanks @ItalyPaleAle! - Add clientAssertion support to the Microsoft Entra ID social provider.
#9304 e0d2b9e Thanks @gustavovalverde! - Propagate sign-out to every connected app and cut off API access immediately, via OIDC Back-Channel Logout 1.0.
When a user's session ends at the OP (sign-out, /oauth2/end-session, admin revoke, ban), @better-auth/oauth-provider now notifies every Relying Party that holds tokens for that session. The user's API access is cut off right away, instead of access tokens staying usable until their own TTL. Each client opts in by registering a backchannel_logout_uri (and optionally backchannel_logout_session_required) via DCR or the admin client-create endpoint. The provider signs a logout+jwt Logout Token per client and POSTs it to that client in parallel, with a short per-RP timeout.
Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns { active: false }, and /oauth2/userinfo rejects it with invalid_token. Previously the token stayed active until its own TTL. If you relied on access tokens outliving the user's session, that no longer holds.
Refresh tokens without offline_access are revoked on session end; offline_access refresh tokens are preserved so long-lived API access can survive the browser session (OIDC Back-Channel Logout 1.0 §2.7). Access-token invalidation on session end is an additional OP hardening choice beyond §2.7, enforced by session liveness, so it holds even when the JWT plugin is disabled.
Delivery runs through the host's background task handler when one is configured (Vercel waitUntil, Cloudflare ctx.waitUntil); without a handler it completes inline so notifications are not lost on request teardown. Configure advanced.backgroundTasks.handler on serverless runtimes to keep sign-out fast.
Discovery at /.well-known/openid-configuration and /.well-known/oauth-authorization-server advertises backchannel_logout_supported: true and backchannel_logout_session_supported: true when the JWT plugin is enabled. Registering a backchannel_logout_uri rejects fragments, non-http(s) schemes, and non-HTTPS targets on confidential clients. Its SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, now also covers a private_key_jwt client's jwks_uri.
Schema changes on @better-auth/oauth-provider:
oauthClient.backchannelLogoutUri: string | nulloauthClient.backchannelLogoutSessionRequired: booleanoauthAccessToken.revoked: Date | nullbetter-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.
Updated dependencies [7fe0e2b, 4f53b61, 91f235f, 41cca60]:
Nothing published for this version
This allows users to set their username during sign-up or first update, but prevents changing it to a different value afterwards. Users can still upda
#8733 4e8e4c7 Thanks @bytaesu! - Add hydrateSession to seed the client with a server-fetched session so useSession returns data on the first render.
#9431 523f95c Thanks @pi0! - feat: make Auth instance fetchable
#9240 729c00d Thanks @adrianmxb! - feat(username): add immutable username option
This allows users to set their username during sign-up or first update, but prevents changing it to a different value afterwards. Users can still update other profile fields.
Both new ranges track the minor line that carries the vulnerability fix and nothing newer, so the adapters only advertise support for versions that ha…
954b664 Thanks @ruban-s! - allow passing userId and organizationId to the listUserTeams API. userId lets callers list teams for another member of an organization (gated behind the member:update permission). organizationId scopes the result to a specific organization without needing to switch the session's active organization, matching the pattern used by addTeamMember/removeTeamMember.#9205 9aed910 Thanks @gustavovalverde! - fix(two-factor): revert enforcement broadening from #9122
Restores the pre-#9122 enforcement scope. 2FA is challenged only on /sign-in/email, /sign-in/username, and /sign-in/phone-number, matching the behavior that shipped through v1.6.2. Non-credential sign-in flows (magic link, email OTP, OAuth, SSO, passkey, SIWE, one-tap, phone-number OTP, device authorization, email-verification auto-sign-in) are no longer gated by a 2FA challenge by default.
A broader enforcement scope with per-method opt-outs and alignment to NIST SP 800-63B-4 authenticator assurance levels is planned for a future minor release.
#9068 acbd6ef Thanks @GautamBytes! - Fix forced UUID user IDs from create hooks being ignored on PostgreSQL adapters when advanced.database.generateId is set to "uuid".
#9165 39d6af2 Thanks @gustavovalverde! - chore(adapters): require patched drizzle-orm and kysely peer versions
Narrows the drizzle-orm peer to ^0.45.2 and the kysely peer to ^0.28.14. Both new ranges track the minor line that carries the vulnerability fix and nothing newer, so the adapters only advertise support for versions that have actually been tested against. Consumers on older ORM releases see an install-time warning and can upgrade alongside the adapter; the peer is marked optional, so installs do not hard-fail.
Updated dependencies [39d6af2]:
signIn.oauth2({ providerId }) replaced by signIn.social({ provider })
#9069 c7d2253 Thanks @gustavovalverde! - Rewrite the generic OAuth plugin as a first-class social provider with OAuth 2.1 security defaults. Providers now use signIn.social + callback/:id instead of dedicated plugin endpoints, with PKCE required by default (OAuth 2.1), RFC 9207 issuer validation, OIDC auto-discovery with openid scope injection, and typed provider IDs.
Breaking changes:
signIn.oauth2({ providerId }) replaced by signIn.social({ provider })oauth2.link() replaced by linkSocial()/api/auth/oauth2/callback/:id to /api/auth/callback/:idgenericOAuthClient() removed; generic OAuth providers now use the standard social client APIspkce defaults to true (was false); set pkce: false for providers that reject PKCEauthorizationUrlParams and tokenUrlParams only accept Record<string, string>issuer and requireIssuerValidation config fields removed; issuer validation is automatic via OIDC discoverymapProfileToUser profile typed as OAuth2UserInfo & Record<string, unknown>#9079 6f2948e Thanks @gustavovalverde! - feat(oauth-provider): compute at_hash in ID tokens per OIDC Core §3.1.3.6
ID tokens issued alongside an access token now include the at_hash claim, which cryptographically binds the two tokens to prevent token substitution attacks. The hash algorithm is selected based on the actual signing key's algorithm (EdDSA/Ed25519 uses SHA-512, RS/ES/PS384 uses SHA-384, RS/ES/PS512 uses SHA-512, all others use SHA-256).
A new resolveSigningKey() export is available from better-auth/plugins to resolve the current JWKS signing key (including its algorithm). When using a custom jwt.sign callback, the signed ID token's header is validated against the declared algorithm to prevent at_hash mismatches.
#9131 5142e9c Thanks @gustavovalverde! - harden dynamic baseURL handling for direct auth.api.* calls and plugin metadata helpers
Direct auth.api.* calls
APIError with a clear message when the baseURL can't be resolved (no source and no fallback), instead of leaving ctx.context.baseURL = "" for downstream plugins to crash on.allowedHosts mismatches on the direct-API path to APIError.advanced.trustedProxyHeaders on the dynamic path (default true, unchanged). Previously x-forwarded-host / -proto were unconditionally trusted with allowedHosts; they now go through the same gate as the static path. The default flip to false ships in a follow-up PR.resolveRequestContext rehydrates trustedProviders and cookies per call (in addition to trustedOrigins). User-defined trustedOrigins(req) / trustedProviders(req) callbacks receive a Request synthesized from forwarded headers when no full Request is available.http for loopback hosts (localhost, 127.0.0.1, [::1], 0.0.0.0) on the headers-only protocol fallback, so local-dev calls don't silently resolve to https://localhost:3000.hasRequest uses isRequestLike, which now rejects objects that spoof Symbol.toStringTag without a real url / headers.get shape.Plugin metadata helpers
oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oAuthDiscoveryMetadata, and oAuthProtectedResourceMetadata forward the incoming request to their chained auth.api calls, so issuer and discovery URLs reflect the request host on dynamic configs.withMcpAuth forwards the incoming request to getMcpSession, threads trustedProxyHeaders, and emits a bare Bearer challenge when baseURL can't be resolved (instead of Bearer resource_metadata="undefined/...").metadataResponse in @better-auth/oauth-provider normalizes headers via new Headers() so callers can pass Headers, tuple arrays, or records without silently dropping entries.#9122 484ce6a Thanks @gustavovalverde! - fix(two-factor): enforce 2FA on all sign-in paths
The 2FA after-hook now triggers on any endpoint that creates a new session, covering magic-link, OAuth, passkey, email-OTP, SIWE, and all future sign-in methods. Authenticated requests (session refreshes, profile updates) are excluded.
#7231 f875897 Thanks @Byte-Biscuit! - fix(two-factor): preserve backup codes storage format after verification
After using a backup code, remaining codes are now re-saved using the same storeBackupCodes strategy (plain, encrypted, or custom) configured by the user. Previously, codes were always re-encrypted with the built-in symmetric encryption, breaking subsequent verifications for plain or custom storage modes.
#9078 9a6d475 Thanks @ping-maxwell! - fix(client): prevent isMounted race condition causing many rps
#9113 513dabb Thanks @bytaesu! - resolve dynamic baseURL from request headers on direct auth.api calls
Updated dependencies []:
enableTwoFactor now accepts a method parameter ("otp" | "totp", default "totp") and returns a discriminated response with a method field.
#8836 93d3871 Thanks @gustavovalverde! - Add private_key_jwt (RFC 7523) client authentication across the stack. Servers verify JWT client assertions signed with asymmetric keys; clients sign them for authorization code, refresh, and client credentials flows.
#9057 544f1c6 Thanks @gustavovalverde! - feat(two-factor)!: add OTP-only enablement and remove skipVerificationOnEnable
enableTwoFactor now accepts a method parameter ("otp" | "totp", default "totp") and returns a discriminated response with a method field.
method: "otp"twoFactorEnabled: true immediately.{ method: "otp" }.otpOptions.sendOTP to be configured on the server; rejects with OTP_NOT_CONFIGURED otherwise.method: "totp" (default){ method: "totp", totpURI, backupCodes }.TOTP_NOT_CONFIGURED if totpOptions.disable is set.skipVerificationOnEnable: use method: "otp" for immediate activation, or the standard TOTP verification flow.enableTwoFactor includes a method field in the response ("otp" or "totp").Fixed database option type inference for projects that do not use Cloudflare Workers.
@better-auth/coreFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@better-release[bot]
Full changelog: v1.6.32...v1.6.33
Improved diagnostics for Cloudflare Turnstile verification failures
better-authFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@better-release[bot]
Full changelog: v1.6.31...v1.6.32
Added an option to disable OpenTelemetry span creation for individual auth instances
better-authFor detailed changes, see CHANGELOG
@better-auth/coreFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@better-release[bot]
Full changelog: v1.6.30...v1.6.31
Fixed concurrent cold-start requests from intermittently losing authentication or transaction context due to an async storage initialization race
better-authFor detailed changes, see CHANGELOG
@better-auth/sso409 with SSO_PROVIDER_CHANGED if the provider changes during DNS resolution so callers can reload and retry.For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
Full changelog: v1.6.29...v1.6.30
07c1718]:
Improved deleteSessions performance by running deletes in parallel instead of sequentially
better-authdeleteSessions performance by running deletes in parallel instead of sequentially (#10805)For detailed changes, see CHANGELOG
@better-auth/sso409 with SSO_PROVIDER_CHANGED if the provider changes during DNS verification so callers can reload and retry.For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
Full changelog: v1.6.28...v1.6.29
#10805 e6e1b4e Thanks @Emmaccen! - Speed up batch session revocation when secondary storage is enabled by deleting sessions in parallel.
Updated dependencies []:
Prevented duplicate session requests during React Suspense retries while preserving revalidation for interrupted refreshes
better-authFor detailed changes, see the CHANGELOG.
@better-auth/electronFor detailed changes, see the CHANGELOG.
@better-auth/expoFor detailed changes, see the CHANGELOG.
Thanks to everyone who contributed to this release:
Full changelog: v1.6.27...v1.6.28
#10769 773de54 Thanks @bytaesu! - Prevent duplicate session requests during transient remounts while ensuring incomplete refreshes are revalidated.
#10794 2ad2928 Thanks @bytaesu! - Restore client plugin declaration compatibility for downstream TypeScript consumers.
Updated dependencies []:
Fixed duplicate session requests being made across Suspense retries
better-authFor detailed changes, see CHANGELOG
@better-auth/scimbetter-call (#10657)For detailed changes, see CHANGELOG
authFor detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
Full changelog: v1.6.26...v1.6.27
#10657 2ae491e Thanks @bytaesu! - Aligned endpoint and middleware context types with runtime route parameters, and preserved response headers when resolving sessions from endpoint contexts.
#10676 90b5093 Thanks @bytaesu! - Deduplicate in-flight session requests when React retries a suspended component.
Updated dependencies [2ae491e]:
Fixed session cleanup on user deletion to also remove sessions from secondary storage
better-authfindSessions to skip invalid secondary-storage session entries without discarding other valid sessions (#10580)jwtClient() collapsing createAuthClient type inference when combined with other client plugins (#10513)oAuthProxy to preserve Apple user data from form_post callbacks (#10599)oneTapClient() collapsing createAuthClient type inference when combined with other client plugins (#10635)nextCookies performance in instrumented Next.js applications by reusing the next/headers import promise (#10467)For detailed changes, see CHANGELOG
@better-auth/coreplaceholder.invalid domain (#10576)For detailed changes, see CHANGELOG
@better-auth/redis-storagelistKeys() and clear() to use SCAN instead of KEYS so large keyspaces no longer block the Redis server (#10507)For detailed changes, see CHANGELOG
Thanks to everyone who contributed to this release:
@bytaesu, @Emmaccen, @gustavovalverde, @jashkarangiya, @jeroenvandermerwe, @jlucaso1, @krish-vachhani, @mrosberghaus, @XXMOHAMED012
Full changelog: v1.6.25...v1.6.26
#10619 9ede805 Thanks @jeroenvandermerwe! - Ensure database rate-limit cleanup completes when no background task handler is configured.
#10608 5a811f1 Thanks @bytaesu! - Pass the email verification type to custom OTP generators after email sign-up.
#10605 d8327f1 Thanks @XXMOHAMED012! - The email OTP verification check no longer reveals whether an email is registered before the OTP itself is verified.
#10513 e2c73fb Thanks @mrosberghaus! - Fix jwtClient() collapsing createAuthClient type inference when combined with other client plugins such as inferAdditionalFields. Additional user fields (for example on updateUser) are preserved again.
#10635 af50c45 Thanks @krish-vachhani! - Fix oneTapClient() collapsing createAuthClient type inference when combined with other client plugins. The oneTap action is available on the client again.
#10633 701cd43 Thanks @gustavovalverde! - Minting or reading a JWKS signing key inside an active database transaction now uses the transaction-scoped adapter instead of the root connection. On a single-connection SQLite database with native transactions enabled, this no longer deadlocks, and on Postgres and MySQL the key commits with the surrounding transaction instead of independently of it.
#10599 e7b0eba Thanks @bytaesu! - Preserve Apple user data from form_post callbacks when using oAuthProxy.
#10552 2b4a14f Thanks @bytaesu! - Allow users to retry email OTP password resets after entering an invalid password.
#10467 7552a3b Thanks @jlucaso1! - Improve nextCookies performance in instrumented Next.js applications.
#10580 ea38fca Thanks @Emmaccen! - Skip invalid secondary-storage session entries without discarding other valid sessions.
#10520 a03e4c1 Thanks @bytaesu! - Ensure deleting a user also removes their sessions from secondary storage.
Updated dependencies [a30e274]:
Updated dependencies [`0ffd1fb`]:
#10479 5124c34 Thanks @krish-vachhani! - Prevent Google One Tap from creating new users when sign-up is disabled for the Google provider.
#10444 7439359 Thanks @birkskyum! - Expose the real $fetch instance and $store atoms from the Solid client instead of resolving them as dynamic API routes.
Updated dependencies [0ffd1fb]:
Updated dependencies [`6758231`, `54fab08`, `c4d1dda`]:
#10235 03dc5a0 Thanks @ping-maxwell! - Fixes silent foreign-key and adapter-join misrouting when a user remaps a built-in model name to a string that collides with another schema key
#10357 7508940 Thanks @c-nicol! - Fixes Kysely migration generation for new-table fields that are both unique: true and index: true.
#10342 bae7198 Thanks @ping-maxwell! - Fix organization.listMembers failing with "User not found for member" for orgs with more than ~100 members by applying the same membership limit to the users query.
#10336 ef4d273 Thanks @Tushar-Khandelwal-2004! - Prevent verification callbacks from failing auth requests when cloning the request throws.
#10333 99dbdd7 Thanks @c-nicol! - Fixes Drizzle schema generation for fields that are both unique: true and index: true.
#10368 086ca91 Thanks @gaurav0107! - Force-validate the request Origin on the magic-link (/sign-in/magic-link) and email-otp (/email-otp/send-verification-otp) send endpoints, including cookieless requests, to match the built-in /sign-in/email and /sign-up/email routes. A cookieless cross-origin POST can no longer trigger a magic-link or verification-OTP email to an arbitrary address. Cookieless requests that carry no Origin (server-to-server) are unaffected.
#10290 8f2dedd Thanks @GautamBytes! - Expose the remote MCP auth client's 401 challenge headers to browser clients using CORS.
#10453 4e685ee Thanks @ping-maxwell! - OpenAPI now includes user.additionalFields and plugin user schema fields (e.g. username plugin username / displayUsername) on /sign-up/email and /update-user request bodies.
#10190 3bf0e49 Thanks @gaurav-init! - Pass the endpoint context as the second argument to beforeDeleteOrganization and afterDeleteOrganization hooks in the organization plugin, matching the signature shown in the docs and the existing databaseHooks pattern. The Stripe plugin's beforeDeleteOrganization wrapper now forwards the context to user-supplied hooks instead of dropping it.
#10040 f59a0ee Thanks @shiminshen! - Organization invitations now let the database generate their id when ID generation is delegated to the database (e.g. advanced.database.generateId: "uuid" with a UUID-capable adapter such as Postgres), matching every other model. Previously createInvitation always generated the invitation id in application code, so invitation rows received an app-generated value instead of a database-generated one while organizations, members and teams correctly deferred to the database (better-auth/better-auth#10024). A caller-provided id (e.g. via beforeCreateInvitation) is still honored.
#10302 0f2cc1b Thanks @momomuchu! - Prefer exact schema-key matches over modelName aliases in getDefaultModelName, so remapping a built-in table onto another table's schema key (e.g. user.modelName = "account") does not reroute internal adapter queries to the wrong table.
#9787 ae78109 Thanks @ping-maxwell! - Fixes an issue where useSession({ throw: true }) incorrectly excluded null from its data type.
#10222 46d2bf0 Thanks @ping-maxwell! - fix: add no-store cache-control headers to get-session route
#10316 29a373e Thanks @vinay-oppuri! - Recognize SQLite BIGINT as a valid number type in migration diffs so database-backed rate limiter columns like lastRequest no longer report spurious pending changes on every run.
#10379 f6d18fa Thanks @ping-maxwell! - fix(client): restore auth query revalidation and signal listeners after remount
#5753 f23ce50 Thanks @ping-maxwell! - feat(last-login-method): beforeStoreCookie option for GDPR compliance
#10376 c4d1dda Thanks @ping-maxwell! - Pass the request endpoint context as a third argument to verifyIdToken, so custom ID token verifiers can read request headers (for example Apple's user-agent requirement).
Updated dependencies [6758231, 54fab08, c4d1dda]:
Updated dependencies [`930b260`]:
#9138 8581f97 Thanks @vladflotsky! - Add a pre-configured Yandex provider helper for the generic OAuth plugin.
Updated dependencies [930b260]:
If you signed up with email and password but first signed in through a magic link or email OTP rather than confirming the verification email, your pas
#10239 c06a56d Thanks @gustavovalverde! - Magic-link and email-OTP sign-in now reset the credentials on an account whose email had never been confirmed. When verification resolves to such an account, any existing password on it is removed and its sessions are revoked before the user is signed in, so proven control of the mailbox is the source of truth for the account.
If you signed up with email and password but first signed in through a magic link or email OTP rather than confirming the verification email, your password is cleared and you will need to set a new one through password reset.
#10240 3a035e9 Thanks @gustavovalverde! - Add account-level lockout for two-factor verification. The attempt limit applies per account across sign-in challenges and across factors: TOTP, email-OTP, and backup codes share one counter, and a successful verification resets it.
Enabled by default: an account locks for 15 minutes after 10 consecutive failed verifications, and locked attempts return 429 with the ACCOUNT_TEMPORARILY_LOCKED error code. Configure it with twoFactor({ accountLockout: { enabled, maxFailedAttempts, durationSeconds } }).
Run a database migration after upgrading: this adds failedVerificationCount and lockedUntil columns to the twoFactor table.
Updated dependencies [8bd43d9]:
Google One Tap now applies the configured Google hosted-domain restriction before creating a session.
#10212 e0762a1 Thanks @bytaesu! - In root-mounted deployments, requests whose path does not start with the configured basePath now return 404 instead of resolving to an endpoint.
#10187 882cf9e Thanks @ping-maxwell! - Admin permission changes and bans now take effect immediately for admin APIs, even when session cookie cache is enabled. Sensitive session checks also continue to work in stateless apps where signed cookies are the session record.
#9939 f52e1ab Thanks @benpsnyder! - fixes a bug causing deviceAuthorization() throwing a ZodError at construction when called without a schema option
#10196 b5bec19 Thanks @Paola3stefania! - OAuth sign-up and account-link profile sync now ignore provider profile values for user fields marked input: false. Input-allowed additional fields still persist from mapProfileToUser, and schema defaults still apply when OAuth creates a user. Apps that used mapProfileToUser to fill input: false fields should set those fields in server-side provisioning code instead.
#10197 816d7f9 Thanks @Paola3stefania! - Google sign-in now accepts hd: "*" to allow any Google Workspace hosted domain while still rejecting tokens with no hosted-domain claim.
Google One Tap now applies the configured Google hosted-domain restriction before creating a session.
#10192 239bcc8 Thanks @bytaesu! - Validate PayPal user info against the verified ID token subject during social sign-in.
#10228 1bc370a Thanks @gustavovalverde! - The SIWE plugin no longer binds a provided email that already belongs to another account. With anonymous set to false, /siwe/verify previously created the new account using that email even when it was already in use; it now keeps the wallet-derived address in that case, so one email cannot be attached to two accounts.
#10198 570267c Thanks @rachit367! - Honor disableMigration on plugin schema tables. Tables flagged with disableMigration: true are now skipped by better-auth generate (Drizzle and Prisma output) and by the runtime migrator, instead of being emitted and created anyway. The flag was previously dropped while assembling the table list, so it had no effect.
#10182 461ca6f Thanks @bytaesu! - Only store display username fallbacks as usernames when they pass username validation during email sign-up.
#10183 88409b0 Thanks @bytaesu! - Require OAuth proxy profile callbacks to match an issued OAuth state before creating sessions.
#10203 5953157 Thanks @bytaesu! - Rate limiting no longer trusts multi-hop X-Forwarded-For chains, preventing a client behind an appending proxy from spoofing the leftmost hop to bypass the per-IP rate limit. Single-value IP headers continue to work. To key the real client behind a proxy chain, set advanced.ipAddress.trustedProxies to your reverse-proxy IPs or CIDR ranges (the chain is walked right to left, skipping trusted hops), or point advanced.ipAddress.ipAddressHeaders at a single trusted client-IP header.
#10191 b046f9e Thanks @bytaesu! - Rate limit client requests before plugin request handlers run.
#10210 ae647b4 Thanks @gustavovalverde! - Two-factor verification now locks out after five wrong codes per sign-in challenge for TOTP and backup codes. Once the limit is reached the challenge is rejected with TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE, and a new sign-in is required to try again.
During a rolling deploy, two-factor challenges issued by the previous version may prompt the user to sign in again; this clears once the deploy completes.
Updated dependencies [90d509e, 816d7f9, 570267c, 5953157]:
@better-auth/drizzle-adapter@1.6.20
#10121 21448b1 Thanks @adityachaudhary99! - OAuth account-linking and create-user error logs now respect a custom logger configured in betterAuth(), instead of always being written to the default console logger.
#9621 8ecf238 Thanks @dipan-ck! - Session refresh no longer emits a cookie Max-Age above the browser's 400-day ceiling when using a database without fractional-second precision.
#8734 930f534 Thanks @sleepe229! - declare inherited APIError properties to fix TypeScript inference errors
Updated dependencies []:
On MongoDB servers older than 5.0, these flows and other guarded value updates (rate-limit window resets, API-key refills) no longer fail with an empt
#10088 de4aa52 Thanks @bytaesu! - Session and account cache cookies near the browser's per-cookie size limit (for example with a long cookiePrefix or many cached fields) are now split into chunks instead of being silently dropped by the browser. A cache too large to fit even when chunked is skipped with a warning rather than failing the request, so reads fall back to the database.
#9995 b4b0266 Thanks @ElGauchooooo! - The device authorization plugin now accepts an optional user_id when issuing a device code via /device/code, pre-binding the code to that user. Only the bound user can approve or deny the code, so a publicly visible user code can no longer be claimed by someone else.
#10086 5bd5e1c Thanks @gustavovalverde! - Refresh-token rotation and token revocation, two-factor backup-code regeneration, device-code claiming, and organization invitation acceptance now work on Prisma. Concurrent or repeat requests in these flows could previously return an error on Prisma instead of the expected result.
On MongoDB servers older than 5.0, these flows and other guarded value updates (rate-limit window resets, API-key refills) no longer fail with an empty-update error.
@better-auth/core: incrementOne now reports a clear error when called with no increment and no set.
#9319 581f827 Thanks @ping-maxwell! - fix(last-login-method): include domain when clearing cross-subdomain cookies
#10067 8407885 Thanks @bytaesu! - The oauth-popup plugin now ignores internal OAuth state fields passed through its additionalData parameter, so additionalData only ever carries your own custom values.
#9555 c1a8a64 Thanks @ChrisMGeo! - Fix invalid OpenAPI output for Better Auth callback, session, and passkey routes so client generators can consume the schema.
#10071 635f190 Thanks @gustavovalverde! - Auth clients exported from wrapper packages can now be emitted in TypeScript declaration builds without extra type annotations.
#10070 a787e0b Thanks @gustavovalverde! - Single-use verification flows no longer hang on database adapters that use a one-connection pool. This fixes magic-link verification and similar token checks in connection-limited serverless database setups.
#9348 c2f718f Thanks @ping-maxwell! - fix: cookie cache fallback lookup
#8863 7d18175 Thanks @ping-maxwell! - sendVerificationEmail was invoked via runInBackgroundOrAwait, which could defer work when advanced.backgroundTasks.handler is configured (so the handler could return 200 before the email callback finished) and, in the default path, caught and logged errors without rethrowing. User callbacks that throw APIError (e.g. 429 from a rate limiter) were therefore not reliably reflected in the HTTP response (better-auth/better-auth#8757).
Now we await sendVerificationEmailFn so failures surface to the client with the correct status. The unauthenticated /send-verification-email path enforces a constant-time floor (500 ms) so that the response duration does not reveal whether the email belongs to a real unverified user.
Updated dependencies [0895993, 5bd5e1c, a787e0b]:
Updated dependencies [`b21a5f7`]:
#9315 9ef7240 Thanks @GautamBytes! - fix OpenAPI requestBody generation for intersected and default-wrapped body schemas
#9583 b21a5f7 Thanks @GautamBytes! - Fix plugin-provided client methods and additional session fields not being inferred in composite monorepos.
Updated dependencies [b21a5f7]:
`96c78c3` Thanks @GautamBytes! - Downgrade expected auth validation failures from error logs to warnings.
#9993 baeaa00 Thanks @gustavovalverde! - When a team had a single open slot, accepting an invitation into it was wrongly rejected as over the member limit and left a dangling membership record. Two invitations accepted into a nearly-full team at the same time could also push it past its limit. Both are fixed.
#9482 3e99e6c Thanks @bytaesu! - admin.setUserPassword now creates a credential account when the target user does not have one, matching the behavior of resetPassword. Previously the call returned status: true without doing anything for users without an existing credential account (e.g., social-only or magic-link signups), so admins migrating users from another auth system or assigning an initial password to a social-only user can now do so directly without poking the account table.
96c78c3 Thanks @GautamBytes! - Downgrade expected auth validation failures from error logs to warnings.
#9993 baeaa00 Thanks @gustavovalverde! - Captcha provider verification requests now time out after 10 seconds and fail closed, so a slow or unreachable captcha provider can no longer tie up a request indefinitely.
#9993 baeaa00 Thanks @gustavovalverde! - A delete-account confirmation link can no longer delete the account more than once when its callback is opened concurrently.
#9991 0c3856f Thanks @gustavovalverde! - Completing account deletion through /delete-user/callback now fails 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.
#9993 baeaa00 Thanks @gustavovalverde! - Polling for a device-authorization token can no longer redeem the same approved device code more than once when several polls arrive together.
#9993 baeaa00 Thanks @gustavovalverde! - Submitting the same email OTP from several requests at once can no longer sign in more than once or gain extra tries beyond the attempt limit.
#10002 ed7b6c9 Thanks @gustavovalverde! - Adding a member to a team that is already at its maximumMembersPerTeam limit is now rejected on every path. addMember with a teamId and add-team-member previously skipped the limit that invitation acceptance enforced, so they could push a team over its cap. A rejected addMember no longer creates the organization member.
#9677 e0a768c Thanks @GautamBytes! - Refactor role.authorize control flow while preserving existing authorization behavior.
#9987 7343284 Thanks @bytaesu! - Generic OAuth sign-in works again for providers whose userinfo response has no sub or id field when mapProfileToUser derives the account id. An empty id field now falls back to sub.
#9991 0c3856f Thanks @gustavovalverde! - getCookieCache now returns null for an expired session instead of the stale session data. Middleware that calls it to gate access no longer treats an expired signed cookie as a live session.
#9993 baeaa00 Thanks @gustavovalverde! - The Have I Been Pwned plugin now checks submitted passwords against the breach database on more password-setting endpoints by default, including the email-OTP and phone-number reset-password routes and the admin create-user and set-user-password routes. A breached password can no longer be set through those routes when the plugin is enabled with its default paths.
#9987 7343284 Thanks @bytaesu! - Preserve the fresh account cookie issued while switching users in the same browser instead of expiring it from stale request cookie state.
#9991 0c3856f Thanks @gustavovalverde! - Expired MCP access tokens are no longer accepted. A protected MCP resource now rejects a bearer token once it has expired, both on the server and through the remote client. A refresh token is accepted only when the original authorization included the offline_access scope.
#9991 0c3856f Thanks @gustavovalverde! - The multi-session set-active and revoke endpoints now act only on the session the caller holds a signed cookie for. A request could previously activate or revoke a different session by naming its token in the request body without holding that session's cookie.
#9890 d9c526b Thanks @bytaesu! - Add an experimental oauthPopup plugin (with oauthPopupClient and signIn.popup) for popup-based OAuth sign-in. It lets an app sign in inside a cross-site iframe by completing OAuth in a popup and handing the session token back to the opener, where the bearer plugin authenticates with it. The API may change while it is experimental.
#9991 0c3856f Thanks @gustavovalverde! - The OIDC provider's RP-initiated logout endpoint (/oauth2/endsession) no longer logs a user out, or revokes their OAuth tokens, in response to a cross-site GET that carries only a session cookie. Logout authenticated by a valid id_token_hint is unaffected.
#10003 fdef997 Thanks @gustavovalverde! - Google One Tap now requires a configured Google client ID and rejects the sign-in callback when none is set. A Google ID token issued for a different application is no longer accepted. Set the client ID on the oneTap plugin or on socialProviders.google.
#9993 baeaa00 Thanks @gustavovalverde! - A one-time token can no longer be redeemed for a session more than once when redeemed concurrently.
#9993 baeaa00 Thanks @gustavovalverde! - A password reset token can no longer change the password more than once when used from several requests at the same time.
#9993 baeaa00 Thanks @gustavovalverde! - Submitting the same phone-number OTP from several requests at once can no longer sign in more than once or gain extra tries beyond the attempt limit.
#9993 baeaa00 Thanks @gustavovalverde! - Concurrent requests can no longer slip past the configured rate limit. The in-memory rate-limit store no longer grows without bound, and the database backend removes expired entries on its own. A custom rate-limit storage may implement a new optional consume method for strict enforcement; without it, the previous behavior is kept and a one-time warning is logged.
#9987 7343284 Thanks @bytaesu! - Deleting a team no longer breaks its pending invitations. The removed team is dropped from those invitations, which stay valid for their remaining teams or as plain organization-level invitations. Accepting an invitation that still references a missing team fails without consuming the invitation.
#9993 baeaa00 Thanks @gustavovalverde! - Add internalAdapter.reserveVerificationValue. It atomically records a single-use marker (such as a replay tombstone) so that exactly one of several concurrent callers succeeds and the rest observe that the marker is already taken. Database-backed verification storage is atomic; secondary-storage-only verification is best-effort.
#8760 8960f5f Thanks @gustavovalverde! - Session refreshes now avoid duplicate /get-session requests from focus and other browser session events. Client hooks keep stable data references when refetches return unchanged data, reducing unnecessary renders. Unmounting during an in-flight session request no longer leaves session state stuck in a loading state.
#9993 baeaa00 Thanks @gustavovalverde! - A Sign-In with Ethereum nonce can no longer be used to sign in more than once when submitted from several requests at the same time.
#9979 5c289b5 Thanks @SferaDev! - Stateless OAuth deployments can now read account info, access tokens, and refresh tokens after different server instances handle sign-in and later requests. Session refresh also keeps the OAuth account cookie instead of clearing it in that case.
#9990 1dbf5bb Thanks @gustavovalverde! - Hardens how requests are trusted across several flows. Rate limiting is now enforced even when a client IP cannot be determined, instead of being skipped. When baseURL is not configured, password-reset and verification links use the current request's host rather than the host of the first request the server handled, and a request-scoped trustedOrigins callback no longer affects other concurrent requests. The OAuth proxy, Google One Tap, and the Expo authorization proxy reject redirect and callback targets that are not in trustedOrigins. Google reCAPTCHA and Cloudflare Turnstile accept optional expectedAction and allowedHostnames to reject tokens minted for a different action or hostname. Server-side fetches reject additional reserved IPv6 ranges, and malformed redirect parameters return a 400 instead of a 500.
#9993 baeaa00 Thanks @gustavovalverde! - An expired two-factor sign-in challenge can no longer complete login with a valid TOTP, OTP, or backup code, and the same challenge can no longer create more than one session when verified concurrently.
#9993 baeaa00 Thanks @gustavovalverde! - Submitting the same two-factor OTP from several requests at once can no longer sign in more than once or gain extra tries beyond the attempt limit.
#9777 59e0ccb Thanks @GautamBytes! - Client updateSession calls now accept inferred custom session fields from inferAdditionalFields.
#9962 b803c61 Thanks @Bekacru! - Validate roles when updating an organization member. Roles are now normalized into individual tokens and checked against the configured static and dynamic roles, so unknown or malformed role values are rejected instead of being persisted.
Updated dependencies [baeaa00, baeaa00, baeaa00, 7343284, baeaa00, baeaa00, fdef997, baeaa00, baeaa00, fdef997, baeaa00, 1dbf5bb, fdef997]:
The plugin now parses the ERC-4361 message itself and requires its nonce, domain, address, and chain ID to match the server-issued nonce and configure
#9974 cb1cbfa Thanks @Bekacru! - Guard protected user fields in the admin plugin behind their dedicated permissions. /admin/create-user now requires user:set-role when a role is supplied (top-level or via data.role), validates requested roles against the configured roles, requires user:ban for ban fields passed in data, and no longer lets data override email, name, or role. /admin/update-user now requires user:ban for banned/banReason/banExpires (revoking the user's sessions when banning and rejecting self-bans), requires the new user:set-email permission for email/emailVerified (with email validation, lowercasing, and uniqueness checks), and rejects password updates in favor of /admin/set-user-password. If you use a custom access control, add set-email to your statements and grant it (and ban) to roles that should be able to change those fields through update-user.
#9974 cb1cbfa Thanks @Bekacru! - Require a provider account id when signing in through generic OAuth. The default userinfo handler previously fell back to an empty string when the provider response had no sub (or id), and the callback never checked the resolved account id. With certain non-OIDC providers that omit sub, accounts could be stored under the same empty id and a later sign-in could resolve to an existing account. The generic OAuth callback now rejects sign-in when no account id can be resolved, the default userinfo handler returns no profile when neither sub nor id is present, and the built-in OAuth callback also rejects an empty account id.
#9974 cb1cbfa Thanks @Bekacru! - Scope organization invitation team IDs to the invited organization. createInvitation now validates that every requested teamId belongs to the invitation's organization regardless of whether teams.maximumMembersPerTeam is set, and acceptInvitation re-checks each stored team's organization before adding team membership. Previously, with the default unlimited team size, a team ID from another organization could be stored on an invitation and applied on acceptance.
#9973 87e7aa5 Thanks @gustavovalverde! - Email sign-in and sign-up now validate the Origin or Referer header against trustedOrigins even when the request carries no cookies. Requests that send no Origin/Referer header and no Fetch Metadata (such as curl or server-to-server clients) are unaffected. A non-browser client that sends an untrusted Origin/Referer without cookies now receives a 403 and must add that origin to trustedOrigins.
#9974 cb1cbfa Thanks @Bekacru! - Require /refresh-token to only trust the account cookie when its userId, providerId and (when supplied) accountId match the resolved session user.
#9967 893cf6c Thanks @gustavovalverde! - Deleting a session now immediately stops /update-session and the account token endpoints (/get-access-token, /refresh-token, /account-info) from accepting it, when cookie cache is enabled alongside a database or secondary storage. Before, these routes kept serving the deleted session from the cached cookie until the cache expired. Deployments that store the session only in the cookie are unaffected.
#9974 cb1cbfa Thanks @Bekacru! - Bind the SIWE signed message to server state before creating a session. Previously /siwe/verify only checked that a nonce row existed for the wallet address and then delegated entirely to verifyMessage. Since the documented verifyMessage (viem) performs signature recovery only — without inspecting the message body — a signature the wallet produced for a different message (an earlier nonce, another domain, or arbitrary content) could also satisfy verification against a freshly minted nonce.
The plugin now parses the ERC-4361 message itself and requires its nonce, domain, address, and chain ID to match the server-issued nonce and configured domain, and enforces the message's Expiration Time / Not Before bounds, before verifying the signature. message must now be a valid ERC-4361 message (which all standard SIWE clients produce); non-conforming or mismatched messages are rejected with a 401 (UNAUTHORIZED_SIWE_MESSAGE_MISMATCH, UNAUTHORIZED_SIWE_MESSAGE_EXPIRED, or UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID). verifyMessage implementations should continue to perform signature recovery only.
#9974 cb1cbfa Thanks @Bekacru! - Separate SSO provider ids from the account-linking provider namespace used for social/OAuth providers. Previously an SSO provider registered with an id matching a configured accountLinking.trustedProviders entry (e.g. google) was treated as a trusted provider and could implicitly link to an existing verified account with the same email.
SSO registration now rejects provider ids that collide with a configured social provider, a trustedProviders entry, or a reserved built-in id. In addition, the OIDC and SAML callbacks no longer derive trust from a trustedProviders name match — SSO trust comes solely from verified domain ownership (domainVerified). handleOAuthUserInfo gains a trustProviderByName option (default true, preserving social-provider behavior) that the SSO plugin sets to false.
#9965 5e49c56 Thanks @gustavovalverde! - Passing activeOrganizationId, activeTeamId, or impersonatedBy to /update-session now returns a 400. Change these plugin-managed session fields through their dedicated endpoints instead, such as organization.setActive.
Updated dependencies [cb1cbfa, cb1cbfa, cb1cbfa, cb1cbfa, cb1cbfa, cb1cbfa]:
hooks.before / hooks.after configured on the auth instance now run for the OAuth authorization that continues after a user signs in, selects an accoun
#9875 1012b69 Thanks @WilsonnnTan! - The admin plugin's unbanUser, setRole and adminUpdateUser endpoints used to call internalAdapter.updateUser without checking that the target user existed, so when the caller passed an unknown id the underlying database error (for example Prisma's P2025) bubbled up as a generic HTTP 500. those endpoints now mirror the existing guard in banUser: look the user up via findUserById, and throw a clean NOT_FOUND (USER_NOT_FOUND) when no row is returned. Closes #9800.
#9865 ad60333 Thanks @ping-maxwell! - list-session endpoint now requires a fresh-age session check.
#9811 0933c05 Thanks @zeroknowledge0x! - Restore Kysely 0.28 and 0.29 compatibility for SQLite dialect introspection. The dialects now mirror Kysely's stable migration table names locally, avoiding strict ESM build failures in Turbopack without forcing consumers onto Kysely 0.29.
#9919 b0ddfd3 Thanks @gustavovalverde! - Run configured hooks through the whole OAuth sign-in flow
hooks.before / hooks.after configured on the auth instance now run for the OAuth authorization that continues after a user signs in, selects an account, or consents. They were being skipped there.
Headers or cookies a hooks.before sets before returning its own response are no longer dropped, and a hooks.after that throws an APIError no longer loses either its cookies or the error's headers.
Updated dependencies []:
Client-side listUserInvitations now always requires a verified session email because it enumerates invitation IDs from session.user.email. The require
#9877 2d9781a Thanks @gustavovalverde! - Restore the normal emailed-invitation flow while documenting the stricter verification posture for organization invitations.
Client-side listUserInvitations now always requires a verified session email because it enumerates invitation IDs from session.user.email. The requireEmailVerificationOnInvitation option now controls recipient calls that carry an invitation ID (acceptInvitation, rejectInvitation, getInvitation). When unset, Better Auth keeps the emailed-invitation sign-up flow for built-in opaque invitation IDs, including the default generator or advanced.database.generateId: "uuid", and requires verified email when invitation IDs are externally controlled or predictable, such as advanced.database.generateId: "serial" / false or custom ID generation. Apps that expose invitation IDs outside the invited user's mailbox, expose organization invitation lists to members, or require stricter ownership proof should set requireEmailVerificationOnInvitation: true or require verified email before sign-in.
#9841 5a2d642 Thanks @bytaesu! - Optional fields (required: false) now accept null, not just omission. The
generated input validation previously rejected null even though the column is
nullable, so a nullable field could not be cleared by passing null.
#9845 13abc79 Thanks @gustavovalverde! - Harden redirect-URI validation across the OAuth provider plugins. isSafeUrlScheme and SafeUrlSchema no longer call URL.canParse, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with a try/catch fallback. SafeUrlSchema also rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.
#9806 9d3450a Thanks @bytaesu! - getSessionCookie now prefers the __Secure- cookie when both it and a non-secure cookie are present, so the non-secure cookie no longer shadows the current session cookie.
Updated dependencies [13abc79]:
Unified escape hatch for customizing the provider authorization URL on a per-request basis. Previously, dynamic parameters like Google's access_type=o
#9305 e7eb45b Thanks @gustavovalverde! - feat(oauth): per-request additionalParams and loginHint parity across signIn.social, linkSocial, and signIn.sso
Unified escape hatch for customizing the provider authorization URL on a per-request basis. Previously, dynamic parameters like Google's access_type=offline / prompt=consent, Cognito's identity_provider=Google, or Microsoft's domain_hint could only be set as static server configuration.
signIn.social, linkSocial, and signIn.sso accept additionalParams: Record<string, string>. Values are appended to the authorization URL as query parameters.linkSocial also accepts loginHint, matching the surface of signIn.social and signIn.sso.OAuthProvider.createAuthorizationURL gains additionalParams in its input contract; every built-in provider forwards it to the shared helper.additionalParams with the config-level authorizationUrlParams; call-time wins on key collision.identityProvider?: string config option that maps to the identity_provider query parameter, avoiding magic strings.createAuthorizationURL helper silently drops any caller-supplied key in RESERVED_AUTHORIZATION_PARAMS (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope). The request-body Zod schema rejects the same keys with 400, so misuse is visible at the edge rather than silently overriding security-critical parameters.wechat → appid, tiktok → client_key) additionally filter those keys so a caller cannot swap the configured OAuth app.atlassian → audience, notion → owner) are merged last so caller-supplied additionalParams cannot override them. Configured defaults that represent operator intent (e.g. Google include_granted_scopes, Cognito identityProvider) remain caller-overridable.signIn.sso rejects additionalParams with 400 when the resolved provider is SAML; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters, so silently dropping them would mislead integrators.ZodRecord handling to the OpenAPI generator so z.record() fields emit type: object with typed additionalProperties. Incidentally fixes a long-standing bug where additionalData was rendered as type: string.discord, roblox, zoom, and slack providers now delegate to the shared createAuthorizationURL helper and inherit its RFC behavior and reserved-key guard.tiktok and wechat keep their manual URL construction (non-standard OAuth2 parameter names and URL fragment requirements) but thread additionalParams with the same reserved-key filter.Closes #2351. Closes #5441. Closes #5592. Closes #5604. Supersedes #4992 and #5443.
#9657 1e5b808 Thanks @gustavovalverde! - Harden private_key_jwt and token endpoint client authentication, and add the helpers that make the fix structural.
@better-auth/core/oauth2 now exposes encodeBasicCredentials and decodeBasicCredentials, a round-trip-tested pair that follows RFC 6749 §2.3.1 (application/x-www-form-urlencoded each value, split on the first : only). The decoder accepts the scheme case-insensitively and tolerates one or more spaces before the credentials per RFC 7235 §2.1. client_secret_basic on the client side and the Better Auth OAuth provider on the server side both go through these helpers, so credentials containing reserved characters round-trip cleanly across the stack and headers like basic xxx or Basic xxx are accepted.
createPrivateKeyJwtClientAssertionGetter validates options eagerly. Unsupported algorithms (HS256, none), a JWK with no key material, and disagreement between an explicit algorithm and the JWK-embedded alg all throw at construction rather than on the first token request. signPrivateKeyJwtClientAssertion enforces the same checks for direct callers. Breaking: configurations that paired an unsupported JWK alg with a different explicit algorithm used to silently sign with the explicit option; they now fail at construction.
@better-auth/oauth-provider rejects empty jwks payloads at the schema layer (jwks: [] and jwks: { keys: [] }) so the documented client metadata contract matches what checkOAuthClient already enforces at runtime. Schema consumers (TypeScript, OpenAPI, generated SDKs) now see the constraint.
The SSO private_key_jwt flow redirects with error_description=no_private_key_available when a resolvePrivateKey callback returns no privateKeyJwk or privateKeyPem. The redirect path previously short-circuited only when the resolver was absent entirely; an empty resolver return fell through into an internal signing error.
better-auth/test adds getHttpTestInstance, a counterpart to getTestInstance that binds a real HTTP listener on an OS-assigned port and constructs the auth instance against the discovered URL. It removes the temp-server-then-rebind race that test files have been individually copy-pasting.
#9301 03e6c94 Thanks @gustavovalverde! - Add allowIdpInitiated to GenericOAuthConfig and SSO OIDCConfig to support providers that initiate OAuth without a state parameter (e.g. Clever). When enabled, stateless callbacks restart the OAuth flow server-side with fresh state and PKCE, preserving CSRF protection. Also hardens parseState against undefined request bodies on GET callbacks.
#9845 13abc79 Thanks @gustavovalverde! - Harden redirect-URI validation across the OAuth provider plugins. isSafeUrlScheme and SafeUrlSchema no longer call URL.canParse, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with a try/catch fallback. SafeUrlSchema also rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.
Updated dependencies [e7eb45b, 03e6c94, 1e5b808, 13abc79]:
Nothing published for this version
Nothing published for this version
The endpoint accepted a callbackURL body field but ignored it, so authClient.signIn.username({ ..., callbackURL }) silently did nothing while authClie
#8339 1e0f26d Thanks @ping-maxwell! - fix(captcha): breaks email-otp flow
#9484 8c1e917 Thanks @ping-maxwell! - fix: warn for cookie-plugin being last in array
#9437 b2d655c Thanks @cyphercodes! - Allow organization invitation role input types to accept dynamic access control roles.
#9497 09f1327 Thanks @bytaesu! - Endpoints that set cookies before redirecting (such as social sign-in
callbacks and magic-link verification) no longer emit each Set-Cookie
entry twice on the response.
#9387 906b7b3 Thanks @bytaesu! - The bearer plugin now produces a single entry per cookie name when merging
its session token into the request Cookie header. Previously the merged
header could carry two entries for the same name if the request already
had a stale session cookie, which would surface to downstream code that
picks the first occurrence.
#9475 e9c978e Thanks @jaydeep-pipaliya! - fix(username): respect callbackURL on /sign-in/username
The endpoint accepted a callbackURL body field but ignored it, so
authClient.signIn.username({ ..., callbackURL }) silently did nothing
while authClient.signIn.email redirected as expected. The handler now
sets a Location header when callbackURL is provided and returns
{ redirect, url } alongside token/user, matching the email flow.
#9440 e71aad3 Thanks @cyphercodes! - Clear organization active hook state after sign-out so useActiveMemberRole does not retain a previous user's role in SPA sign-out/sign-in flows.
#9402 80a655d Thanks @onmax! - Revalidate the client session after admin impersonation starts or stops.
#9503 15ff28a Thanks @bytaesu! - internalAdapter.deleteAccount parameter renamed from accountId to id to reflect that it queries by primary key, not the accountId column. No runtime behavior change.
#9268 88a7c67 Thanks @ping-maxwell! - fix: openAPI schema for POST /sign-in/social mis-declares required fields
#8839 9a7b51d Thanks @dipan-ck! - Apply email enumeration protection when emailAndPassword.autoSignIn is false. Duplicate sign-ups now return a synthetic user (token: null) and trigger onExistingUserSignUp, and new sign-ups skip auto sign-in (token: null)—even without requireEmailVerification, aligning with the docs.
#9065 1b25902 Thanks @ping-maxwell! - non-ASCII error_description in generic-oauth callback routes causes TypeError on redirect
#9349 cf59136 Thanks @ping-maxwell! - fix(organization): re-export field types to prevent TS2742 with additionalFields
#9453 a597ee0 Thanks @mausic! - The organization plugin's cancelPendingInvitationsOnReInvite option now actually cancels the prior pending invitation when re-inviting the same email. Previously the option had no effect — re-inviting always failed with USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION
#9456 fc02ced Thanks @cyphercodes! - Reject OAuth callbacks when provider user info omits the account id to avoid linking accounts under the literal undefined id.
#9461 9f1ef1f Thanks @cyphercodes! - Expose authClient.siwe.getNonce() as a compatibility alias for the SIWE nonce endpoint.
#9369 36ef808 Thanks @ping-maxwell! - fix: incorrect email casing across one-tap, email-otp & email-verification
#9239 c1336c5 Thanks @GautamBytes! - Fix organization.setActiveTeam so it only accepts teams from the current active organization.
#7764 3a9a2c3 Thanks @programming-with-ia! - chore: expose refreshUserSessions on internal adapter
#9521 fde0432 Thanks @ping-maxwell! - fix: improve link accessibility issues
Updated dependencies [2220a6d]:
Updated dependencies [`815ecf6`]:
815ecf6]:
Mirrors #4765 for teams and invitations: adapter.createTeam and adapter.createInvitation now pass forceAllowId: true, so ids returned from the respect
#9253 856ab24 Thanks @baptisteArno! - fix(organization): allow passing id through beforeCreateTeam and beforeCreateInvitation
Mirrors #4765 for teams and invitations: adapter.createTeam and adapter.createInvitation now pass forceAllowId: true, so ids returned from the respective hooks survive the DB insert.
#9331 9aa8e63 Thanks @gustavovalverde! - fix(oauth): support mapProfileToUser fallback for providers that may omit email
Social sign-in with OAuth providers that may return no email address (Discord phone-only accounts, Apple subsequent sign-ins, GitHub private emails, Facebook, LinkedIn, and Microsoft Entra ID managed users) can now be unblocked by synthesizing an email inside mapProfileToUser. Rejection logger messages now point at this workaround and at the new "Handling Providers Without Email" docs section.
Provider profile types now reflect where email can be null or absent:
DiscordProfile.email is string | null and optional (absent when the email scope is not granted)AppleProfile.email is optionalGithubProfile.email is string | nullFacebookProfile.email is optionalFacebookProfile.email_verified is optional (Meta's Graph API does not include this field)LinkedInProfile.email is optionalLinkedInProfile.email_verified is optionalMicrosoftEntraIDProfile.email is optionalTypeScript consumers who previously dereferenced profile.email directly inside mapProfileToUser will see a compile error that matches the runtime reality; use a nullish-coalescing fallback (profile.email ?? ...) or null-check the field.
Sign-in still rejects with error=email_not_found (social callback) or error=email_is_missing (Generic OAuth plugin) when neither the provider nor mapProfileToUser produces an email. First-class support for users without an email, keyed on (providerId, accountId) per OpenID Connect Core §5.7, is tracked in #9124.
Updated dependencies [9aa8e63]:
`ts socialProviders: { google: { clientId: [ process.env.GOOGLE_WEB_CLIENT_ID!, process.env.GOOGLE_IOS_CLIENT_ID!, process.env.GOOGLE_ANDROID_CLIENT_I
#9211 307196a Thanks @stewartjarod! - Preserve Set-Cookie headers accumulated on ctx.responseHeaders when an endpoint throws APIError. Cookie side-effects from deleteSessionCookie (and any ctx.setCookie / ctx.setHeader calls before the throw) are no longer silently discarded on the error path.
#9292 4f373ee Thanks @gustavovalverde! - Accept an array of Client IDs on providers that verify ID tokens by audience (Google, Apple, Microsoft Entra, Facebook, Cognito). The first entry is used for the authorization code flow; all entries are accepted when verifying an ID token's aud claim, so a single backend can serve Web, iOS, and Android clients with their platform-specific Client IDs.
socialProviders: {
google: {
clientId: [
process.env.GOOGLE_WEB_CLIENT_ID!,
process.env.GOOGLE_IOS_CLIENT_ID!,
process.env.GOOGLE_ANDROID_CLIENT_ID!,
],
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
}
Passing a single string keeps working; no migration needed.
Also exports getPrimaryClientId from @better-auth/core/oauth2 for provider authors: it returns the primary Client ID (the raw string, or the entry at array index 0), paired with clientSecret for the authorization code flow. Providers now reject empty arrays, empty strings, and missing config at sign-in time instead of silently producing a malformed authorization URL. Google, Apple, and Facebook require both clientId and clientSecret because each of those providers mandates a client secret for their server-side code exchange. Microsoft Entra and Cognito only require clientId, since both support public-client flows with PKCE alone (no secret).
#9293 e1b1cfc Thanks @gustavovalverde! - Guard against c.body being undefined in parseState. Callback requests that arrive as GET leave c.body unset in some runtimes, which caused c.body.state to throw a TypeError before the existing error redirect could run. The state lookup now short-circuits on the query parameter and falls back to c.body?.state safely, so a callback without a state parameter redirects to the error page instead of crashing.
#4894 d053a45 Thanks @Kinfe123! - Fire callbackOnVerification when a phone number is verified with updatePhoneNumber: true. The callback previously only ran on initial verification, so consumers relying on it (e.g. to sync verified numbers to an external system) would miss the event when an authenticated user changed their number.
Updated dependencies [307196a, 4a180f0, 4f373ee]:
Electron user-image proxy: SSRF bypasses closed (`@better-auth/electron`). fetchUserImage previously gated outbound requests with a bespoke IPv4/IPv6
#9214 4debfb6 Thanks @ping-maxwell! - fix(custom-session): use coerced boolean for disableRefresh query param validation
#9235 9ea7eb1 Thanks @bytaesu! - Preserve the Partitioned attribute when the customSession plugin and framework integrations forward Set-Cookie headers.
#9266 ab4c10f Thanks @ping-maxwell! - fix(organization): infer team additional fields correctly
#9219 a61083e Thanks @bytaesu! - Allow removing a phone number with updateUser({ phoneNumber: null }). The verified flag is reset atomically. Changing to a different number still requires OTP verification through verify({ updatePhoneNumber: true }).
#9226 e64ff72 Thanks @gustavovalverde! - Consolidate host/IP classification behind @better-auth/core/utils/host and close several loopback/SSRF bypasses that the previous per-package regex checks missed.
Electron user-image proxy: SSRF bypasses closed (@better-auth/electron). fetchUserImage previously gated outbound requests with a bespoke IPv4/IPv6 regex that missed multiple vectors. All of the following were reachable in production and are now blocked:
http://tenant.localhost/ and other *.localhost names (RFC 6761 reserves the entire TLD for loopback).http://[::ffff:169.254.169.254]/ (IPv4-mapped IPv6 to AWS IMDS, the classic SSRF bypass).http://metadata.google.internal/, http://metadata.goog/ (GCP instance metadata).http://instance-data/, http://instance-data.ec2.internal/ (AWS IMDS alternate FQDNs).http://100.100.100.200/ (Alibaba Cloud IMDS; lives in RFC 6598 shared address space 100.64/10, which the old regex did not cover).http://0.0.0.0:PORT/ (the Linux/macOS kernel routes the unspecified address to loopback: Oligo's "0.0.0.0 Day").http://[fc00::...]/, http://[fd00::...]/ (IPv6 ULA per RFC 4193) and IPv6 link-local fe80::/10, neither of which the regex recognized.Documentation ranges (RFC 5737 / RFC 3849), benchmarking (198.18/15), multicast, and broadcast are also now rejected.
better-auth: 0.0.0.0 is no longer treated as loopback. The previous isLoopbackHost implementation in packages/better-auth/src/utils/url.ts classified 0.0.0.0 alongside 127.0.0.1 / ::1 / localhost. 0.0.0.0 is the unspecified address, not loopback; treating it as such lets browser-origin requests reach localhost-bound dev services (Oligo's "0.0.0.0 Day"). The helper now accepts the full 127.0.0.0/8 range and any *.localhost name, and rejects 0.0.0.0.
better-auth: trusted-origin substring hardening. getTrustedOrigins previously used host.includes("localhost") || host.includes("127.0.0.1") when deciding whether to add an http:// variant for a dynamic baseURL.allowedHosts entry. Misconfigurations like evil-localhost.com or 127.0.0.1.nip.io would incorrectly gain an HTTP origin in the trust list. The check now uses the shared classifier, so only real loopback hosts get the HTTP variant.
@better-auth/oauth-provider: RFC 8252 compliance.
127.0.0.0/8 range (not just 127.0.0.1) plus [::1], with port-flexible comparison. Port-flexible matching is limited to IP literals; DNS names such as localhost continue to use exact-string matching per §8.3 ("NOT RECOMMENDED" for loopback).validateIssuerUrl uses the shared loopback check rather than a two-hostname literal comparison.New module: @better-auth/core/utils/host. Exposes classifyHost, isLoopbackIP, isLoopbackHost, and isPublicRoutableHost. One RFC 6890 / RFC 6761 / RFC 8252 implementation that handles IPv4, IPv6 (including bracketed literals, zone IDs, IPv4-mapped addresses, and 6to4 / NAT64 / Teredo tunnel forms with embedded-IPv4 recursion), and FQDNs, with a curated cloud-metadata FQDN set. All bespoke loopback/private/link-local checks across the monorepo now route through it.
Updated dependencies [b5742f9, a844c7d, e64ff72]:
@better-auth/drizzle-adapter@1.6.5
#9119 938dd80 Thanks @GautamBytes! - clarify recommended production usage for the test utils plugin
#9087 0538627 Thanks @ramonclaudio! - fix(client): refetch session after /change-password and /revoke-other-sessions
Updated dependencies []:
Both new ranges track the minor line that carries the vulnerability fix and nothing newer, so the adapters only advertise support for versions that ha…
#9205 9aed910 Thanks @gustavovalverde! - fix(two-factor): revert enforcement broadening from #9122
Restores the pre-#9122 enforcement scope. 2FA is challenged only on /sign-in/email, /sign-in/username, and /sign-in/phone-number, matching the behavior that shipped through v1.6.2. Non-credential sign-in flows (magic link, email OTP, OAuth, SSO, passkey, SIWE, one-tap, phone-number OTP, device authorization, email-verification auto-sign-in) are no longer gated by a 2FA challenge by default.
A broader enforcement scope with per-method opt-outs and alignment to NIST SP 800-63B-4 authenticator assurance levels is planned for a future minor release.
#9068 acbd6ef Thanks @GautamBytes! - Fix forced UUID user IDs from create hooks being ignored on PostgreSQL adapters when advanced.database.generateId is set to "uuid".
#9165 39d6af2 Thanks @gustavovalverde! - chore(adapters): require patched drizzle-orm and kysely peer versions
Narrows the drizzle-orm peer to ^0.45.2 and the kysely peer to ^0.28.14. Both new ranges track the minor line that carries the vulnerability fix and nothing newer, so the adapters only advertise support for versions that have actually been tested against. Consumers on older ORM releases see an install-time warning and can upgrade alongside the adapter; the peer is marked optional, so installs do not hard-fail.
Updated dependencies [39d6af2]:
Throw APIError with a clear message when the baseURL can't be resolved (no source and no fallback), instead of leaving ctx.context.baseURL = "" for do
#9131 5142e9c Thanks @gustavovalverde! - harden dynamic baseURL handling for direct auth.api.* calls and plugin metadata helpers
Direct auth.api.* calls
APIError with a clear message when the baseURL can't be resolved (no source and no fallback), instead of leaving ctx.context.baseURL = "" for downstream plugins to crash on.allowedHosts mismatches on the direct-API path to APIError.advanced.trustedProxyHeaders on the dynamic path (default true, unchanged). Previously x-forwarded-host / -proto were unconditionally trusted with allowedHosts; they now go through the same gate as the static path. The default flip to false ships in a follow-up PR.resolveRequestContext rehydrates trustedProviders and cookies per call (in addition to trustedOrigins). User-defined trustedOrigins(req) / trustedProviders(req) callbacks receive a Request synthesized from forwarded headers when no full Request is available.http for loopback hosts (localhost, 127.0.0.1, [::1], 0.0.0.0) on the headers-only protocol fallback, so local-dev calls don't silently resolve to https://localhost:3000.hasRequest uses isRequestLike, which now rejects objects that spoof Symbol.toStringTag without a real url / headers.get shape.Plugin metadata helpers
oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oAuthDiscoveryMetadata, and oAuthProtectedResourceMetadata forward the incoming request to their chained auth.api calls, so issuer and discovery URLs reflect the request host on dynamic configs.withMcpAuth forwards the incoming request to getMcpSession, threads trustedProxyHeaders, and emits a bare Bearer challenge when baseURL can't be resolved (instead of Bearer resource_metadata="undefined/...").metadataResponse in @better-auth/oauth-provider normalizes headers via new Headers() so callers can pass Headers, tuple arrays, or records without silently dropping entries.#9122 484ce6a Thanks @gustavovalverde! - fix(two-factor): enforce 2FA on all sign-in paths
The 2FA after-hook now triggers on any endpoint that creates a new session, covering magic-link, OAuth, passkey, email-OTP, SIWE, and all future sign-in methods. Authenticated requests (session refreshes, profile updates) are excluded.
#7231 f875897 Thanks @Byte-Biscuit! - fix(two-factor): preserve backup codes storage format after verification
After using a backup code, remaining codes are now re-saved using the same storeBackupCodes strategy (plain, encrypted, or custom) configured by the user. Previously, codes were always re-encrypted with the built-in symmetric encryption, breaking subsequent verifications for plain or custom storage modes.
#9072 6ce30cf Thanks @ramonclaudio! - fix(api): align top-level operationId on requestPasswordResetCallback with the OpenAPI resetPasswordCallback
#8389 f6428d0 Thanks @Oluwatobi-Mustapha! - fix(open-api): correct get-session nullable schema for OAS 3.1
#9078 9a6d475 Thanks @ping-maxwell! - fix(client): prevent isMounted race condition causing many rps
#9113 513dabb Thanks @bytaesu! - resolve dynamic baseURL from request headers on direct auth.api calls
#8926 c5066fe Thanks @bytaesu! - omit quantity for metered prices in checkout and upgrades
#9084 5f84335 Thanks @bytaesu! - support Stripe SDK v21 and v22
Updated dependencies [93d3871]:
Updated dependencies []:
The link-social callback used findAccount(accountId) which matched by account ID across all providers. When two providers return the same numeric ID (
#8949 9deb793 Thanks @ping-maxwell! - security: verify OAuth state parameter against cookie-stored nonce to prevent CSRF on cookie-backed flows
#8983 2cbcb9b Thanks @jaydeep-pipaliya! - fix(oauth2): prevent cross-provider account collision in link-social callback
The link-social callback used findAccount(accountId) which matched by account ID across all providers. When two providers return the same numeric ID (e.g. both Google and GitHub assign 99999), the lookup could match the wrong provider's account, causing a spurious account_already_linked_to_different_user error or silently updating the wrong account's tokens.
Replaced with findAccountByProviderId(accountId, providerId) to scope the lookup to the correct provider, matching the pattern already used in the generic OAuth plugin.
#9059 b20fa42 Thanks @gustavovalverde! - fix(next-js): replace cookie probe with header-based RSC detection in nextCookies() to prevent infinite router refresh loops and eliminate leaked __better-auth-cookie-store cookie. Also fix two-factor enrollment flows to set the new session cookie before deleting the old session.
#9058 608d8c3 Thanks @gustavovalverde! - fix(sso): include RelayState in signed SAML AuthnRequests per SAML 2.0 Bindings §3.4.4.1
authnRequestsSigned: true without a private key now throws instead of silently sending unsigned requests.#8772 8409843 Thanks @aarmful! - feat(two-factor): include enabled 2fa methods in sign-in redirect response
The 2FA sign-in redirect now returns twoFactorMethods (e.g. ["totp", "otp"]) so frontends can render the correct verification UI without guessing. The onTwoFactorRedirect client callback receives twoFactorMethods as a context parameter.
otpOptions.sendOTP is configured.#8711 e78a7b1 Thanks @aarmful! - fix(two-factor): prevent unverified TOTP enrollment from gating sign-in
Adds a verified boolean column to the twoFactor table that tracks whether a TOTP secret has been confirmed by the user.
enableTwoFactor creates the row with verified: false. The row is promoted to verified: true only after verifyTOTP succeeds with a valid code.enableTwoFactor when TOTP is already verified): the new row preserves verified: true, so the user is never locked out of sign-in while rotating their TOTP secret.verifyTOTP rejects rows where verified === false, preventing abandoned enrollments from blocking authentication. Backup codes and OTP are unaffected and work as fallbacks during unfinished enrollment.Migration: The new column defaults to true, so existing twoFactor rows are treated as verified. No data migration is required. skipVerificationOnEnable: true is also unaffected — the row is created as verified: true in that mode.
Updated dependencies []:
@better-auth/drizzle-adapter@1.6.1
#9023 2e537df Thanks @jonathansamines! - Update endpoint instrumentation to always use endpoint routes
#8902 f61ad1c Thanks @ping-maxwell! - use INVALID_PASSWORD for all checkPassword failures
#9017 7495830 Thanks @bytaesu! - restore getSession accessibility in generic Auth<O> context
Updated dependencies []:
The oidc-provider plugin now emits a one-time runtime deprecation warning when instantiated and is marked as @deprecated in TypeScript. It will be rem…
#8836 5dd9e44 Thanks @gustavovalverde! - Add case-insensitive query support for database adapters
#8836 5dd9e44 Thanks @gustavovalverde! - Add optional version field to the plugin interface and expose version from all built-in plugins
#8985 dd537cb Thanks @gustavovalverde! - deprecate oidc-provider plugin in favor of @better-auth/oauth-provider
The oidc-provider plugin now emits a one-time runtime deprecation warning when instantiated and is marked as @deprecated in TypeScript. It will be removed in the next major version. Migrate to @better-auth/oauth-provider.
#8843 bd9bd58 Thanks @gustavovalverde! - enforce role-based authorization on SCIM management endpoints and normalize passkey ownership checks via shared authorization middleware
#8836 5dd9e44 Thanks @gustavovalverde! - Return additional user fields and session data from the magic-link verify endpoint
#8836 5dd9e44 Thanks @gustavovalverde! - Allow passwordless users to enable, disable, and manage two-factor authentication
#8836 5dd9e44 Thanks @gustavovalverde! - Prevent updateUser from overwriting unrelated username or displayUsername fields
#8836 5dd9e44 Thanks @gustavovalverde! - Use non-blocking scrypt for password hashing to avoid blocking the event loop
#8836 5dd9e44 Thanks @gustavovalverde! - Enforce username uniqueness when updating a user profile
#8836 5dd9e44 Thanks @gustavovalverde! - Align session fresh age calculation with creation time instead of update time
#8836 5dd9e44 Thanks @gustavovalverde! - Compare account cookie by provider accountId instead of internal id
#8836 5dd9e44 Thanks @gustavovalverde! - Trigger session signal after requesting email change in email-otp plugin
#8836 5dd9e44 Thanks @gustavovalverde! - Rethrow sendOTP failures in phone-number plugin instead of silently swallowing them
#8836 5dd9e44 Thanks @gustavovalverde! - Read OAuth proxy callback parameters from request body when using form_post response mode
#8980 469eee6 Thanks @bytaesu! - fix oauth state double-hashing when verification storeIdentifier is set to hashed
#8981 560230f Thanks @bytaesu! - Prevent any from collapsing auth.$Infer and auth.$ERROR_CODES. Preserve client query typing when body is any.
Updated dependencies [5dd9e44, 5dd9e44, 5dd9e44]:
Your coding agent can read these notes before it upgrades. Set up the MCP server →