Files
better-auth/packages/core/CHANGELOG.md

108 KiB

@better-auth/core

1.7.10

Patch Changes

  • #11612 fedd055 Thanks @vhinayindia! - If your resource server verifies access tokens with verifyAccessTokenRequest or verifyBearerToken and a jwksUrl, 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 remoteVerify configured, such a failure no longer falls back to remote introspection.

    The OAuth provider's /oauth2/introspect, /oauth2/userinfo and /oauth2/revoke behave the same way with the JWT plugin's jwks.remoteUrl: they return 500 instead of reporting the token as invalid.

  • #11805 bbd0a19 Thanks @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 call verifyAccessTokenRequest or verifyBearerToken with jwksUrl. Previously every such token caused a new fetch: a remote request with jwksUrl or jwks.remoteUrl, or otherwise a read of the jwks table. Anyone could send tokens with made-up kid values 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 JWKSTimeout error 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 kid in that time. No configuration change is needed.

1.7.9

1.7.8

Patch Changes

  • #11512 4175b34 Thanks @bytaesu! - Align the installed @better-auth/utils version with better-call while accepting ^0.4.2 || ^0.5.0 across official package peers.

  • #11592 435645e Thanks @drewsephski! - Fix Atlassian user info errors being logged as coming from Figma.

  • #11625 369176e Thanks @gustavovalverde! - Fixed single-use checks for apps that set advanced.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_jwt client authentication, and magic link or email OTP sign-ins that adopt an unverified account failed with a NOT NULL error on the id column. They now work. SIWE sign-in ignored the email field 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.

  • #11749 e78f15a Thanks @gustavovalverde! - ID token verification now allows 30 seconds of clock difference between your server and the identity provider when checking exp, nbf, and iat. Previously, a provider whose clock ran even slightly ahead (for example, an nbf set a fraction of a second in the future) made sign-in fail. This affected generic OAuth providers configured with discoveryUrl since 1.7.0, ID token sign-in with the built-in social providers, and Google One Tap. Tokens up to 30 seconds past exp are now also accepted. No configuration change is needed. A custom verifyIdToken replaces this check and must set its own tolerance, and the SSO plugin's OIDC sign-in is unchanged.

  • #11663 60525e0 Thanks @kaiizer777! - Fixed Microsoft Entra ID sign-in with an ID token (signIn.social with idToken, and linking an account with an ID token) rejecting every token. Microsoft publishes its signing keys without an alg field, 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 916ef5c Thanks @BilalAtique! - If you pass client to mongodbAdapter on 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 your MongoClient's timeoutMS. Under contention a request can take longer instead of failing.

    A retry runs the work again. databaseHooks before hooks can run more than once for one request, and after hooks 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 1d95ebf Thanks @bytaesu! - Fix missing device sessions and failed automatic session switching after cache eviction when storeSessionInDatabase is enabled and preserveSessionInDatabase is 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 6214610 Thanks @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's cursor://anysphere.cursor-mcp/oauth/callback. Previously, only the authority-free reverse-domain form, such as com.example.app:/callback, was accepted, and these registrations failed with invalid_redirect_uri.

    Cursor also omits application_type when it registers, so the web default still rejects it. To accept Cursor, set the new clientRegistrationDefaultApplicationType option to "infer". A dynamic registration that omits application_type then becomes native when 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 as native. The default remains "web", and an application_type the 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 only https redirect URIs on non-loopback hosts.

  • #11626 67743ed Thanks @gustavovalverde! - Password resets and password changes no longer keep the new password when revoking the user's sessions fails. With revokeSessionsOnPasswordReset or revokeOtherSessions, 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. onPasswordReset now 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 c46d2a6 Thanks @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 92dfcea Thanks @gustavovalverde! - A sign-up that fails after its session is created no longer leaves that session in secondary storage. With storeSessionInDatabase enabled and preserveSessionInDatabase off, 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 with resolveUser keep 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 a session.create.after database hook also stays revoked.

  • #11645 a9f455d Thanks @gustavovalverde! - On database adapters without transactions enabled, a request that fails after writing data now still runs the after hooks for those writes, such as databaseHooks after callbacks and secondary-storage session cleanup. Previously they were skipped even though the writes were kept.

  • #11711 9725f1b Thanks @bytaesu! - Fix subsequent authentication requests hanging after async context initialization is interrupted.

1.7.7

1.7.6

Patch Changes

  • #11333 631ac29 Thanks @bytaesu! - Preserve logical model identity when a custom model name matches another schema key.

1.7.5

Patch Changes

  • #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.

  • #11290 dae97ed Thanks @bytaesu! - Restore database option type inference for projects that do not use Cloudflare Workers.

1.7.4

