108 KiB
@better-auth/core
1.7.10
Patch Changes
-
#11612
fedd055Thanks @vhinayindia! - If your resource server verifies access tokens withverifyAccessTokenRequestorverifyBearerTokenand ajwksUrl, including through the MCP plugin, a key set that cannot be reached at all, for example a refused connection, now throws an error. Before, the token was rejected as invalid with 401 "no token payload", which hid the outage. A key set that answers with an HTTP error already threw.With
remoteVerifyconfigured, such a failure no longer falls back to remote introspection.The OAuth provider's
/oauth2/introspect,/oauth2/userinfoand/oauth2/revokebehave the same way with the JWT plugin'sjwks.remoteUrl: they return 500 instead of reporting the token as invalid. -
#11805
bbd0a19Thanks @gustavovalverde! - Servers that verify JWTs against a key set now refetch that key set at most once every 30 seconds per server process when a token names a key ID (kid) the cached set does not contain. This covers the OAuth provider plugin's introspection, revocation, UserInfo, and logout endpoints, and resource servers that callverifyAccessTokenRequestorverifyBearerTokenwithjwksUrl. Previously every such token caused a new fetch: a remote request withjwksUrlorjwks.remoteUrl, or otherwise a read of thejwkstable. Anyone could send tokens with made-upkidvalues to force one fetch per request, and the UserInfo endpoint needs no client credentials.Concurrent verifications now share one fetch, and a key set fetch that takes longer than 5 seconds fails with a
JWKSTimeouterror instead of waiting indefinitely.A token signed with a newly published key still verifies without waiting for the 5-minute cache to expire. It can be rejected for up to 30 seconds if the key set was already refetched for another unknown
kidin that time. No configuration change is needed.
1.7.9
1.7.8
Patch Changes
-
#11512
4175b34Thanks @bytaesu! - Align the installed@better-auth/utilsversion withbetter-callwhile accepting^0.4.2 || ^0.5.0across official package peers. -
#11592
435645eThanks @drewsephski! - Fix Atlassian user info errors being logged as coming from Figma. -
#11625
369176eThanks @gustavovalverde! - Fixed single-use checks for apps that setadvanced.database.generateId: "uuid". Default id generation is unaffected, and no migration is needed.- On SQLite and MySQL, SAML sign-in, DPoP-bound requests checked against the database replay store,
private_key_jwtclient authentication, and magic link or email OTP sign-ins that adopt an unverified account failed with aNOT NULLerror on theidcolumn. They now work. SIWE sign-in ignored theemailfield and used the wallet-derived address; it now uses the supplied email when no other account has it. - On Postgres and MongoDB, a reused SAML assertion, DPoP proof, or client assertion was accepted. Each one is now accepted only once.
With
generateId: "serial", behavior is unchanged: these checks need string ids, and Better Auth now logs a warning the first time one of these flows runs. - On SQLite and MySQL, SAML sign-in, DPoP-bound requests checked against the database replay store,
-
#11749
e78f15aThanks @gustavovalverde! - ID token verification now allows 30 seconds of clock difference between your server and the identity provider when checkingexp,nbf, andiat. Previously, a provider whose clock ran even slightly ahead (for example, annbfset a fraction of a second in the future) made sign-in fail. This affected generic OAuth providers configured withdiscoveryUrlsince 1.7.0, ID token sign-in with the built-in social providers, and Google One Tap. Tokens up to 30 seconds pastexpare now also accepted. No configuration change is needed. A customverifyIdTokenreplaces this check and must set its own tolerance, and the SSO plugin's OIDC sign-in is unchanged. -
#11663
60525e0Thanks @kaiizer777! - Fixed Microsoft Entra ID sign-in with an ID token (signIn.socialwithidToken, and linking an account with an ID token) rejecting every token. Microsoft publishes its signing keys without analgfield, which made verification fail. Those keys are now treated as RS256, and Microsoft ID tokens signed with any other algorithm are rejected. The redirect sign-in flow was not affected. -
#11515
916ef5cThanks @BilalAtique! - If you passclienttomongodbAdapteron a replica set, concurrent requests that write the same documents no longer fail with a 500. Before, MongoDB aborted all but one of them with a write conflict, so a SCIM directory that provisions several users at once returned 500 for most of them. The adapter now retries such a transaction, as MongoDB requires, for up to 120 seconds or until yourMongoClient'stimeoutMS. Under contention a request can take longer instead of failing.A retry runs the work again.
databaseHooksbeforehooks can run more than once for one request, andafterhooks still run once, after the commit. Work that the request does outside the database before it commits can also repeat on a retried request. For example, an email sign-up can send its verification email twice. Requests that succeed on the first attempt are unaffected, and so are other adapters. -
#11718
1d95ebfThanks @bytaesu! - Fix missing device sessions and failed automatic session switching after cache eviction whenstoreSessionInDatabaseis enabled andpreserveSessionInDatabaseis disabled. Automatic switching now follows the order of held sessions, and large lists or sessions whose users no longer exist no longer cause valid sessions to disappear. -
#10956
6214610Thanks @disbell! - Native OAuth clients and Client ID Metadata Documents can now use custom-scheme redirect URIs with a host and a path, such as Cursor'scursor://anysphere.cursor-mcp/oauth/callback. Previously, only the authority-free reverse-domain form, such ascom.example.app:/callback, was accepted, and these registrations failed withinvalid_redirect_uri.Cursor also omits
application_typewhen it registers, so thewebdefault still rejects it. To accept Cursor, set the newclientRegistrationDefaultApplicationTypeoption to"infer". A dynamic registration that omitsapplication_typethen becomesnativewhen any of its redirect URIs uses a custom scheme, and every redirect URI must pass the native rules. Setting"native"treats every registration that omits the field asnative. The default remains"web", and anapplication_typethe client sends is never overridden.Pairwise clients (
subject_type: "pairwise") cannot use these host-bearing custom-scheme redirect URIs. Web clients are unaffected: they still accept onlyhttpsredirect URIs on non-loopback hosts. -
#11626
67743edThanks @gustavovalverde! - Password resets and password changes no longer keep the new password when revoking the user's sessions fails. WithrevokeSessionsOnPasswordResetorrevokeOtherSessions, database sessions are revoked and the password is saved in one transaction when transactions are enabled for the adapter, so a failure leaves the old password and sessions in place. Sessions in secondary storage are still removed after commit, on a best-effort basis. This covers/reset-password, the email OTP and phone number reset endpoints, and/change-password.onPasswordResetnow runs after sessions are revoked, so a throwing callback no longer leaves them active, and a callback that lists the user's sessions no longer sees the revoked ones. -
#11712
c46d2a6Thanks @bytaesu! - Fix misleading errors for invalid runtime-provided AsyncLocalStorage values in the edge and browser fallback, and recognize AsyncLocalStorage initialized after importing Better Auth but before first use. -
#11643
92dfceaThanks @gustavovalverde! - A sign-up that fails after its session is created no longer leaves that session in secondary storage. WithstoreSessionInDatabaseenabled andpreserveSessionInDatabaseoff, the secondary-storage copy is written only after the sign-up commits, and listing a user's sessions now reads the database, so a session whose cached copy failed to write is still listed and revoked. With other secondary-storage settings, sign-up, passkey registration and SSO sign-in withresolveUserkeep the session write inside their transaction, so a storage failure rolls back the new user, passkey or account instead of keeping them without a session. A session revoked by asession.create.afterdatabase hook also stays revoked. -
#11645
a9f455dThanks @gustavovalverde! - On database adapters without transactions enabled, a request that fails after writing data now still runs the after hooks for those writes, such asdatabaseHooksaftercallbacks and secondary-storage session cleanup. Previously they were skipped even though the writes were kept. -
#11711
9725f1bThanks @bytaesu! - Fix subsequent authentication requests hanging after async context initialization is interrupted.
1.7.7
1.7.6
Patch Changes
- #11333
631ac29Thanks @bytaesu! - Preserve logical model identity when a custom model name matches another schema key.
1.7.5
Patch Changes
-
#11203
cb627ebThanks @dshukertjr! - Add adatabase.schemaNameoption for direct PostgreSQL connections. When set, the adapter and the CLI qualify every statement with that schema, soauth generatewrites a schema-qualified migration that creates the schema before its tables instead of relying on the connection'ssearch_path. -
#11290
dae97edThanks @bytaesu! - Restore database option type inference for projects that do not use Cloudflare Workers.
1.7.4
Patch Changes
-
#11210
3ff842aThanks @bytaesu! - Prevent bundlers such as Metro from failing when the optional OpenTelemetry API is not installed. -
#11224
c1756a2Thanks @bytaesu! - Addexperimental.instrumentation.enabledto disable Better Auth OpenTelemetry span creation per auth instance. Instrumentation remains enabled by default and independent of usage reporting.
1.7.3
Patch Changes
-
#11179
352d012Thanks @bytaesu! - Validate Drizzle schema objects and generated Prisma client models during initialization, including in production, and report mismatches with guidance for fixing them. These checks do not query the database and cannot detect unapplied migrations.For Prisma clients whose model metadata omits nullability,
auth generatereports required fields that Better Auth never writes by reading the existing Prisma schema. Setadvanced.database.validateSchema: falseto disable runtime validation. -
#9908
76d311fThanks @harshil1712! - Add Cloudflare as a built-in social provider, with support for client-secret authentication and PKCE clients without a secret. -
#11101
3e9e197Thanks @bytaesu! - Add a custom token endpoint authentication strategy for providers that require non-standard request parameters. -
#11068
157ec8dThanks @bytaesu! - Improve request IP validation performance. -
#11102
baa08f4Thanks @bytaesu! - Fix TikTok sign-in and token refresh failing when configured with the documentedclientKeyandclientSecretoptions. -
#11129
9e36635Thanks @bytaesu! - Improve PayPal authorization code and refresh token requests, including PKCE parameter handling. -
#11178
be0e007Thanks @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: falseto disable runtime validation.auth migraterefuses to apply changes when required unwritten columns need manual repair. -
#11134
a2bae0cThanks @bytaesu! - Improve Reddit token requests with OAuth-compliant Basic authentication and redirect protection. -
#11189
1a1b7d5Thanks @bytaesu! - MakeconsumeOneandincrementOneoptional again for custom database adapters, using guarded fallbacks when native methods are absent. Fallbacks require atomic conditional writes and accurate affected-row counts. Fallback increments can fail when contention exhausts their retries. -
#11153
2220ee7Thanks @bytaesu! - Restore sign-in compatibility with 1.6 databases by identifying accounts with(providerId, accountId)and removing theissuerrequirement 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
issuernullable or remove the column so sign-ups and account linking can succeed.auth migratedoes not perform this cleanup. Follow the upgrade guide for database-specific steps.
1.7.2
Patch Changes
-
#10938
557e19bThanks @bytaesu! - Added synchronous auth endpoint context access withgetCurrentAuthEndpointContextand optional access withtryGetCurrentAuthEndpointContext. The existinggetCurrentAuthContextandgetCurrentAuthContextAsyncLocalStoragefunctions remain available as deprecated compatibility APIs. -
#10855
64da15bThanks @bytaesu! - Prevent async context loss in Cloudflare Workers bundles that enable multiple runtime conditions. -
#10982
b4ad5a1Thanks @bytaesu! - Built-in placeholder emails now consistently use the namespaced{identifier}@{namespace}.placeholder.invalidformat. -
#10979
fced1a5Thanks @bytaesu! - Allow relative callback and redirect URLs to use standard path, query, and fragment syntax while preserving open-redirect protections. -
#10939
e1d4011Thanks @bytaesu! - Logs emitted while handling auth requests now respect the configured custom logger, log level, and disabled setting.
1.7.1
1.7.0
Minor Changes
-
#10577
5c45abcThanks @gustavovalverde! - Client ID Metadata Documents now follow shared-cache freshness rules and fail closed when freshness is ambiguous. The plugin preferss-maxageovermax-ageandExpires, honorss-maxage=0, conditionally revalidates with ETag or Last-Modified, and treats invalid or duplicate freshness directives as immediately stale. Concurrent refreshes converge on one client-resource link instead of failing on its unique constraint.Shared OAuth metadata validation now rejects a blank
client_namewithout trimming a valid display name. Native private-use redirects require the RFC 8252 single-slash form, such ascom.example.app:/callback. Native HTTP redirects accept only exactlocalhost,127.0.0.1, or[::1]hosts; other127.0.0.0/8addresses and localhost subdomains are rejected.CIMD now bounds metadata request amplification through
metadataFetchPolicy: same-client fetches coalesce, per-client pacing and global/per-origin concurrency reject immediately, and rolling 60-second budgets cap unique-client sprays. HTTPno-store,private, andVary: *behavior is unchanged and never feeds metadata or validators into the governor.Node.js deployments can import
fetchClientMetadataResourcefrom@better-auth/cimd/node. The transport resolves once, rejects any non-public DNS answer, pins the approved connection without using the global HTTPS pool, preserves Host and TLS certificate identity, and returns redirects and response bodies without buffering. Other runtimes remain responsible for providing an equivalent secure transport.Unknown draft-02 metadata members are now ignored and never persisted. Recognized secrets, privilege fields, and server controls remain fatal, while generic internal aliases and nonstandard client-credentials authority spellings are stripped.
-
#10402
763a267Thanks @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. -
#9948
3d04fabThanks @yordis! - feat(generic-oauth): addrefreshTokenParamsconfig to forward extra params on token refreshMulti-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 arefreshTokenParamsoption (object or sync/async function) that is merged into the refresh request body, withgrant_typeandrefresh_tokenprotected 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.refreshAccessTokennow accepts an optional secondctxargument; the change is backwards compatible because existing implementations that take onlyrefreshTokenremain valid. See #7554. -
#9368
430c895Thanks @GautamBytes! - Generic OAuth users can now sign out from the configured OpenID provider when they callauthClient.signOut(). When a provider exposes a discovered or configured logout endpoint, Better Auth redirects to it and includes the storedid_token_hintwhen available. PasscallbackURLor configurepostLogoutRedirectURIfor the return flow, with optionalstate, or setdisableRedirectto handle the returnedurlyourself. When multiple linked providers support logout, Better Auth selects the most recently updated account. SetdisableProviderLogout: trueto keep sign-out local. -
#10577
5c45abcThanks @gustavovalverde! - MCP clients that hit a scope wall now learn exactly which scopes to ask for. Missing protected scopes produce a403with an RFC 6750insufficient_scopeWWW-Authenticatechallenge that names every missing scope. Clients can union those scopes into one authorization request instead of opening one browser redirect per scope.- Configure protected scopes with
requiredScopesthroughRequireMcpAuthOptionsor the matchingcreateMcpProtectedRequestHandlerverifier option. Exact membership remains the default;isScopeSatisfiedcan define hierarchical policies. - Use
createInsufficientScopeErrorwhen an operation determines its required scopes dynamically.createResourceServerChallengeconverts that signal and recognized token failures into safe RFC 6750 challenges. - Use
challengeScopesonly as the unauthenticated challenge hint.
Handler-produced responses, ordinary permission denials, configuration failures, and unrelated thrown values keep their original status and identity.
- Configure protected scopes with
-
#10403
dbd302eThanks @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.issuerbut preservesAccount.accountIdas the provider-assigned account identifier. Account-specific APIs select the localAccount.idthrough theaccountIdrequest property; token and provider-profile APIs can instead select the signed account cookie withuseAccountCookie: true. Credential accounts uselocal:credentialand the linked user's stableidas their provider identity.OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses
sub, plain OAuth usesid, and providers can declareaccountSubjectfor another immutable field; Better Auth no longer switches betweensubandidat runtime.getUserInfo().userno longer carries provider identity, andmapProfileToUsercannot returnid. Read the selected identity fromaccountInfo.account.accountIdinstead ofaccountInfo.user.id. The genericmicrosoftEntraIdhelper 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
subclaim, and SAML uses the signedNameID;mapping.idis removed from both configurations. A manual SAML configuration without metadata XML must setidpMetadata.entityID, becausesamlConfig.issueridentifies 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
8784c1cThanks @ping-maxwell! - Database joins have moved out ofexperimentalinto a stable option atadvanced.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). -
#10204
0683a5fThanks @GautamBytes! - Microsoft sign-in now identifies Entra accounts with the stableoidclaim in both the built-inmicrosoftprovider and the Generic OAuthmicrosoftEntraIdhelper. Tokens without a validoidare rejected, and the Generic OAuth helper refuses to initialize unless Microsoft discovery provides ID-token verification metadata. Existing Microsoft account rows created fromsubmust be migrated before upgrading. -
#9305
e7eb45bThanks @gustavovalverde! - feat(oauth): per-requestadditionalParamsandloginHintparity acrosssignIn.social,linkSocial, andsignIn.ssoUnified 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'sidentity_provider=Google, or Microsoft'sdomain_hintcould only be set as static server configuration.New capabilities
signIn.social,linkSocial, andsignIn.ssoacceptadditionalParams: Record<string, string>. Values are appended to the authorization URL as query parameters.linkSocialalso acceptsloginHint, matching the surface ofsignIn.socialandsignIn.sso.OAuthProvider.createAuthorizationURLgainsadditionalParamsin its input contract; every built-in provider forwards it to the shared helper.- Generic-OAuth providers merge call-time
additionalParamswith the config-levelauthorizationUrlParams; call-time wins on key collision. - Cognito exposes a typed
identityProvider?: stringconfig option that maps to theidentity_providerquery parameter, avoiding magic strings.
Security
- The shared
createAuthorizationURLhelper silently drops any caller-supplied key inRESERVED_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.nonceis reserved so a caller cannot replace the OIDC nonce Better Auth generates when binding a discovery provider'sid_tokento the authorization request. - Providers that use non-standard client identifiers (
wechat→appid,tiktok→client_key) additionally filter those keys so a caller cannot swap the configured OAuth app. - Provider protocol constants that are required for the integration to function (
atlassian→audience,notion→owner) are merged last so caller-suppliedadditionalParamscannot override them. Configured defaults that represent operator intent (e.g. Googleinclude_granted_scopes, CognitoidentityProvider) remain caller-overridable. signIn.ssorejectsadditionalParamswith 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.
OpenAPI
- Added
ZodRecordhandling to the OpenAPI generator soz.record()fields emittype: objectwith typedadditionalProperties. Incidentally fixes a long-standing bug whereadditionalDatawas rendered astype: string.
Refactors
discord,roblox,zoom, andslackproviders now delegate to the sharedcreateAuthorizationURLhelper and inherit its RFC behavior and reserved-key guard.tiktokandwechatkeep their manual URL construction (non-standard OAuth2 parameter names and URL fragment requirements) but threadadditionalParamswith the same reserved-key filter.
Closes #2351. Closes #5441. Closes #5592. Closes #5604. Supersedes #4992 and #5443.
-
#10127
7c7313cThanks @gustavovalverde! - OAuth sign-in, account linking, callback, and proxy flows now buildredirect_urifrom the current request base URL whenbaseURL.allowedHostsis configured. Built-in social providers and generic OAuth providers now use the resolved request host for redirects in multi-host deployments.Custom
OAuthProviderimplementations can omitcallbackPathwhen using the shared/callback/<provider-id>route. SetcallbackPathonly for custom callback routes. -
#10039
aedcb97Thanks @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_tokensat registration,dpop_jkton the authorization request, or by targeting a resource configured withdpopBoundAccessTokensRequired. Issued tokens carrycnf.jkt, returntoken_type: "DPoP", and stay bound through refresh-token rotation, introspection, and userinfo.Resource servers verify DPoP requests with
verifyAccessTokenRequest, which checks theAuthorization: DPoPscheme, 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.verifyAccessTokenRequestandrequireMcpAuthuse that store by default; build one withcreateDpopReplayStore(internalAdapter)or pass a customdpop.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
verifyAccessTokenis renamed toverifyBearerToken, both inbetter-auth/oauth2and as theoauthProviderResourceClientaction, and it rejects DPoP-bound tokens. UseverifyAccessTokenRequeston any endpoint that may receive them. The resource-request input type is renamed fromAccessTokenRequestInputtoResourceRequestInput, and the DPoP algorithm option issigningAlgorithmseverywhere.Run a schema migration for the DPoP token-binding fields: the
confirmationcolumn on the access-token and refresh-token tables. DPoP-bound clients also gaindpopBoundAccessTokensand resourcesdpopBoundAccessTokensRequired. No dedicated replay table is added; proof replay reuses the verification store. -
#9828
4f53b61Thanks @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-providerverifyIdTokenmethod. Each provider declares anidTokenconfig 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
idTokenconfig, and the client id_token path returnsID_TOKEN_NOT_SUPPORTED. PayPal sign-in through the redirect flow is unchanged.Custom providers that implement
UpstreamProviderdirectly replace the removedverifyIdTokenmethod with anidTokenconfig: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 }. TheverifyIdTokenanddisableIdTokenSignInprovider options are unchanged. -
#10065
2196ea6Thanks @gustavovalverde! - OAuth and device-authorization responses that carry credentials now consistently sendCache-Control: no-storeandPragma: 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/coreasNO_STORE_HEADERSfor responses built by hand. -
#9929
91f235fThanks @gustavovalverde! - AddrequireEmailVerificationto 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 return403EMAIL_NOT_VERIFIED. Verification emails follow the existingemailVerification.sendOnSignUp/sendOnSignInsettings.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 trustworthyemail_verifiedsignal. -
#8931
34558bcThanks @GautamBytes! - Add opt-in JWKS-backed asymmetric JWT support forsession_datacookie cache tokens, so services can verify cookie-cache JWTs with public keys instead of shared secrets. Enable it withjwt({ sessionCookieCache: true })alongsidesession.cookieCache.strategy = "jwt". -
#9657
1e5b808Thanks @gustavovalverde! - Hardenprivate_key_jwtand token endpoint client authentication, and add the helpers that make the fix structural.@better-auth/core/oauth2now exposesencodeBasicCredentialsanddecodeBasicCredentials, a round-trip-tested pair that follows RFC 6749 §2.3.1 (application/x-www-form-urlencodedeach 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_basicon 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 likebasic xxxorBasic xxxare accepted.createPrivateKeyJwtClientAssertionGettervalidates options eagerly. Unsupported algorithms (HS256,none), a JWK with no key material, and disagreement between an explicitalgorithmand the JWK-embeddedalgall throw at construction rather than on the first token request.signPrivateKeyJwtClientAssertionenforces the same checks for direct callers. Breaking: configurations that paired an unsupported JWKalgwith a different explicitalgorithmused to silently sign with the explicit option; they now fail at construction.Breaking:
@better-auth/oauth-provideraccepts clientjwksmetadata only as an RFC 7517 JWK Set object with a non-emptykeysarray. Replacejwks: [key]withjwks: { keys: [key] }in DCR payloads, administrative and user client creation, Client ID Metadata Documents, test fixtures, and generated client code. Remotely fetchedjwks_uriresponses 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 declaresalg, it must be a supportedprivate_key_jwtalgorithm that matches the key type and curve; omitalgwhen the client chooses the algorithm in its assertion header. OAuth client rows previously written throughoauthToSchemaare 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_jwtflow redirects witherror_description=no_private_key_availablewhen aresolvePrivateKeycallback returns noprivateKeyJwkorprivateKeyPem. 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/testaddsgetHttpTestInstance, a counterpart togetTestInstancethat 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
93d3871Thanks @gustavovalverde! - Add client authentication configuration for token endpoint requests across the stack, includingprivate_key_jwt(RFC 7523).Generic OAuth providers now accept
tokenEndpointAuthfor token endpoint client authentication. UsetokenEndpointAuth: { method: "private_key_jwt", getClientAssertion }for JWT client assertions,{ method: "none" }for public clients, and{ method: "client_secret_basic" }or{ method: "client_secret_post" }withclientSecretfor explicit secret-based client authentication. The existingauthentication: "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, andPRIVATE_KEY_JWT_SIGNING_ALGORITHMS.Token endpoint client authentication parameters are derived from
clientId,clientSecret, andtokenEndpointAuth. Configured token endpoint authentication requiresclientId; secret-based token endpoint authentication also requiresclientSecret. Custom token parameters are for provider-specific fields and do not replace the configured client authentication values.refreshAccessToken()now forwardsresourcevalues to refresh-token requests, so RFC 8707 resource indicators work through both the high-level refresh helper andrefreshAccessTokenRequest().The synchronous OAuth2 request builders
createAuthorizationCodeRequest,createRefreshAccessTokenRequest, andcreateClientCredentialsTokenRequesthave been removed. Use the asyncauthorizationCodeRequest,refreshAccessTokenRequest, andclientCredentialsTokenRequesthelpers 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.
-
#10473
ed61b47Thanks @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. -
#9864
41cca60Thanks @GautamBytes! - Add auser.validateUserInfoprovisioning 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.actionis"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
userplus asourcedescribing theaction(create-user,link-account, orsign-in), themethod, and provider metadata:source.oauthfor OAuth providers andsource.ssofor OIDC/SAML SSO providers. Return{ error, errorDescription }to reject: browser flows redirect to the error URL and programmatic flows return a403.
Patch Changes
-
#10126
de8394dThanks @gustavovalverde! - Harden the host classifier (@better-auth/core/utils/host) against three non-public address forms it previously reported as public: deprecated IPv4-compatible IPv6 (::w.x.y.z, RFC 4291 §2.5.5.1, e.g.[::127.0.0.1]whichURLnormalizes to[::7f00:1]), the 6to4 relay anycast prefix192.88.99.0/24(RFC 7526), and deprecated site-localfec0::/10(RFC 3879). SSRF gates built onisPublicRoutableHost/classifyHost(jwks_urivalidation, SSO OIDC discovery, CIMD metadata fetches) now reject these. -
#9898
7fe0e2bThanks @ItalyPaleAle! - AddclientAssertionsupport to the Microsoft Entra ID social provider. -
#9301
03e6c94Thanks @gustavovalverde! - AddallowIdpInitiatedtoGenericOAuthConfigand SSOOIDCConfigto support providers that initiate OAuth without astateparameter (e.g. Clever). When enabled, stateless callbacks restart the OAuth flow server-side with fresh state and PKCE, preserving CSRF protection. Also hardensparseStateagainst undefined request bodies on GET callbacks. -
#10128
97903c9Thanks @gustavovalverde! - Preserve previously granted OAuth scopes across sign-in re-authentication and refresh-token requests.account.scopenow accumulates monotonically: newly granted scopes are merged in only when added vialinkSocial, and providers returning a narrower scope claim than the user has granted no longer shrink the stored value. -
#10390
0de88f5Thanks @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.
-
#10129
3a79affThanks @gustavovalverde! - AddincludeGrantedScopesoption to the Google provider. Set tofalseto omitinclude_granted_scopes=truefrom the authorization URL by default so each OAuth flow requests only its own scopes instead of accumulating prior grants. Defaults totrue, preserving current behavior; call-timeadditionalParamsstill wins for single-flow overrides. -
#10505
d701f90Thanks @gustavovalverde! - One Tap, Electron, and Expo client plugins now compose withcreateAuthClientwithout TypeScript errors, and the resulting client preserves each plugin's inferred actions.
1.7.0-rc.6
1.7.0-rc.5
1.7.0-rc.4
1.7.0-rc.3
Minor Changes
-
#10577
5c45abcThanks @gustavovalverde! - Client ID Metadata Documents now follow shared-cache freshness rules and fail closed when freshness is ambiguous. The plugin preferss-maxageovermax-ageandExpires, honorss-maxage=0, conditionally revalidates with ETag or Last-Modified, and treats invalid or duplicate freshness directives as immediately stale. Concurrent refreshes converge on one client-resource link instead of failing on its unique constraint.Shared OAuth metadata validation now rejects a blank
client_namewithout trimming a valid display name. Native private-use redirects require the RFC 8252 single-slash form, such ascom.example.app:/callback. Native HTTP redirects accept only exactlocalhost,127.0.0.1, or[::1]hosts; other127.0.0.0/8addresses and localhost subdomains are rejected.CIMD now bounds metadata request amplification through
metadataFetchPolicy: same-client fetches coalesce, per-client pacing and global/per-origin concurrency reject immediately, and rolling 60-second budgets cap unique-client sprays. HTTPno-store,private, andVary: *behavior is unchanged and never feeds metadata or validators into the governor.Node.js deployments can import
fetchClientMetadataResourcefrom@better-auth/cimd/node. The transport resolves once, rejects any non-public DNS answer, pins the approved connection without using the global HTTPS pool, preserves Host and TLS certificate identity, and returns redirects and response bodies without buffering. Other runtimes remain responsible for providing an equivalent secure transport.Unknown draft-02 metadata members are now ignored and never persisted. Recognized secrets, privilege fields, and server controls remain fatal, while generic internal aliases and nonstandard client-credentials authority spellings are stripped.
-
#9368
430c895Thanks @GautamBytes! - Generic OAuth users can now sign out from the configured OpenID provider when they callauthClient.signOut(). When a provider exposes a discovered or configured logout endpoint, Better Auth redirects to it and includes the storedid_token_hintwhen available. PasscallbackURLor configurepostLogoutRedirectURIfor the return flow, with optionalstate, or setdisableRedirectto handle the returnedurlyourself. When multiple linked providers support logout, Better Auth selects the most recently updated account. SetdisableProviderLogout: trueto keep sign-out local. -
#10577
5c45abcThanks @gustavovalverde! - MCP clients that hit a scope wall now learn exactly which scopes to ask for. Missing protected scopes produce a403with an RFC 6750insufficient_scopeWWW-Authenticatechallenge that names every missing scope. Clients can union those scopes into one authorization request instead of opening one browser redirect per scope.- Configure protected scopes with
requiredScopesthroughRequireMcpAuthOptionsor the matchingcreateMcpProtectedRequestHandlerverifier option. Exact membership remains the default;isScopeSatisfiedcan define hierarchical policies. - Use
createInsufficientScopeErrorwhen an operation determines its required scopes dynamically.createResourceServerChallengeconverts that signal and recognized token failures into safe RFC 6750 challenges. - Use
challengeScopesonly as the unauthenticated challenge hint.
Handler-produced responses, ordinary permission denials, configuration failures, and unrelated thrown values keep their original status and identity.
- Configure protected scopes with
-
#10204
0683a5fThanks @GautamBytes! - Microsoft sign-in now identifies Entra accounts with the stableoidclaim in both the built-inmicrosoftprovider and the Generic OAuthmicrosoftEntraIdhelper. Tokens without a validoidare rejected, and the Generic OAuth helper refuses to initialize unless Microsoft discovery provides ID-token verification metadata. Existing Microsoft account rows created fromsubmust be migrated before upgrading.
Patch Changes
- #10505
d701f90Thanks @gustavovalverde! - One Tap, Electron, and Expo client plugins now compose withcreateAuthClientwithout TypeScript errors, and the resulting client preserves each plugin's inferred actions.
1.7.0-rc.2
Minor Changes
-
#10402
763a267Thanks @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
dbd302eThanks @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.accountIdis renamed toAccount.providerAccountId, andAccount.issueris required. Account-specific APIs select the localAccount.idthroughaccountId; token and provider-profile APIs can instead select the signed account cookie withuseAccountCookie: true. Credential accounts uselocal:credentialand the linked user's stableidas their provider identity.OAuth provider identity now comes from raw verified profiles. OpenID Connect discovery uses
sub, plain OAuth usesid, and providers can declareaccountSubjectfor another immutable field; Better Auth no longer switches betweensubandidat runtime.getUserInfo().userno longer carries provider identity, andmapProfileToUsercannot returnid. Read the selected identity fromaccountInfo.account.providerAccountIdinstead ofaccountInfo.user.id. The genericmicrosoftEntraIdhelper 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
subclaim, and SAML uses the signedNameID;mapping.idis removed from both configurations. A manual SAML configuration without metadata XML must setidpMetadata.entityID, becausesamlConfig.issueridentifies 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
8784c1cThanks @ping-maxwell! - Database joins have moved out ofexperimentalinto a stable option atadvanced.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). -
#10473
ed61b47Thanks @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.
Patch Changes
-
#10390
0de88f5Thanks @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.
1.7.0-rc.1
1.7.0-rc.0
1.7.0-beta.10
1.7.0-beta.9
1.7.0-beta.8
Minor Changes
-
#10127
7c7313cThanks @gustavovalverde! - OAuth sign-in, account linking, callback, and proxy flows now buildredirect_urifrom the current request base URL whenbaseURL.allowedHostsis configured. Built-in social providers and generic OAuth providers now use the resolved request host for redirects in multi-host deployments.Custom
OAuthProviderimplementations can omitcallbackPathwhen using the shared/callback/<provider-id>route. SetcallbackPathonly for custom callback routes.
Patch Changes
-
#10128
97903c9Thanks @gustavovalverde! - Preserve previously granted OAuth scopes across sign-in re-authentication and refresh-token requests.account.scopenow accumulates monotonically: newly granted scopes are merged in only when added vialinkSocial, and providers returning a narrower scope claim than the user has granted no longer shrink the stored value. -
#10129
3a79affThanks @gustavovalverde! - AddincludeGrantedScopesoption to the Google provider. Set tofalseto omitinclude_granted_scopes=truefrom the authorization URL by default so each OAuth flow requests only its own scopes instead of accumulating prior grants. Defaults totrue, preserving current behavior; call-timeadditionalParamsstill wins for single-flow overrides.
1.7.0-beta.7
Minor Changes
-
#9948
3d04fabThanks @yordis! - feat(generic-oauth): addrefreshTokenParamsconfig to forward extra params on token refreshMulti-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 arefreshTokenParamsoption (object or sync/async function) that is merged into the refresh request body, withgrant_typeandrefresh_tokenprotected 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.refreshAccessTokennow accepts an optional secondctxargument; the change is backwards compatible because existing implementations that take onlyrefreshTokenremain valid. See #7554.
Patch Changes
- #10126
de8394dThanks @gustavovalverde! - Harden the host classifier (@better-auth/core/utils/host) against three non-public address forms it previously reported as public: deprecated IPv4-compatible IPv6 (::w.x.y.z, RFC 4291 §2.5.5.1, e.g.[::127.0.0.1]whichURLnormalizes to[::7f00:1]), the 6to4 relay anycast prefix192.88.99.0/24(RFC 7526), and deprecated site-localfec0::/10(RFC 3879). SSRF gates built onisPublicRoutableHost/classifyHost(jwks_urivalidation, SSO OIDC discovery, CIMD metadata fetches) now reject these.
1.7.0-beta.6
Minor Changes
-
#10039
aedcb97Thanks @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_tokensat registration,dpop_jkton the authorization request, or by targeting a resource configured withdpopBoundAccessTokensRequired. Issued tokens carrycnf.jkt, returntoken_type: "DPoP", and stay bound through refresh-token rotation, introspection, and userinfo.Resource servers verify DPoP requests with
verifyAccessTokenRequest, which checks theAuthorization: DPoPscheme, 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.verifyAccessTokenRequestandrequireMcpAuthuse that store by default; build one withcreateDpopReplayStore(internalAdapter)or pass a customdpop.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
verifyAccessTokenis renamed toverifyBearerToken, both inbetter-auth/oauth2and as theoauthProviderResourceClientaction, and it rejects DPoP-bound tokens. UseverifyAccessTokenRequeston any endpoint that may receive them. The resource-request input type is renamed fromAccessTokenRequestInputtoResourceRequestInput, and the DPoP algorithm option issigningAlgorithmseverywhere.Run a schema migration for the DPoP token-binding fields: the
confirmationcolumn on the access-token and refresh-token tables. DPoP-bound clients also gaindpopBoundAccessTokensand resourcesdpopBoundAccessTokensRequired. No dedicated replay table is added; proof replay reuses the verification store. -
#10065
2196ea6Thanks @gustavovalverde! - OAuth and device-authorization responses that carry credentials now consistently sendCache-Control: no-storeandPragma: 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/coreasNO_STORE_HEADERSfor responses built by hand.
1.7.0-beta.5
Minor Changes
-
#9828
4f53b61Thanks @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-providerverifyIdTokenmethod. Each provider declares anidTokenconfig 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
idTokenconfig, and the client id_token path returnsID_TOKEN_NOT_SUPPORTED. PayPal sign-in through the redirect flow is unchanged.Custom providers that implement
OAuthProviderdirectly replace the removedverifyIdTokenmethod with anidTokenconfig: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 }. TheverifyIdTokenanddisableIdTokenSignInprovider options are unchanged. -
#9929
91f235fThanks @gustavovalverde! - AddrequireEmailVerificationto 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 return403EMAIL_NOT_VERIFIED. Verification emails follow the existingemailVerification.sendOnSignUp/sendOnSignInsettings.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 trustworthyemail_verifiedsignal. -
#9864
41cca60Thanks @GautamBytes! - Add auser.validateUserInfoprovisioning 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.actionis"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
userplus asourcedescribing theaction(create-user,link-account, orsign-in), themethod, and provider metadata:source.oauthfor OAuth providers andsource.ssofor OIDC/SAML SSO providers. Return{ error, errorDescription }to reject: browser flows redirect to the error URL and programmatic flows return a403.
Patch Changes
- #9898
7fe0e2bThanks @ItalyPaleAle! - AddclientAssertionsupport to the Microsoft Entra ID social provider.
1.7.0-beta.4
1.6.30
Patch Changes
- #10833
07c1718Thanks @bytaesu! - Prevent concurrent cold-start requests from intermittently losing authentication or transaction context.
1.6.29
1.6.28
1.6.27
Patch Changes
- #10657
2ae491eThanks @bytaesu! - Aligned endpoint and middleware context types with runtime route parameters, and preserved response headers when resolving sessions from endpoint contexts.
1.6.26
Patch Changes
- #10576
a30e274Thanks @bytaesu! - Add a utility for creating stable, namespaced placeholder emails on the reservedplaceholder.invaliddomain.
1.6.25
Patch Changes
- #10294
0ffd1fbThanks @jsj! - Send Apple OAuth PKCE code challenges during authorization so callback token exchanges include a matching code verifier.
1.6.24
Patch Changes
-
#9862
54fab08Thanks @OrangeManLi! - Fix a request-stateAsyncLocalStorageinitialization race that could intermittently throwNo request state found. Please make sure you are calling this function within a runWithRequestState callback.ensureAsyncStorage()now memoizes its in-flight initialization so concurrent first-callers share a singleAsyncLocalStorageinstance instead of each constructing one and the last write winning. This surfaced on serverless cold start (e.g. Cloudflare Workers) where the first requests arrive before the lazynode:async_hooksimport settles, causingrunWithRequestState().run()and a nestedgetCurrentRequestState()to land on different instances. -
#10376
c4d1ddaThanks @ping-maxwell! - Pass the request endpoint context as a third argument toverifyIdToken, so custom ID token verifiers can read request headers (for example Apple'suser-agentrequirement).
1.6.23
1.6.22
Patch Changes
-
#10241
8bd43d9Thanks @gustavovalverde! - Refuse HTTP redirects on server-side OAuth requestsBetter Auth refuses HTTP redirects on the server-side OAuth requests it makes: the token exchange, token refresh, client-credentials, token introspection, and JWKS requests. A provider endpoint cannot redirect one of these requests to an unintended internal address. Conformant OAuth providers answer these endpoints with a direct response and never redirect, so standard integrations are unaffected.
1.6.21
Patch Changes
-
#10180
90d509eThanks @ping-maxwell! -adapter.updatenow returnsnullwhen no row matches or when it is called without a predicate. UseupdateManyfor intentional bulk updates.The Kysely MySQL adapter no longer returns a row after a guarded update misses. Updates with an
idguard also return the targeted row whenidis not the first predicate. Keep MySQL rows-matched semantics enabled, which mysql2 does by default throughFOUND_ROWS; disabling it can make idempotent updates look like misses.The Prisma adapter now returns
nullwhen an update guard excludes the targeted row instead of surfacing Prisma's not-found exception. The shared adapter test suite now asserts the same fail-closed update behavior for adapter implementations. -
#10197
816d7f9Thanks @Paola3stefania! - Google sign-in now acceptshd: "*"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.
-
#10198
570267cThanks @rachit367! - HonordisableMigrationon plugin schema tables. Tables flagged withdisableMigration: trueare now skipped bybetter-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. -
#10203
5953157Thanks @bytaesu! - Rate limiting no longer trusts multi-hopX-Forwarded-Forchains, 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, setadvanced.ipAddress.trustedProxiesto your reverse-proxy IPs or CIDR ranges (the chain is walked right to left, skipping trusted hops), or pointadvanced.ipAddress.ipAddressHeadersat a single trusted client-IP header.
1.6.20
Patch Changes
- #8734
930f534Thanks @sleepe229! - Declare inheritedAPIErrorproperties to fix TypeScript inference errors.
1.6.19
Patch Changes
-
#10086
5bd5e1cThanks @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:incrementOnenow reports a clear error when called with noincrementand noset. -
#10070
a787e0bThanks @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.
1.6.18
Patch Changes
- #9583
b21a5f7Thanks @GautamBytes! - Fix plugin-provided client methods and additional session fields not being inferred in composite monorepos.
1.6.17
Patch Changes
-
#9993
baeaa00Thanks @gustavovalverde! - Add the optionalincrementOneadapter method and the optionalSecondaryStorage.incrementmethod.incrementOneatomically applies signed numeric deltas to a single row under a where-clause guard (for example, decrementing a remaining-uses counter only while it is still positive) and returns the updated row, or null when the guard matched no row. Adapters that do not implement it natively keep working through a transaction-based fallback.SecondaryStorage.incrementatomically increments a counter and sets its time-to-live only when the key is first created. -
#9987
7343284Thanks @bytaesu! - Fixed a memory leak where the JWKS cache could grow on every access token verification. -
#10003
fdef997Thanks @gustavovalverde! - Microsoft Entra ID sign-in now honors the configured tenant restriction.tenantId: "organizations"rejects personal Microsoft accounts, andtenantId: "consumers"rejects work and school accounts. Both were accepted before. -
#9993
baeaa00Thanks @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 optionalconsumemethod for strict enforcement; without it, the previous behavior is kept and a one-time warning is logged. -
#10003
fdef997Thanks @gustavovalverde! - A Reddit user with no email now receives a non-routable placeholder address (<id>@reddit.invalid) instead of one on the realreddit.comdomain, so it cannot match a deliverable mailbox. The address stays unverified, andmapProfileToUsercan supply a real email. -
#9993
baeaa00Thanks @gustavovalverde! - AddinternalAdapter.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. -
#9990
1dbf5bbThanks @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. WhenbaseURLis 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-scopedtrustedOriginscallback 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 intrustedOrigins. Google reCAPTCHA and Cloudflare Turnstile accept optionalexpectedActionandallowedHostnamesto 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. -
#10003
fdef997Thanks @gustavovalverde! - WeChat sign-in now succeeds with the documented default setup, which previously failed because WeChat returns no email address. The created user receives a stable, unverified placeholder email; supply a real one withmapProfileToUser.
1.6.16
Patch Changes
-
#9974
cb1cbfaThanks @Bekacru! - Validate Facebook opaque access tokens against the configured app. PreviouslyverifyIdTokenreturnedtruefor any non-JWT token andgetUserInfocalled Graph/mewith the caller-supplied token without checking which app issued it, so tokens issued for other Facebook apps were not distinguished on the direct sign-in path. Facebook tokens are now inspected via thedebug_tokenendpoint, requiringis_valid, anapp_idthat matches one of the configured client ids, and auser_idthat matches the returned profile, before the token is accepted. A client secret must be configured for access-token sign-in to work. -
#9974
cb1cbfaThanks @Bekacru! - Enforce the Googlehd(hosted domain) option against the id token. Previouslyhdwas only sent to Google as an authorization hint, which does not by itself restrict sign-in to the configured Workspace domain. Whenhdis set, thehdclaim on the verified id token (verifyIdToken) and the decoded callback profile (getUserInfo) must be present and match, otherwise sign-in is rejected. -
#9974
cb1cbfaThanks @Bekacru! - Scope the JWKS cache per source. Access-token verification previously kept a single global key set and reused it whenever it contained a key matching the token'skid, without considering which JWKS source the verification was for. When verifying tokens against more than one source, a token could end up matched against keys fetched for a different source if the two shared akid. The cache is now keyed per JWKS source and honors a TTL, so each verification uses the keys for its own source and rotated or removed keys are no longer used after the TTL elapses. -
#9974
cb1cbfaThanks @Bekacru! - Cryptographically verify PayPal ID tokens on direct sign-in. PreviouslyverifyIdTokenonly decoded the JWT and checked that asubclaim was present, performing no signature, issuer, audience, or expiration checks, so any well-formed token paired with a valid access token would be accepted. The token is now verified against PayPal's issuer and published JWKS (RS256) or the client secret (HS256), with theaudpinned to the configuredclientId, amaxTokenAgebound, and thenoncechecked when supplied. -
#9974
cb1cbfaThanks @Bekacru! - Stop mapping the Redditoauth_client_idto the user's email. Reddit'sidentityscope does not return an email address, and the provider previously storedoauth_client_id(which identifies the OAuth application and is the same for every user of the app) asuser.emailwithhas_verified_emailasemailVerified. This collapsed all Reddit users of the same app onto a single "verified" email, which could enable implicit account linking/takeover. The Reddit provider now uses the email returned frommapProfileToUserwhen provided, otherwise falls back to a unique per-user synthetic address (<reddit-user-id>@reddit.com), and no longer marks it as verified. Provide a real email viamapProfileToUserif you need the actual address. -
#9974
cb1cbfaThanks @Bekacru! - FixverifyAccessTokensilently dropping the configured audience check during remote introspection. Previously, when a requiredaudiencewas set inverifyOptionsbut the introspection response omitted theaudclaim, audience validation was skipped and any active token from the issuer was accepted — so a token issued for a different resource or client on the same issuer could also pass verification. Verification now requires the claim: a missing or mismatchingaudis rejected. Authorization servers that legitimately omitaudfrom introspection responses (it is OPTIONAL per RFC 7662) can opt back into the old behavior with the newremoteVerify.allowMissingAudience: trueflag, which still rejects mismatching audiences.
1.6.15
1.6.14
Patch Changes
- #9845
13abc79Thanks @gustavovalverde! - Harden redirect-URI validation across the OAuth provider plugins.isSafeUrlSchemeandSafeUrlSchemano longer callURL.canParse, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with atry/catchfallback.SafeUrlSchemaalso rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.
1.6.13
Minor Changes
-
#9305
e7eb45bThanks @gustavovalverde! - feat(oauth): per-requestadditionalParamsandloginHintparity acrosssignIn.social,linkSocial, andsignIn.ssoUnified 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'sidentity_provider=Google, or Microsoft'sdomain_hintcould only be set as static server configuration.New capabilities
signIn.social,linkSocial, andsignIn.ssoacceptadditionalParams: Record<string, string>. Values are appended to the authorization URL as query parameters.linkSocialalso acceptsloginHint, matching the surface ofsignIn.socialandsignIn.sso.OAuthProvider.createAuthorizationURLgainsadditionalParamsin its input contract; every built-in provider forwards it to the shared helper.- Generic-OAuth providers merge call-time
additionalParamswith the config-levelauthorizationUrlParams; call-time wins on key collision. - Cognito exposes a typed
identityProvider?: stringconfig option that maps to theidentity_providerquery parameter, avoiding magic strings.
Security
- The shared
createAuthorizationURLhelper silently drops any caller-supplied key inRESERVED_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. - Providers that use non-standard client identifiers (
wechat→appid,tiktok→client_key) additionally filter those keys so a caller cannot swap the configured OAuth app. - Provider protocol constants that are required for the integration to function (
atlassian→audience,notion→owner) are merged last so caller-suppliedadditionalParamscannot override them. Configured defaults that represent operator intent (e.g. Googleinclude_granted_scopes, CognitoidentityProvider) remain caller-overridable. signIn.ssorejectsadditionalParamswith 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.
OpenAPI
- Added
ZodRecordhandling to the OpenAPI generator soz.record()fields emittype: objectwith typedadditionalProperties. Incidentally fixes a long-standing bug whereadditionalDatawas rendered astype: string.
Refactors
discord,roblox,zoom, andslackproviders now delegate to the sharedcreateAuthorizationURLhelper and inherit its RFC behavior and reserved-key guard.tiktokandwechatkeep their manual URL construction (non-standard OAuth2 parameter names and URL fragment requirements) but threadadditionalParamswith the same reserved-key filter.
Closes #2351. Closes #5441. Closes #5592. Closes #5604. Supersedes #4992 and #5443.
-
#9657
1e5b808Thanks @gustavovalverde! - Hardenprivate_key_jwtand token endpoint client authentication, and add the helpers that make the fix structural.@better-auth/core/oauth2now exposesencodeBasicCredentialsanddecodeBasicCredentials, a round-trip-tested pair that follows RFC 6749 §2.3.1 (application/x-www-form-urlencodedeach 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_basicon 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 likebasic xxxorBasic xxxare accepted.createPrivateKeyJwtClientAssertionGettervalidates options eagerly. Unsupported algorithms (HS256,none), a JWK with no key material, and disagreement between an explicitalgorithmand the JWK-embeddedalgall throw at construction rather than on the first token request.signPrivateKeyJwtClientAssertionenforces the same checks for direct callers. Breaking: configurations that paired an unsupported JWKalgwith a different explicitalgorithmused to silently sign with the explicit option; they now fail at construction.@better-auth/oauth-providerrejects emptyjwkspayloads at the schema layer (jwks: []andjwks: { keys: [] }) so the documented client metadata contract matches whatcheckOAuthClientalready enforces at runtime. Schema consumers (TypeScript, OpenAPI, generated SDKs) now see the constraint.The SSO
private_key_jwtflow redirects witherror_description=no_private_key_availablewhen aresolvePrivateKeycallback returns noprivateKeyJwkorprivateKeyPem. 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/testaddsgetHttpTestInstance, a counterpart togetTestInstancethat 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.
Patch Changes
-
#9301
03e6c94Thanks @gustavovalverde! - AddallowIdpInitiatedtoGenericOAuthConfigand SSOOIDCConfigto support providers that initiate OAuth without astateparameter (e.g. Clever). When enabled, stateless callbacks restart the OAuth flow server-side with fresh state and PKCE, preserving CSRF protection. Also hardensparseStateagainst undefined request bodies on GET callbacks. -
#9845
13abc79Thanks @gustavovalverde! - Harden redirect-URI validation across the OAuth provider plugins.isSafeUrlSchemeandSafeUrlSchemano longer callURL.canParse, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with atry/catchfallback.SafeUrlSchemaalso rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.
1.7.0-beta.3
1.7.0-beta.2
1.7.0-beta.1
1.7.0-beta.0
Minor Changes
- #8836
93d3871Thanks @gustavovalverde! - Addprivate_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.
1.6.10
Patch Changes
- #9395
2220a6dThanks @cyphercodes! - Route Cloudflare Workers instrumentation imports to the pure no-op entry when OpenTelemetry is not installed.
1.6.9
Patch Changes
- #9340
815ecf6Thanks @erquhart! - fix(core): self-reference./instrumentationin the adapter factory so theexportsmap routes edge/browser to the pure variant
1.6.8
Patch Changes
-
#9331
9aa8e63Thanks @gustavovalverde! - fix(oauth): supportmapProfileToUserfallback for providers that may omit emailSocial 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
emailcan benullor absent:DiscordProfile.emailisstring | nulland optional (absent when theemailscope is not granted)AppleProfile.emailis optionalGithubProfile.emailisstring | nullFacebookProfile.emailis optionalFacebookProfile.email_verifiedis optional (Meta's Graph API does not include this field)LinkedInProfile.emailis optionalLinkedInProfile.email_verifiedis optionalMicrosoftEntraIDProfile.emailis optional
TypeScript consumers who previously dereferenced
profile.emaildirectly insidemapProfileToUserwill 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) orerror=email_is_missing(Generic OAuth plugin) when neither the provider normapProfileToUserproduces an email. First-class support for users without an email, keyed on(providerId, accountId)per OpenID Connect Core §5.7, is tracked in #9124.
1.6.7
Patch Changes
-
#9211
307196aThanks @stewartjarod! - PreserveSet-Cookieheaders accumulated onctx.responseHeaderswhen an endpoint throwsAPIError. Cookie side-effects fromdeleteSessionCookie(and anyctx.setCookie/ctx.setHeadercalls before the throw) are no longer silently discarded on the error path. -
#9281
4a180f0Thanks @ramonclaudio! - fix(core): serve noop./instrumentationonbrowser/edgeconditions, matching./async_hooks -
#9292
4f373eeThanks @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'saudclaim, 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
getPrimaryClientIdfrom@better-auth/core/oauth2for provider authors: it returns the primary Client ID (the raw string, or the entry at array index 0), paired withclientSecretfor 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 bothclientIdandclientSecretbecause each of those providers mandates a client secret for their server-side code exchange. Microsoft Entra and Cognito only requireclientId, since both support public-client flows with PKCE alone (no secret).
1.6.6
Patch Changes
-
#9227
b5742f9Thanks @bytaesu! - feat(core): addmapConcurrentbounded-concurrency utility at@better-auth/core/utils/async -
#9111
a844c7dThanks @jonathansamines! -@opentelemetry/apiis now an optional peer dependency -
#9226
e64ff72Thanks @gustavovalverde! - Consolidate host/IP classification behind@better-auth/core/utils/hostand close several loopback/SSRF bypasses that the previous per-package regex checks missed.Electron user-image proxy: SSRF bypasses closed (
@better-auth/electron).fetchUserImagepreviously 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*.localhostnames (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 space100.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-localfe80::/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.0is no longer treated as loopback. The previousisLoopbackHostimplementation inpackages/better-auth/src/utils/url.tsclassified0.0.0.0alongside127.0.0.1/::1/localhost.0.0.0.0is 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 full127.0.0.0/8range and any*.localhostname, and rejects0.0.0.0.better-auth: trusted-origin substring hardening.getTrustedOriginspreviously usedhost.includes("localhost") || host.includes("127.0.0.1")when deciding whether to add anhttp://variant for a dynamicbaseURL.allowedHostsentry. Misconfigurations likeevil-localhost.comor127.0.0.1.nip.iowould 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.- §7.3 redirect URI matching now accepts the full
127.0.0.0/8range (not just127.0.0.1) plus[::1], with port-flexible comparison. Port-flexible matching is limited to IP literals; DNS names such aslocalhostcontinue to use exact-string matching per §8.3 ("NOT RECOMMENDED" for loopback). validateIssuerUrluses the shared loopback check rather than a two-hostname literal comparison.
New module:
@better-auth/core/utils/host. ExposesclassifyHost,isLoopbackIP,isLoopbackHost, andisPublicRoutableHost. 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.
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
Patch Changes
- #8836
5dd9e44Thanks @gustavovalverde! - Skip recording redirect APIErrors as span errors in OpenTelemetry traces
1.6.0-beta.0
Patch Changes
28b1291Thanks @gustavovalverde! - Skip recording redirect APIErrors as span errors in OpenTelemetry traces