OAuth profile field sync ignores input: false fields
OAuth sign-up and account-link profile sync now ignore provider profile values for user fields marked input: false. Input-allowed additional fields still persist from mapProfileToUser, and schema defaults still apply when OAuth creates a user.
SSO provider ID namespace separation from OAuth
SSO provider IDs are now separate from the account-linking provider namespace used for social/OAuth providers. SSO registration rejects provider IDs that collide with a configured social provider, a trustedProviders entry, or a reserved built-in ID. The OIDC and SAML callbacks no longer derive trust from a trustedProviders name match — SSO trust comes solely from verified domain ownership (domainVerified).
OAuth hooks run through entire sign-in flow
Configured hooks.before and hooks.after on the auth instance now run for OAuth authorization that continues after a user signs in, selects an account, or consents. Headers or cookies a hooks.before sets are no longer dropped, and a hooks.after that throws an APIError no longer loses its cookies or headers.
OIDC RP-initiated logout requires id_token_hint
The OIDC provider's RP-initiated logout endpoint (/oauth2/endsession) no longer logs a user out or revokes their OAuth tokens in response to a cross-site GET that carries only a session cookie. Logout authenticated by a valid id_token_hint is unaffected.
Session state preservation across server instances
Stateless OAuth deployments can now read account info, access tokens, and refresh tokens after different server instances handle sign-in and later requests. Session refresh also keeps the OAuth account cookie instead of clearing it in that case.
OAuth popup plugin for iframe-based sign-in
An experimental oauthPopup plugin is now available with oauthPopupClient and signIn.popup for popup-based OAuth sign-in. It lets an app sign in inside a cross-site iframe by completing OAuth in a popup and handing the session token back to the opener, where the bearer plugin authenticates with it. The API may change while experimental.
OAuth proxy verifies state before creating sessions
The OAuth proxy, Google One Tap, and the Expo authorization proxy now reject redirect and callback targets that are not in trustedOrigins.
OAuth error logging respects custom logger
OAuth account-linking and create-user error logs now respect a custom logger configured in betterAuth(), instead of always being written to the default console logger.
OAuth popup plugin experimental status
The oauthPopup plugin is experimental and its API may change.
OAuth proxy profile callbacks state matching
OAuth proxy profile callbacks now verify that an OAuth state was issued before creating sessions.
Session state handling across deployments
Stateless deployments can now handle OAuth session data across multiple server instances by keeping OAuth account cookies through session refresh.
OAuth redirect URI validation hardened across providers
Redirect URI validation for OAuth providers was hardened to avoid relying on `URL.canParse`, which is absent on some runtimes and could throw or silently disable the dangerous-scheme check. `isSafeUrlScheme` and `SafeUrlSchema` now parse with a `try`/`catch` fallback. `SafeUrlSchema` also rejects redirect URIs containing a fragment component per RFC 6749 §3.1.2.
storeStateStrategy defaults to database-only setup
When only `secondaryStorage` is configured with no primary database, `storeStateStrategy` now defaults to `"database"` instead of `"cookie"`, preventing oversized-cookie errors on platforms like AWS Lambda. The account cookie that holds OAuth tokens in database-less setups stays enabled so `getAccessToken` keeps working.
OAuth redirect URI scheme validation in OIDC and MCP plugins
The `oidc-provider` and `mcp` plugins now reject `javascript:`, `data:`, and `vbscript:` URL schemes in OAuth `redirect_uri` registrations. The redirect-URI scheme policy now lives in `@better-auth/core` as `SafeUrlSchema` and `isSafeUrlScheme` helper, shared across OAuth provider plugins.
accountLinking.updateUserInfoOnLink applied to all OAuth link flows
The `accountLinking.updateUserInfoOnLink` option now applies across every OAuth link flow: `linkSocial`, generic OAuth `oauth2.link` endpoint, and implicit linking on social sign-in. Synced fields match the sign-up path: `name`, `image`, and fields added by `mapProfileToUser`. Local `email` and `emailVerified` are never changed on a link. Implicit linking on social sign-in now returns the updated user instead of the pre-update user, so the freshly issued session carries current profile data.
Banned user OAuth sign-in redirects to errorCallbackURL
Banned users signing in with an OAuth provider now redirect to the `errorCallbackURL` passed to `signIn.social`, with `?error=BANNED_USER&error_description=<message>` in the query string. The `oauth-proxy`, SSO OIDC, and SAML callbacks also redirect hook rejections to the error URL instead of returning JSON 403.
OAuth callback error codes and redirect helper consolidated
`parseState` now forwards precise `StateError` codes (`state_not_found`, `state_invalid`, `state_mismatch`, with `state_security_mismatch` reported as `state_mismatch`), and unexpected failures map to `internal_server_error`. The `please_restart_the_process` error code is removed. All callback error redirects use one `redirectOnError` helper for query separator, parameter name, and URL encoding consistency.
errorCallbackURL honored on OAuth state validation failure
A social sign-in that passed `errorCallbackURL` now redirects there when the callback's state check fails, instead of the default error page. This enables native flows (such as Expo) that need their own error route. Recovery applies when state was parsed before the failure; failures where nothing could be parsed fall back to the default.
oauth-proxy forwards handleOAuthUserInfo result.error to query
The `oauth-proxy` callback now forwards `result.error` from `handleOAuthUserInfo` as the `?error=` query value (e.g. `?error=signup_disabled`) instead of collapsing every error into generic `?error=user_creation_failed`. The `user_creation_failed` value is still used as a fallback when `result.data` is missing.
OAuth proxy state-cookie re-encryption with proxy key
With the cookie state strategy, the `oauth_state` cookie (encrypted with the local environment secret) is now re-encrypted with the proxy key before being handed to production. This fixes OAuth proxy flows that failed with `state_mismatch` when production and preview use different `BETTER_AUTH_SECRET` values.
OAuth authorization-code race condition fixed
A race condition in the OAuth authorization-code grant where two concurrent token-exchange requests sharing the same `code` could both mint independent token sets was fixed. The `authorization_code` handler in `@better-auth/oauth-provider` and legacy `oidc-provider`/`mcp` plugins now consume the verification row atomically. The first caller mints tokens; concurrent racers receive `invalid_grant` (RFC 6749 §5.2).
Implicit OAuth linking requires verified email by default
Implicit OAuth linking now requires `dbUser.user.emailVerified === true` in addition to the provider trust check by default. A new `account.accountLinking.requireLocalEmailVerified` option (default `true`) gates implicit linking. Apps can opt into legacy behavior with `account: { accountLinking: { requireLocalEmailVerified: false } }` but should understand the takeover risk. The option is deprecated; future versions will make the gate unconditional.
OIDC-provider and MCP authenticate confidential clients on refresh
The legacy `oidc-provider` and `mcp` plugins now require the registered `client_secret` from confidential clients on refresh_token grants, matching the `authorization_code` path. Public clients continue to skip secret validation. Secret comparisons now use constant-time equality. The `/mcp/token` endpoint no longer emits a wildcard CORS `Access-Control-Allow-Origin: *` header.
OIDC-provider and MCP drop none signing algorithm
The legacy `oidc-provider` and `mcp` plugins now follow OAuth 2.1 (RFC 9700): `id_token_signing_alg_values_supported` and `resource_signing_alg_values_supported` no longer include `"none"`, preventing relying parties from being steered toward unsigned tokens.
OIDC-provider and MCP default allowPlainCodeChallengeMethod to false
The legacy `oidc-provider` and `mcp` plugins now default `allowPlainCodeChallengeMethod` to `false` per OAuth 2.1. A request providing `code_challenge` without `code_challenge_method` is now rejected with `invalid_request`. Callers who explicitly set `allowPlainCodeChallengeMethod: true` retain the legacy "missing method defaults to plain" behavior for backward compatibility.
mapProfileToUser fallback for providers without 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 be unblocked by synthesizing an email inside `mapProfileToUser`. Provider profile types now reflect where `email` can be `null` or absent. Sign-in still rejects with `error=email_not_found` when neither the provider nor `mapProfileToUser` produces an email.
Multiple Client IDs supported for ID token verification
Providers that verify ID tokens by audience (Google, Apple, Microsoft Entra, Facebook, Cognito) now accept an array of Client IDs.
Example configuration:
```ts
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. The first entry is used for the authorization code flow; all entries are accepted when verifying an ID token's `aud` claim.
getPrimaryClientId exported from @better-auth/core/oauth2
`getPrimaryClientId` is exported from `@better-auth/core/oauth2` for provider authors. It returns the primary Client ID (the raw string, or the array entry at index 0), paired with `clientSecret` for the authorization code flow. Providers now reject empty arrays, empty strings, and missing config at sign-in time.
parseState guards against undefined c.body
`parseState` now guards against `c.body` being undefined. Callback requests that arrive as GET leave `c.body` unset in some runtimes, which previously caused `c.body.state` to throw. The state lookup now short-circuits on the query parameter and falls back to `c.body?.state` safely.
RFC 8252 compliance in oauth-provider redirect URI matching
`@better-auth/oauth-provider` redirect URI matching (RFC 8252 §7.3) now accepts the full `127.0.0.0/8` range plus `[::1]`, with port-flexible comparison limited to IP literals. DNS names like `localhost` continue to use exact-string matching per §8.3.
validateIssuerUrl uses shared loopback check
`validateIssuerUrl` uses the shared loopback check from `@better-auth/core/utils/host` rather than a two-hostname literal comparison.
OAuth callbacks reject when provider user info omits account id
OAuth callbacks now reject when provider user info omits the account id to avoid linking accounts under the literal `undefined` id.
OAuth cross-provider account collision prevention
The link-social callback was fixed to prevent cross-provider account collision. Previously, `findAccount(accountId)` matched by account ID across all providers, causing spurious errors when two providers return the same numeric ID (e.g. both Google and GitHub assign `99999`). Replaced with `findAccountByProviderId(accountId, providerId)` to scope the lookup to the correct provider, matching the pattern already used in the generic OAuth plugin.
OAuth state parameter CSRF protection
OAuth state parameter is now verified against cookie-stored nonce to prevent CSRF on cookie-backed flows.
SAML AuthnRequest RelayState signature handling
RelayState is now passed to samlify's ServiceProvider constructor so it is included in the redirect binding signature, per SAML 2.0 Bindings §3.4.4.1. Previously it was appended after the signature, causing spec-compliant IdPs to reject signed AuthnRequests. `authnRequestsSigned: true` without a private key now throws instead of silently sending unsigned requests.
OAuth form_post response mode parameter handling
OAuth proxy callback parameters are now read from request body when using form_post response mode.