Patch Changes

  • #11210 3ff842a Thanks @bytaesu! - Prevent bundlers such as Metro from failing when the optional OpenTelemetry API is not installed.

  • #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.

1.7.3

Patch Changes

  • #11179 352d012 Thanks @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 generate reports required fields that Better Auth never writes by reading the existing Prisma schema. Set advanced.database.validateSchema: false to disable runtime validation.

  • #9908 76d311f Thanks @harshil1712! - Add Cloudflare as a built-in social provider, with support for client-secret authentication and PKCE clients without a secret.

  • #11101 3e9e197 Thanks @bytaesu! - Add a custom token endpoint authentication strategy for providers that require non-standard request parameters.

  • #11068 157ec8d Thanks @bytaesu! - Improve request IP validation performance.

  • #11102 baa08f4 Thanks @bytaesu! - Fix TikTok sign-in and token refresh failing when configured with the documented clientKey and clientSecret options.

  • #11129 9e36635 Thanks @bytaesu! - Improve PayPal authorization code and refresh token requests, including PKCE parameter handling.

  • #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.

  • #11134 a2bae0c Thanks @bytaesu! - Improve Reddit token requests with OAuth-compliant Basic authentication and redirect protection.

  • #11189 1a1b7d5 Thanks @bytaesu! - Make consumeOne and incrementOne optional 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 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.

1.7.2

Patch Changes

  • #10938 557e19b Thanks @bytaesu! - Added synchronous auth endpoint context access with getCurrentAuthEndpointContext and optional access with tryGetCurrentAuthEndpointContext. The existing getCurrentAuthContext and getCurrentAuthContextAsyncLocalStorage functions remain available as deprecated compatibility APIs.

  • #10855 64da15b Thanks @bytaesu! - Prevent async context loss in Cloudflare Workers bundles that enable multiple runtime conditions.

  • #10982 b4ad5a1 Thanks @bytaesu! - Built-in placeholder emails now consistently use the namespaced {identifier}@{namespace}.placeholder.invalid format.

  • #10979 fced1a5 Thanks @bytaesu! - Allow relative callback and redirect URLs to use standard path, query, and fragment syntax while preserving open-redirect protections.

  • #10939 e1d4011 Thanks @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 5c45abc Thanks @gustavovalverde! - Client ID Metadata Documents now follow shared-cache freshness rules and fail closed when freshness is ambiguous. The plugin prefers s-maxage over max-age and Expires, honors s-maxage=0, conditionally revalidates with ETag or Last-Modified, and treats invalid or duplicate freshness directives as immediately stale. Concurrent refreshes converge on one client-resource link instead of failing on its unique constraint.

    Shared OAuth metadata validation now rejects a blank client_name without trimming a valid display name. Native private-use redirects require the RFC 8252 single-slash form, such as com.example.app:/callback. Native HTTP redirects accept only exact localhost, 127.0.0.1, or [::1] hosts; other 127.0.0.0/8 addresses 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. HTTP no-store, private, and Vary: * behavior is unchanged and never feeds metadata or validators into the governor.

    Node.js deployments can import fetchClientMetadataResource from @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 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.

  • #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.

  • #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.

    • Configure protected scopes with requiredScopes through RequireMcpAuthOptions or the matching createMcpProtectedRequestHandler verifier option. Exact membership remains the default; isScopeSatisfied can define hierarchical policies.
    • Use createInsufficientScopeError when an operation determines its required scopes dynamically. createResourceServerChallenge converts that signal and recognized token failures into safe RFC 6750 challenges.
    • Use 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).

  • #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.

    New capabilities

    • 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.
    • Generic-OAuth providers merge call-time additionalParams with the config-level authorizationUrlParams; call-time wins on key collision.
    • Cognito exposes a typed identityProvider?: string config option that maps to the identity_provider query parameter, avoiding magic strings.

    Security

    • The shared 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.
    • 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-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.

    OpenAPI

    • Added 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.

    Refactors

    • 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.

  • #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.

  • #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.

  • #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".

  • #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.

  • #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.

  • #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.

Patch Changes

  • #10126 de8394d Thanks @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] which URL normalizes to [::7f00:1]), the 6to4 relay anycast prefix 192.88.99.0/24 (RFC 7526), and deprecated site-local fec0::/10 (RFC 3879). SSRF gates built on isPublicRoutableHost / classifyHost (jwks_uri validation, SSO OIDC discovery, CIMD metadata fetches) now reject these.

  • #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.

  • #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.

  • #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.

  • #10129 3a79aff Thanks @gustavovalverde! - Add includeGrantedScopes option to the Google provider. Set to false to omit include_granted_scopes=true from the authorization URL by default so each OAuth flow requests only its own scopes instead of accumulating prior grants. Defaults to true, preserving current behavior; call-time additionalParams still wins for single-flow overrides.

  • #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.

