better-auth
❗ Breaking Changes
-
feat(captcha)!: support wildcard endpoint matching (#10004)
-
feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)
The route helper is renamed
requireMcpAuth(waswithMcpAuth), and the remote client iscreateMcpResourceClient(wascreateMcpAuthClient).requireMcpAuthverifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.To migrate, install
@better-auth/mcp, add thejwt()plugin (now required for token signing), and move options that were nested underoidcConfigto flat options onmcp({ ... }). The database models change:oauthApplicationbecomesoauthClient, with newoauthRefreshTokenandoauthClientAssertiontables. Regenerate or migrate your schema withnpx auth migrateornpx auth generate. -
feat(oauth-provider)!: add OIDC back-channel logout (#9304)
When a user's session ends at the OP (sign-out,
/oauth2/end-session, admin revoke, ban),@better-auth/oauth-providernow notifies every Relying Party that holds tokens for that session. The user's API access is cut off right away, instead of access tokens staying usable until their own TTL. Each client opts in by registering abackchannel_logout_uri(and optionallybackchannel_logout_session_required) via DCR or the admin client-create endpoint. The provider signs alogout+jwtLogout Token per client and POSTs it to that client in parallel, with a short per-RP timeout.Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns
{ active: false }, and/oauth2/userinforejects it withinvalid_token. Previously the token stayed active until its own TTL. If you relied on access tokens outliving the user's session, that no longer holds.Refresh tokens without
offline_accessare revoked on session end;offline_accessrefresh tokens are preserved so long-lived API access can survive the browser session (OIDC Back-Channel Logout 1.0 §2.7). Access-token invalidation on session end is an additional OP hardening choice beyond §2.7, enforced by session liveness, so it holds even when the JWT plugin is disabled.Delivery runs through the host's background task handler when one is configured (Vercel
waitUntil, Cloudflarectx.waitUntil); without a handler it completes inline so notifications are not lost on request teardown. Configureadvanced.backgroundTasks.handleron serverless runtimes to keep sign-out fast.Discovery at
/.well-known/openid-configurationand/.well-known/oauth-authorization-serveradvertisesbackchannel_logout_supported: trueandbackchannel_logout_session_supported: truewhen the JWT plugin is enabled. Registering abackchannel_logout_urirejects fragments, non-http(s) schemes, and non-HTTPS targets on confidential clients. Its SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, now also covers aprivate_key_jwtclient'sjwks_uri.Schema changes on
@better-auth/oauth-provider:oauthClient.backchannelLogoutUri: string | nulloauthClient.backchannelLogoutSessionRequired: booleanoauthAccessToken.revoked: Date | null
better-auth'ssignJWTgains an optionalheaderargument, forwarded to custom remote signers. JWT profiles that need an explicit media type, such astyp: "logout+jwt", can now set it without reaching for the low-level signing primitives. -
feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)
validAudiencesis removed. Move each existing resource identifier intoresources; link clients that should be limited to specific resources throughoauthClientResourceor Dynamic Client Registrationresources.Access-token issuance now applies resource policy to the requested RFC 8707
resourcevalues. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emitsjti, and keeps repeatedresourceform parameters.Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource
refreshTokenTtllonger thanrefreshTokenExpiresInwill see refresh tokens expire at the provider default instead of the longer resource value.JWT signing can now honor per-resource pins.
signJWT()acceptssigningKeyIdandsigningAlgorithm; JWKS adapters exposegetKeyById()andgetLatestKeyByAlg(). Thejwkstable adds nullablealgandcrvcolumns, andkeyPairConfigscan provision multiple algorithms in one keyring.After upgrading, run
npx @better-auth/cli generateand apply the migration before deploying. The migration addsoauthResource,oauthClientResource, and the newjwkscolumns. Without it, resources usingsigningAlgorithmcannot find matching keys.Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.
@better-auth/mcpnow requires an explicitresourceoption. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existingmcp({ loginPage, consentPage })setups should add a protected MCP resource identifier, for exampleresource: "https://api.example.com/mcp". -
feat(two-factor)!: add OTP enablement and discriminated response (#9057)
enableTwoFactornow accepts amethodparameter ("otp" | "totp", default"totp") and returns a discriminated response with amethodfield.method: "otp"- Sets
twoFactorEnabled: trueimmediately. - Returns
{ method: "otp" }. - Requires
otpOptions.sendOTPto be configured on the server; rejects withOTP_NOT_CONFIGUREDotherwise.
method: "totp"(default)- Returns
{ method: "totp", totpURI, backupCodes }. - Rejects with
TOTP_NOT_CONFIGUREDiftotpOptions.disableis set.
Breaking changes
- Removed
skipVerificationOnEnable: usemethod: "otp"for immediate activation, or the standard TOTP verification flow. - Response shape changed:
enableTwoFactorincludes amethodfield in the response ("otp"or"totp").
- Sets
-
fix(auth)!: ignore x-forwarded headers by default on dynamic baseURL (#9134)
Requests using
baseURL: { allowedHosts }now resolve the auth origin fromHostby default, so forwarded headers cannot select another allowed host unless trusted proxy headers are enabled.Breaking change: if your proxy exposes the public hostname only through
x-forwarded-host, setadvanced.trustedProxyHeaders: true. Deployments where the proxy rewritesHostto the public hostname (nginx default, Vercel, Cloudflare, and Netlify) are unaffected.Migration:
betterAuth({ baseURL: { allowedHosts: [...] }, advanced: { trustedProxyHeaders: true, }, });
-
fix(electron)!: enforce S256 PKCE and harden origin checks (#9645)
The Electron sign-in flow now mandates PKCE S256. Plain PKCE is rejected: the
code_challenge_methodparameter is gone and every authorization code is verified by hashing the verifier with SHA-256. The server no longer trusts anelectron-originheader to set the request Origin. The Electron client now sends a realOrigin(for examplemyapp:/), so upgrade the@better-auth/electronclient and server together and make sure your app's scheme is intrustedOrigins. The unuseddisableOriginOverrideoption is removed.Custom-scheme entries in
trustedOriginsnow match by scheme and authority instead of string prefix. A host-less entry such asmyapp://orexp://still trusts every host of that scheme, but a host-bearing entry such asmyapp://callbackmatches that host exactly, so it is no longer satisfied bymyapp://callback.attacker.tld. -
fix(one-tap)!: require client id for audience validation (#10036)
-
refactor!: remove deprecated oidc-provider plugin (#10031)
-
refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)
Breaking changes:
signIn.oauth2({ providerId })replaced bysignIn.social({ provider })oauth2.link()replaced bylinkSocial()- Callback URL changed from
/api/auth/oauth2/callback/:idto/api/auth/callback/:id genericOAuthClient()removed; generic OAuth providers now use the standard social client APIspkcedefaults totrue(wasfalse); setpkce: falsefor providers that reject PKCEauthorizationUrlParamsandtokenUrlParamsonly acceptRecord<string, string>issuerandrequireIssuerValidationconfig fields removed; issuer validation is automatic via OIDC discoverymapProfileToUserprofile typed asOAuth2UserInfo & Record<string, unknown>
-
refactor(oauth)!: verify provider
id_tokenswith a single shared verifier (#9828)Client-submitted id_token sign-in (
signIn.social({ idToken })and account linking) is verified by one function instead of a per-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.
Features
- feat: add
clientAssertionsupport to the Microsoft Entra ID social provider (#9898) - feat: make
Authinstance fetchable (#9431) - feat(auth): add per-provider
requireEmailVerificationfor social sign-in (#9929) - feat(auth): add user.validateUserInfo provisioning gate (#9864)
- feat(client): add
hydrateSessionfor SSR session hydration (#8733) - feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
- feat(generic-oauth): forward
refreshTokenParamsto token endpoint (#9948) - feat(generic-oauth): verify discovery
id_tokensand enableid_tokensign-in (#9966) - feat(oauth-provider): add DPoP support (#10039)
- feat(oauth-provider): compute
at_hashin id tokens per OIDC Core §3.1.3.6 (#9079) - feat(oauth): add
private_key_jwtclient authentication (RFC 7523) (#8836) - feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
- feat(oauth): per-request
additionalParamsandloginHint(#9305) - feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
- feat(org): allow passing userId and organizationId to listUserTeams API (#8977)
- feat(phone-number): add server-side OTP consumption API (#9766)
- feat(session): support JWKS-backed JWT session cookie cache (#8931)
- feat(username): add immutable username option (#9240)
Bug Fixes
- Bundled dependencies were refreshed to their latest compatible releases, including jose, nanostores, the noble crypto packages, and SimpleWebAuthn. These updates are backward compatible and require no changes to existing projects.
- fix(generic-oauth): bind id token nonce in redirect flow (#10095)
- fix(oauth): create new oauth account in transaction (#10125)
- fix(oauth): derive redirect URI from per-request baseURL (#10127)
- fix(oauth): preserve account.scope across re-auth and refresh (#10128)
- fix(oauth): preserve user on null profile override (#10124)
- fix(session): fire session-delete hooks for preserved sessions on secondaryStorage (#9969)
- refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)
For detailed changes, see CHANGELOG
@better-auth/oauth-provider
❗ Breaking Changes
-
feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)
The route helper is renamed
requireMcpAuth(waswithMcpAuth), and the remote client iscreateMcpResourceClient(wascreateMcpAuthClient).requireMcpAuthverifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.To migrate, install
@better-auth/mcp, add thejwt()plugin (now required for token signing), and move options that were nested underoidcConfigto flat options onmcp({ ... }). The database models change:oauthApplicationbecomesoauthClient, with newoauthRefreshTokenandoauthClientAssertiontables. Regenerate or migrate your schema withnpx auth migrateornpx auth generate. -
feat(oauth-provider)!: add OIDC back-channel logout (#9304)
When a user's session ends at the OP (sign-out,
/oauth2/end-session, admin revoke, ban),@better-auth/oauth-providernow notifies every Relying Party that holds tokens for that session. The user's API access is cut off right away, instead of access tokens staying usable until their own TTL. Each client opts in by registering abackchannel_logout_uri(and optionallybackchannel_logout_session_required) via DCR or the admin client-create endpoint. The provider signs alogout+jwtLogout Token per client and POSTs it to that client in parallel, with a short per-RP timeout.Breaking change. Introspection of an opaque or JWT access token whose bound session has ended now returns
{ active: false }, and/oauth2/userinforejects it withinvalid_token. Previously the token stayed active until its own TTL. If you relied on access tokens outliving the user's session, that no longer holds.Refresh tokens without
offline_accessare revoked on session end;offline_accessrefresh tokens are preserved so long-lived API access can survive the browser session (OIDC Back-Channel Logout 1.0 §2.7). Access-token invalidation on session end is an additional OP hardening choice beyond §2.7, enforced by session liveness, so it holds even when the JWT plugin is disabled.Delivery runs through the host's background task handler when one is configured (Vercel
waitUntil, Cloudflarectx.waitUntil); without a handler it completes inline so notifications are not lost on request teardown. Configureadvanced.backgroundTasks.handleron serverless runtimes to keep sign-out fast.Discovery at
/.well-known/openid-configurationand/.well-known/oauth-authorization-serveradvertisesbackchannel_logout_supported: trueandbackchannel_logout_session_supported: truewhen the JWT plugin is enabled. Registering abackchannel_logout_urirejects fragments, non-http(s) schemes, and non-HTTPS targets on confidential clients. Its SSRF host guard, which blocks private, reserved, tunneled, and cloud-metadata hosts, now also covers aprivate_key_jwtclient'sjwks_uri.Schema changes on
@better-auth/oauth-provider:oauthClient.backchannelLogoutUri: string | nulloauthClient.backchannelLogoutSessionRequired: booleanoauthAccessToken.revoked: Date | null
better-auth'ssignJWTgains an optionalheaderargument, forwarded to custom remote signers. JWT profiles that need an explicit media type, such astyp: "logout+jwt", can now set it without reaching for the low-level signing primitives. -
feat(oauth-provider)!: enforce max_age (#9936)
-
feat(oauth-provider)!: make id-token claim authority explicit (#10140)
customIdTokenClaims, extension ID-token claims, and per-issuanceidTokenClaimscan no longer set OIDC/JWT protocol claims such as issuer, subject, audience, token lifetime, nonce, session or hash binding,auth_time,acr,amr, orazp. Namespaced custom claims still appear in ID tokens. -
feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)
validAudiencesis removed. Move each existing resource identifier intoresources; link clients that should be limited to specific resources throughoauthClientResourceor Dynamic Client Registrationresources.Access-token issuance now applies resource policy to the requested RFC 8707
resourcevalues. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emitsjti, and keeps repeatedresourceform parameters.Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource
refreshTokenTtllonger thanrefreshTokenExpiresInwill see refresh tokens expire at the provider default instead of the longer resource value.JWT signing can now honor per-resource pins.
signJWT()acceptssigningKeyIdandsigningAlgorithm; JWKS adapters exposegetKeyById()andgetLatestKeyByAlg(). Thejwkstable adds nullablealgandcrvcolumns, andkeyPairConfigscan provision multiple algorithms in one keyring.After upgrading, run
npx @better-auth/cli generateand apply the migration before deploying. The migration addsoauthResource,oauthClientResource, and the newjwkscolumns. Without it, resources usingsigningAlgorithmcannot find matching keys.Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.
@better-auth/mcpnow requires an explicitresourceoption. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existingmcp({ loginPage, consentPage })setups should add a protected MCP resource identifier, for exampleresource: "https://api.example.com/mcp". -
fix(oauth-provider)!: bind client authentication to the issuing grant (#10063)
-
fix(oauth-provider)!: bind RFC 8707 resource indicators to the authorization grant (#9836)
Breaking change: when the authorization includes a
resource, the token and refresh requests may only narrow it. A request for a resource the authorization did not cover returnsinvalid_target. ThecustomAccessTokenClaimscallback now receives aresourcesarray in place of theresourcestring.Migration: run the schema migration (
npx @better-auth/cli migrate, orgenerateif you manage the schema yourself) to add the new resource columns. -
fix(oauth-provider)!: return RFC-compliant error envelopes from validation failures (#9277)
An internal
createOAuthEndpointwrapper now translates zod validation failures into the envelope required by RFC 6749 §5.2, 7009 §2.2.1, 7662 §2.3, and 7591 §3.2.2. Failing issues are routed per field:- an absent required value maps to
errorCodesByField[name].missingor the endpoint'sdefaultError. - an unsupported value (unknown enum member) maps to
errorCodesByField[name].invalidordefaultError. - any other failure (wrong type, duplicated query params, invalid format, refinement) maps to
defaultError, so RFC 6749 §3.1 malformed requests emit the endpoint's default code regardless of field.
All six OAuth endpoints (
/oauth2/token,/oauth2/authorize,/oauth2/revoke,/oauth2/introspect,/oauth2/register,/oauth2/end-session) now return RFC-compliant errors for malformed requests./oauth2/authorizevalidation failures redirect to the relying party witherror,error_description, echoedstate, andisswheneverclient_idandredirect_uriresolve against the registered client; requests without a trusted RP fall back to the server error page.Additional RFC compliance fixes on the same endpoints:
/oauth2/revokeand/oauth2/introspectnow ignore an unknowntoken_type_hintinstead of rejecting it. RFC 7009 §2.2.1 and RFC 7662 §2.1 reserveunsupported_token_typefor the token itself, not the hint value; servers MAY ignore unrecognized hints and search across supported token types./oauth2/authorizeerror redirects now respect OIDC Core 1.0 §5 response modes. Errors forresponse_type=tokenorid_tokenare delivered in the URL fragment per RFC 6749 §4.2.2.1; an explicitresponse_mode=queryoverrides the default.
- an absent required value maps to
-
refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)
Breaking changes:
signIn.oauth2({ providerId })replaced bysignIn.social({ provider })oauth2.link()replaced bylinkSocial()- Callback URL changed from
/api/auth/oauth2/callback/:idto/api/auth/callback/:id genericOAuthClient()removed; generic OAuth providers now use the standard social client APIspkcedefaults totrue(wasfalse); setpkce: falsefor providers that reject PKCEauthorizationUrlParamsandtokenUrlParamsonly acceptRecord<string, string>issuerandrequireIssuerValidationconfig fields removed; issuer validation is automatic via OIDC discoverymapProfileToUserprofile typed asOAuth2UserInfo & Record<string, unknown>
Features
- feat: add token endpoint client authentication (#9625)
- feat(cimd): add Client ID Metadata Document plugin (#9159)
- feat(oauth-provider): add DPoP support (#10039)
- feat(oauth-provider): add extension surface (#10030)
- feat(oauth-provider): add refresh token reuse interval (#10145)
- feat(oauth-provider): allow confidential DCR clients without PKCE (#10146)
- feat(oauth-provider): compute
at_hashin id tokens per OIDC Core §3.1.3.6 (#9079) - feat(oauth-provider): consistent and audience-scoped token introspection (#10045)
- feat(oauth-provider): expose sessionId to
id_tokenclaim contributors (#10113) - feat(oauth-provider): honor requested UserInfo claims via a claim registry (#10156)
- feat(oauth-provider): support protected dynamic client registration (#10037)
- feat(oauth): add
private_key_jwtclient authentication (RFC 7523) (#8836) - feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
- feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
Bug Fixes
- fix(oauth-provider): accept UserInfo form-body tokens (#10155)
- fix(oauth-provider): allow nonce-bound offline access without PKCE (#10153)
- fix(oauth-provider): challenge invalid userinfo tokens (#10068)
- fix(oauth-provider): handle OIDC authorization request inputs (#10151)
- fix(oauth-provider): keep OIDC scope claims on UserInfo (#10152)
- fix(oauth-provider): make
private_key_jwtjti single-use atomic across processes (#9964) - fix(oauth-provider): make
redirect_uriconditional at the token endpoint (#10159) - fix(oauth-provider): preserve dcr client key metadata (#10144)
- fix(oauth-provider): redirect missing
response_typeerrors (#10149) - fix(oauth-provider): reject authorization code replay correctly (#10150)
- fix(oauth-provider): report
unsupported_token_typefor JWT access-token revocation (#9970) - fix(oauth-provider): return invalid_grant for cross-client refresh tokens (#10154)
- refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)
For detailed changes, see CHANGELOG
@better-auth/sso
❗ Breaking Changes
-
feat(sso)!: support multiple IdP signing certificates (#8805)
SAML signing certificates now accept an array of PEM strings, so administrators can publish a new IdP cert alongside the old one and complete the rotation without forcing every active session to re-authenticate. Responses signed by any listed cert are accepted.
samlConfig: { idpMetadata: { cert: [currentPem, nextPem], }, }
Both
samlConfig.certandsamlConfig.idpMetadata.certaccept either a single PEM string or an array. When both are set,idpMetadata.certwins.Breaking: response shape
The management endpoints (
getSSOProvider,listSSOProviders,updateSSOProvider) now returnsamlConfig.certificateas an array of parsed certificates in every case, even when a single cert is configured. The field is absent only when certs live insideidpMetadata.metadata. Update consumers to read an array; no moreArray.isArraybranching.Validation
Registration now rejects SAML configs that supply no signing-cert source. samlify needs either an
idpMetadata.metadataXML document (which embeds the certs) or an explicit PEM undercertoridpMetadata.cert. Configs missing both fail withCERT_SOURCE_MISSING.Fix
SAML Single Logout could fail to decrypt encrypted
LogoutResponsepayloads because the IdP entity was constructed withoutprivateKey,encPrivateKey, orencPrivateKeyPasson that code path. All three are now applied on every IdP construction. -
fix(auth)!: harden validateUserInfo source contract (#9940)
-
fix(sso)!: harden SAML response validation (InResponseTo, Audience, SessionIndex) (#9055)
Breaking Changes
allowIdpInitiatednow defaults tofalse— IdP-initiated SSO (unsolicited SAML responses) is disabled by default. Setsaml.allowIdpInitiated: trueto restore the previous behavior. This aligns with the SAML2Int interoperability profile which recommends against IdP-initiated SSO due to its susceptibility to injection attacks.
Bug Fixes
- InResponseTo validation was completely non-functional — The code read
extract.inResponseTo(alwaysundefined) instead of samlify's actual pathextract.response.inResponseTo. SP-initiated InResponseTo validation now works as intended in both ACS handlers. - Audience Restriction was never validated — SAML assertions issued for a different service provider were accepted without checking the
<AudienceRestriction>element. Audience is now validated against the configuredsamlConfig.audiencevalue per SAML 2.0 Core §2.5.1. - SessionIndex stored as object instead of string — samlify returns
sessionIndexfrom login responses as{ authnInstant, sessionNotOnOrAfter, sessionIndex }, but the code stored the whole object. SLO session-index comparisons always failed silently. The correct innersessionIndexstring is now extracted.
Improvements
- Extracted shared
validateInResponseTo()andvalidateAudience()intopackages/sso/src/saml/response-validation.ts, eliminating ~160 lines of duplicated validation logic between the two ACS handlers. - Fixed
SAMLAssertionExtracttype to match samlify's actual extractor output shape.
-
refactor(sso)!: remove callbackUrl, consolidate ACS endpoint, fix SLO (#9117)
callbackUrlremoved fromsamlConfig.
The ACS URL is now always derived from yourbaseURLandproviderId. RemovecallbackUrlfrom your SAML provider configuration. The post-login redirect destination is set per sign-in viacallbackURLinsignIn.sso():await authClient.signIn.sso({ providerId: "my-provider", callbackURL: "/dashboard", });
/sso/saml2/callback/:providerIdendpoint removed.
Update your IdP's ACS URL to/sso/saml2/sp/acs/:providerId. This endpoint handles both GET and POST requests.spMetadatais now optional.
You no longer need to passspMetadata: {}when registering a provider. SP metadata is auto-generated from your configuration.Removed unused fields from
SAMLConfig:
decryptionPvk,additionalParams,idpMetadata.entityURL,idpMetadata.redirectURL. These were stored but never read. Remove them from your configuration if present.Bug fixes
- Fix SLO SessionIndex matching: LogoutRequests with a SessionIndex were silently failing to delete the correct session.
- Audience validation now defaults to the SP entity ID when
audienceis not configured, per SAML Core section 2.5.1. - Restore
AllowCreatein AuthnRequests, required by IdPs that use JIT provisioning. - SP metadata endpoint now reflects actual SP capabilities (encryption, signing, SLO).
Features
- feat(auth): add user.validateUserInfo provisioning gate (#9864)
- feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
- feat(oauth): add
private_key_jwtclient authentication (RFC 7523) (#8836) - feat(oauth): per-request
additionalParamsandloginHint(#9305) - feat(oauth): server-trusted state channel; fix anonymous cookieless linking (#9930)
- feat(sso): support additionalFields on ssoProvider (#9445)
Bug Fixes
- fix(sso): reject OIDC endpoint redirects portably (#10072)
- fix(sso): update samlify to 2.13.1 for signed-assertion XML injection (#9821)
- fix(sso): upgrade samlify to 2.12.0 with XPath injection and XXE fixes (#9121)
- refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)
For detailed changes, see CHANGELOG
@better-auth/mcp ✨
❗ Breaking Changes
-
feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992)
The route helper is renamed
requireMcpAuth(waswithMcpAuth), and the remote client iscreateMcpResourceClient(wascreateMcpAuthClient).requireMcpAuthverifies the bearer token against the published JWKS and passes the verified JWT claims to your handler.To migrate, install
@better-auth/mcp, add thejwt()plugin (now required for token signing), and move options that were nested underoidcConfigto flat options onmcp({ ... }). The database models change:oauthApplicationbecomesoauthClient, with newoauthRefreshTokenandoauthClientAssertiontables. Regenerate or migrate your schema withnpx auth migrateornpx auth generate. -
feat(oauth-provider)!: model OAuth protected resources explicitly (#9648)
validAudiencesis removed. Move each existing resource identifier intoresources; link clients that should be limited to specific resources throughoauthClientResourceor Dynamic Client Registrationresources.Access-token issuance now applies resource policy to the requested RFC 8707
resourcevalues. The OAuth provider narrows scopes to resource allowlists, uses the shortest configured TTL, strips reserved RFC 9068 claim names from custom claims, emitsjti, and keeps repeatedresourceform parameters.Refresh-token TTLs now use the shortest applicable lifetime. Deployments with a per-resource
refreshTokenTtllonger thanrefreshTokenExpiresInwill see refresh tokens expire at the provider default instead of the longer resource value.JWT signing can now honor per-resource pins.
signJWT()acceptssigningKeyIdandsigningAlgorithm; JWKS adapters exposegetKeyById()andgetLatestKeyByAlg(). Thejwkstable adds nullablealgandcrvcolumns, andkeyPairConfigscan provision multiple algorithms in one keyring.After upgrading, run
npx @better-auth/cli generateand apply the migration before deploying. The migration addsoauthResource,oauthClientResource, and the newjwkscolumns. Without it, resources usingsigningAlgorithmcannot find matching keys.Resource servers should publish RFC 9728 protected-resource metadata at their own origin. The OAuth provider exposes challenge helpers that point clients at that metadata.
@better-auth/mcpnow requires an explicitresourceoption. The plugin stores that identifier as an OAuth resource, publishes RFC 9728 protected-resource metadata for it, and binds issued access tokens to that resource. Existingmcp({ loginPage, consentPage })setups should add a protected MCP resource identifier, for exampleresource: "https://api.example.com/mcp".
Features
- feat(oauth-provider): add DPoP support (#10039)
- feat(oauth-provider): add refresh token reuse interval (#10145)
For detailed changes, see CHANGELOG
@better-auth/scim
❗ Breaking Changes
-
feat(scim)!: isolate provider connections by organization (#10249)
SCIM-managed accounts now use namespaced provider IDs (
scim:{organizationId}:{providerId}orscim:{providerId}for app-level static providers). Migrate only known SCIM-managed account rows before upgrading; leave non-SCIM accounts unchanged even when they share the same provider ID.Organization-scoped
active: falsenow makes a user inactive in that organization while keeping SCIM group and team associations available for reactivation. UseDELETEto fully deprovision organization-scoped SCIM state.defaultSCIMhas been replaced bystaticProviders.linkExistingUsers.trustedDomainshas been removed; userequireExistingOrgMembership,shouldLinkUser, or explicittrueinstead. -
fix(scim)!: always bind personal SCIM connections to their creator (#9840)
generateSCIMTokennow records the creator'suserIdon every personal connection. Thegenerate-token,list-provider-connections,get-provider-connection, anddelete-provider-connectionendpoints grant access only to that owner. Organization-scoped connections keep their existing behavior and continue to use organization membership and the configuredrequiredRolechecks.This release is breaking. It removes the
providerOwnershipoption, and owner binding can no longer be disabled. ThescimProvider.userIdcolumn is now a permanent part of the schema, so run a migration after upgrading withnpx auth migrateornpx auth generate.Connections created before this release carry no owner. Access now fails closed, so those connections are no longer reachable through the management endpoints, including token regeneration. Reclaim them at the database level: delete
scimProviderrows that have neitherorganizationIdnoruserId, or setuserIdto the intended owner, then regenerate tokens as needed. Organization-scoped connections are not affected.
Features
- feat(auth): add user.validateUserInfo provisioning gate (#9864)
- feat(scim): add durable group resources (#10018)
For detailed changes, see CHANGELOG
@better-auth/electron
❗ Breaking Changes
-
fix(electron)!: enforce S256 PKCE and harden origin checks (#9645)
The Electron sign-in flow now mandates PKCE S256. Plain PKCE is rejected: the
code_challenge_methodparameter is gone and every authorization code is verified by hashing the verifier with SHA-256. The server no longer trusts anelectron-originheader to set the request Origin. The Electron client now sends a realOrigin(for examplemyapp:/), so upgrade the@better-auth/electronclient and server together and make sure your app's scheme is intrustedOrigins. The unuseddisableOriginOverrideoption is removed.Custom-scheme entries in
trustedOriginsnow match by scheme and authority instead of string prefix. A host-less entry such asmyapp://orexp://still trusts every host of that scheme, but a host-bearing entry such asmyapp://callbackmatches that host exactly, so it is no longer satisfied bymyapp://callback.attacker.tld. -
refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)
Breaking changes:
signIn.oauth2({ providerId })replaced bysignIn.social({ provider })oauth2.link()replaced bylinkSocial()- Callback URL changed from
/api/auth/oauth2/callback/:idto/api/auth/callback/:id genericOAuthClient()removed; generic OAuth providers now use the standard social client APIspkcedefaults totrue(wasfalse); setpkce: falsefor providers that reject PKCEauthorizationUrlParamsandtokenUrlParamsonly acceptRecord<string, string>issuerandrequireIssuerValidationconfig fields removed; issuer validation is automatic via OIDC discoverymapProfileToUserprofile typed asOAuth2UserInfo & Record<string, unknown>
For detailed changes, see CHANGELOG
@better-auth/stripe
❗ Breaking Changes
- fix(stripe)!: make
onSubscriptionCancel.eventrequired (#9531) - fix(stripe)!: remove optional marker from onSubscriptionCancel
event(#9359)
For detailed changes, see CHANGELOG
@better-auth/core
❗ Breaking Changes
-
refactor(oauth)!: verify provider
id_tokenswith a single shared verifier (#9828)Client-submitted id_token sign-in (
signIn.social({ idToken })and account linking) is verified by one function instead of a per-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.
Features
- feat: add
clientAssertionsupport to the Microsoft Entra ID social provider (#9898) - feat(auth): add per-provider
requireEmailVerificationfor social sign-in (#9929) - feat(auth): add user.validateUserInfo provisioning gate (#9864)
- feat(generic-oauth,sso): support IDP-initiated flows via secure bounce (#9301)
- feat(generic-oauth): forward
refreshTokenParamsto token endpoint (#9948) - feat(google): add
includeGrantedScopesoption (#10129) - feat(oauth-provider): add DPoP support (#10039)
- feat(oauth): add
private_key_jwtclient authentication (RFC 7523) (#8836) - feat(oauth): enforce no-store on credential responses via a declarative flag (#10065)
- feat(oauth): per-request
additionalParamsandloginHint(#9305)
Bug Fixes
- fix(cimd): route
client_idSSRF checks through the shared host classifier (#10126) - fix(oauth): derive redirect URI from per-request baseURL (#10127)
- fix(oauth): preserve account.scope across re-auth and refresh (#10128)
- refactor(oauth): single-source Basic credentials + getHttpTestInstance (#9657)
For detailed changes, see CHANGELOG
auth
❗ Breaking Changes
- feat(oauth)!: accumulate granted scopes as
grantedScopes string[](#9825)
Features
- feat(cli): add create-admin command (#9547)
Bug Fixes
- refactor(cli): leverage c12 v4
resolveModulefor auth config loading (#9477) - revert(oauth): remove granted scopes architecture (#10123)
For detailed changes, see CHANGELOG
@better-auth/api-key
❗ Breaking Changes
- feat(auth)!: harden atomic state transitions (#10000)
Bug Fixes
- chore: sync main to next (#9533)
For detailed changes, see CHANGELOG
@better-auth/expo
❗ Breaking Changes
-
refactor(generic-oauth)!: rewrite as first-class social provider with RFC compliance (#9069)
Breaking changes:
signIn.oauth2({ providerId })replaced bysignIn.social({ provider })oauth2.link()replaced bylinkSocial()- Callback URL changed from
/api/auth/oauth2/callback/:idto/api/auth/callback/:id genericOAuthClient()removed; generic OAuth providers now use the standard social client APIspkcedefaults totrue(wasfalse); setpkce: falsefor providers that reject PKCEauthorizationUrlParamsandtokenUrlParamsonly acceptRecord<string, string>issuerandrequireIssuerValidationconfig fields removed; issuer validation is automatic via OIDC discoverymapProfileToUserprofile typed asOAuth2UserInfo & Record<string, unknown>
For detailed changes, see CHANGELOG
@better-auth/cimd ✨
Features
- feat(cimd): add Client ID Metadata Document plugin (#9159)
For detailed changes, see CHANGELOG
@better-auth/drizzle-adapter
Features
- feat(drizzle-adapter): support Drizzle Relations v2 (#9489)
For detailed changes, see CHANGELOG
@better-auth/i18n
Features
- feat(i18n): add built-in translations for 22 languages (#9157)
For detailed changes, see CHANGELOG
Contributors
Thanks to everyone who contributed to this release:
@adrianmxb, @app/better-release, @brentmitchell25, @bytaesu, @GautamBytes, @gustavovalverde, @ItalyPaleAle, @OscarCornish, @pi0, @ping-maxwell, @ruban-s, @sovetski, @yordis
Full changelog: v1.6.22...v1.7.0-rc.0