1.7.0-rc.6

1.7.0-rc.5

1.7.0-rc.4

1.7.0-rc.3

Minor Changes

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

    Shared OAuth metadata validation now rejects a blank client_name without trimming a valid display name. Native private-use redirects require the RFC 8252 single-slash form, such as com.example.app:/callback. Native HTTP redirects accept only exact localhost, 127.0.0.1, or [::1] hosts; other 127.0.0.0/8 addresses 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. HTTP no-store, private, and Vary: * behavior is unchanged and never feeds metadata or validators into the governor.

    Node.js deployments can import fetchClientMetadataResource from @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 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.

    • Configure protected scopes with requiredScopes through RequireMcpAuthOptions or the matching createMcpProtectedRequestHandler verifier option. Exact membership remains the default; isScopeSatisfied can define hierarchical policies.
    • Use createInsufficientScopeError when an operation determines its required scopes dynamically. createResourceServerChallenge converts that signal and recognized token failures into safe RFC 6750 challenges.
    • Use 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.

Patch Changes

  • #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.

1.7.0-rc.2

Minor Changes

  • #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).

  • #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.

Patch Changes

  • #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.

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 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.

Patch Changes

  • #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.

  • #10129 3a79aff Thanks @gustavovalverde! - Add includeGrantedScopes option to the Google provider. Set to false to omit include_granted_scopes=true from the authorization URL by default so each OAuth flow requests only its own scopes instead of accumulating prior grants. Defaults to true, preserving current behavior; call-time additionalParams still wins for single-flow overrides.

1.7.0-beta.7

Minor Changes

  • #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.

Patch Changes

  • #10126 de8394d Thanks @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] which URL normalizes to [::7f00:1]), the 6to4 relay anycast prefix 192.88.99.0/24 (RFC 7526), and deprecated site-local fec0::/10 (RFC 3879). SSRF gates built on isPublicRoutableHost / classifyHost (jwks_uri validation, SSO OIDC discovery, CIMD metadata fetches) now reject these.

1.7.0-beta.6

Minor Changes

  • #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.

  • #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.

1.7.0-beta.5

Minor Changes

  • #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.

  • #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.

Patch Changes

1.7.0-beta.4

1.6.30

Patch Changes

  • #10833 07c1718 Thanks @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 2ae491e Thanks @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 a30e274 Thanks @bytaesu! - Add a utility for creating stable, namespaced placeholder emails on the reserved placeholder.invalid domain.

1.6.25

Patch Changes

  • #10294 0ffd1fb Thanks @jsj! - Send Apple OAuth PKCE code challenges during authorization so callback token exchanges include a matching code verifier.

1.6.24

Patch Changes

  • #9862 54fab08 Thanks @OrangeManLi! - Fix a request-state AsyncLocalStorage initialization race that could intermittently throw No 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 single AsyncLocalStorage instance 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 lazy node:async_hooks import settles, causing runWithRequestState().run() and a nested getCurrentRequestState() to land on different instances.

  • #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).

1.6.23

1.6.22

Patch Changes

  • #10241 8bd43d9 Thanks @gustavovalverde! - Refuse HTTP redirects on server-side OAuth requests

    Better 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 90d509e Thanks @ping-maxwell! - adapter.update now returns null when no row matches or when it is called without a predicate. Use updateMany for intentional bulk updates.

    The Kysely MySQL adapter no longer returns a row after a guarded update misses. Updates with an id guard also return the targeted row when id is not the first predicate. Keep MySQL rows-matched semantics enabled, which mysql2 does by default through FOUND_ROWS; disabling it can make idempotent updates look like misses.

    The Prisma adapter now returns null when 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 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.

  • #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.

  • #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.

1.6.20

Patch Changes

  • #8734 930f534 Thanks @sleepe229! - Declare inherited APIError properties to fix TypeScript inference errors.

1.6.19

Patch Changes

  • #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.

  • #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.

1.6.18

Patch Changes

  • #9583 b21a5f7 Thanks @GautamBytes! - Fix plugin-provided client methods and additional session fields not being inferred in composite monorepos.

1.6.17

Patch Changes

  • #9993 baeaa00 Thanks @gustavovalverde! - Add the optional incrementOne adapter method and the optional SecondaryStorage.increment method. incrementOne atomically 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.increment atomically increments a counter and sets its time-to-live only when the key is first created.

  • #9987 7343284 Thanks @bytaesu! - Fixed a memory leak where the JWKS cache could grow on every access token verification.

  • #10003 fdef997 Thanks @gustavovalverde! - Microsoft Entra ID sign-in now honors the configured tenant restriction. tenantId: "organizations" rejects personal Microsoft accounts, and tenantId: "consumers" rejects work and school accounts. Both were accepted before.

  • #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.

  • #10003 fdef997 Thanks @gustavovalverde! - A Reddit user with no email now receives a non-routable placeholder address (<id>@reddit.invalid) instead of one on the real reddit.com domain, so it cannot match a deliverable mailbox. The address stays unverified, and mapProfileToUser can supply a real email.

  • #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.

  • #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.

  • #10003 fdef997 Thanks @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 with mapProfileToUser.

1.6.16

Patch Changes

  • #9974 cb1cbfa Thanks @Bekacru! - Validate Facebook opaque access tokens against the configured app. Previously verifyIdToken returned true for any non-JWT token and getUserInfo called Graph /me with 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 the debug_token endpoint, requiring is_valid, an app_id that matches one of the configured client ids, and a user_id that matches the returned profile, before the token is accepted. A client secret must be configured for access-token sign-in to work.

  • #9974 cb1cbfa Thanks @Bekacru! - Enforce the Google hd (hosted domain) option against the id token. Previously hd was only sent to Google as an authorization hint, which does not by itself restrict sign-in to the configured Workspace domain. When hd is set, the hd claim on the verified id token (verifyIdToken) and the decoded callback profile (getUserInfo) must be present and match, otherwise sign-in is rejected.

  • #9974 cb1cbfa Thanks @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's kid, 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 a kid. 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 cb1cbfa Thanks @Bekacru! - Cryptographically verify PayPal ID tokens on direct sign-in. Previously verifyIdToken only decoded the JWT and checked that a sub claim 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 the aud pinned to the configured clientId, a maxTokenAge bound, and the nonce checked when supplied.

  • #9974 cb1cbfa Thanks @Bekacru! - Stop mapping the Reddit oauth_client_id to the user's email. Reddit's identity scope does not return an email address, and the provider previously stored oauth_client_id (which identifies the OAuth application and is the same for every user of the app) as user.email with has_verified_email as emailVerified. 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 from mapProfileToUser when 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 via mapProfileToUser if you need the actual address.

  • #9974 cb1cbfa Thanks @Bekacru! - Fix verifyAccessToken silently dropping the configured audience check during remote introspection. Previously, when a required audience was set in verifyOptions but the introspection response omitted the aud claim, 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 mismatching aud is rejected. Authorization servers that legitimately omit aud from introspection responses (it is OPTIONAL per RFC 7662) can opt back into the old behavior with the new remoteVerify.allowMissingAudience: true flag, which still rejects mismatching audiences.

1.6.15

1.6.14

Patch Changes

  • #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.

1.6.13

Minor Changes

  • #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.

    New capabilities

    • 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.
    • Generic-OAuth providers merge call-time additionalParams with the config-level authorizationUrlParams; call-time wins on key collision.
    • Cognito exposes a typed identityProvider?: string config option that maps to the identity_provider query parameter, avoiding magic strings.

    Security

    • The shared 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.
    • 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-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.

    OpenAPI

    • Added 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.

    Refactors

    • 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.

Patch Changes

  • #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.

1.7.0-beta.3

1.7.0-beta.2

1.7.0-beta.1

1.7.0-beta.0

Minor Changes

  • #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.

1.6.10

Patch Changes

  • #9395 2220a6d Thanks @cyphercodes! - Route Cloudflare Workers instrumentation imports to the pure no-op entry when OpenTelemetry is not installed.

1.6.9

Patch Changes

  • #9340 815ecf6 Thanks @erquhart! - fix(core): self-reference ./instrumentation in the adapter factory so the exports map routes edge/browser to the pure variant

1.6.8

Patch Changes

  • #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 optional
    • GithubProfile.email is string | null
    • FacebookProfile.email is optional
    • FacebookProfile.email_verified is optional (Meta's Graph API does not include this field)
    • LinkedInProfile.email is optional
    • LinkedInProfile.email_verified is optional
    • MicrosoftEntraIDProfile.email is optional

    TypeScript 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.

1.6.7

Patch Changes

  • #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.

  • #9281 4a180f0 Thanks @ramonclaudio! - fix(core): serve noop ./instrumentation on browser/edge conditions, matching ./async_hooks

  • #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).

1.6.6

Patch Changes

  • #9227 b5742f9 Thanks @bytaesu! - feat(core): add mapConcurrent bounded-concurrency utility at @better-auth/core/utils/async

  • #9111 a844c7d Thanks @jonathansamines! - @opentelemetry/api is now an optional peer dependency

  • #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.

    • §7.3 redirect URI matching now accepts the full 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.

1.6.5

1.6.4

1.6.3

1.6.2

1.6.1

1.6.0

Patch Changes

1.6.0-beta.0

Patch Changes