# Upgrading to Better Auth 1.7 (/docs/guides/1-7-upgrade-guide)

Upgrade Better Auth from 1.6 to 1.7, including Expo, OAuth, OpenID Connect, MCP, SAML, SCIM, proxy, and custom adapter changes.



Most Better Auth 1.7 changes are additive. Most projects start with one command:

<CodeBlockTabs defaultValue="npm" groupId="persist-install">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npx auth upgrade
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm dlx auth upgrade
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn dlx auth upgrade
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun x auth upgrade
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Upgrade `better-auth` and every `@better-auth/*` package together so the CLI and the library stay on 1.7.

Some areas need more care: OAuth, OpenID Connect, SAML, SCIM, two-factor authentication, MCP, custom storage, and proxy setups. Use this table to choose the sections that apply to your project.

| If your project                                                        | Read                                                                            |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Uses Better Auth at all                                                | [Before you upgrade](#before-you-upgrade) and [Behind a proxy](#behind-a-proxy) |
| Uses email and password, social login, generic OAuth, One Tap, or SSO  | [As a login client](#as-a-login-client)                                         |
| Uses Expo or React Native                                              | [Expo and React Native](#expo-and-react-native)                                 |
| Runs its own OAuth or OpenID provider                                  | [As an identity provider](#as-an-identity-provider)                             |
| Runs MCP                                                               | [MCP](#mcp)                                                                     |
| Uses SAML or SSO domain verification                                   | [Enterprise SSO](#enterprise-sso)                                               |
| Uses SCIM                                                              | [SCIM](#scim)                                                                   |
| Uses Stripe billing                                                    | [Stripe](#stripe)                                                               |
| Uses two-factor auth, magic links, or email OTP                        | [Two-factor and passwordless security](#two-factor-and-passwordless-security)   |
| Uses the Device Authorization plugin                                   | [Device Authorization](#device-authorization)                                   |
| Uses a custom database adapter, secondary storage, or rate-limit store | [Custom adapters and storage](#custom-adapters-and-storage)                     |

## Before you upgrade [#before-you-upgrade]

The upgrade command handles package updates. The database needs a separate migration because some 1.7 features change tables or require a data decision. Run the `auth` CLI with Node.js 22.12 or newer, using the same Better Auth version that you will deploy.

### Migrate from 1.6 to 1.7 [#migrate-from-16-to-17]

The supported path for a populated 1.6 database keeps existing accounts linked to the same users by preserving provider-scoped account identity. Prepare the v1.7 configuration with `provider-id` explicitly selected:

```ts title="auth.ts"
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  account: { identityStrategy: "provider-id" },
  // Your existing configuration
});
```

Run the read-only plan before applying a generated schema or changing the database:

```bash title="Terminal"
npx auth migrate plan
```

`migrate plan` reports `ready`, `blocked`, or `up-to-date`. Continue to the rehearsal and cutover when the plan is ready. A blocked plan leaves the database unchanged and gives the configuration or data correction required before you continue. Follow only the linked sections for the blockers it reports; do not run `apply` until the plan is ready.

<Callout type="warn">
  Never apply the 1.7 account schema directly over a populated 1.6 `account` table. A plain schema migration cannot preserve account identity or resolve collisions because the 1.6 rows have no `issuer` value.
</Callout>

Before the production cutover, restore a recent database backup into an isolated environment and rehearse this workflow. Run `auth migrate apply` on the copy, verify the sign-in and provisioning paths your application uses, then run `plan` again and confirm it reports `up-to-date`. This rehearsal confirms that the backup is restorable and that the migration choices, application configuration, and database work together.

During the production cutover, stop every v1.6 process that can write authentication data, including application instances, background jobs, and provisioning workers that handle credentials, OAuth, SSO, or SCIM. Do not run v1.6 and v1.7 writers against the same database at the same time. Take a final restorable backup, retain it for the rollback window, and apply the ready plan:

```bash title="Terminal"
npx auth migrate apply
```

`migrate apply` reruns the same preflight and refuses to change the database unless the plan remains ready. After it succeeds, deploy the same v1.7 packages and configuration to every instance, run the cutover smoke tests, and then reopen application writes.

Keep the final backup and any renamed v1.6 tables until the rollback window closes. If you must roll back before reopening application writes, stop every v1.7 process, restore the complete backup, and redeploy v1.6 together. Do not restore selected authentication tables or reactivate legacy OAuth token tables individually, because a partial restore can combine incompatible schemas and token revocation states. After reopening application writes, restoring the backup would discard new accounts, sessions, and provisioning changes; use a forward fix or a separately reviewed data reconciliation instead.

#### Resolve additional migration work [#resolve-additional-migration-work]

Most applications do not need every migration branch below. Continue only when `migrate plan` reports one of these changes or when your database adapter requires the matching workflow.

| Feature                    | What changes                                                                                                      | Additional preparation                                     |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Protected resources        | New resource tables and key columns                                                                               | None                                                       |
| Resource-bound tokens      | Resource columns on the token tables                                                                              | None                                                       |
| DPoP                       | A token-binding column                                                                                            | None                                                       |
| Refresh-token reuse window | A cached replay-response column on refresh tokens                                                                 | None                                                       |
| Authorization-code replay  | An indexed `authorizationCodeId` column on both token tables                                                      | None                                                       |
| Back-channel logout        | Logout-URL and revoked columns                                                                                    | None                                                       |
| Requested user-info claims | A requested-claims column on the token and consent tables                                                         | None                                                       |
| Account identity           | Required `issuer` and `accountId`, with a unique compound index across them                                       | Populated 1.6 account tables                               |
| SCIM                       | Seven provisioning models, plus three optional managed-catalog models, that replace the legacy SCIM models        | Retire, then reprovision                                   |
| Organization team counters | `team.memberCount` and `teamMember.membershipKey` columns                                                         | None                                                       |
| Provider client store      | `oauthApplication` becomes `oauthClient`, plus new token tables                                                   | Guided or manual client move                               |
| Device Authorization       | Unique indexes on `deviceCode` and `userCode`; the optional OAuth grant also adds `oauthClientId` and `resources` | Deduplicate; MySQL and SQL Server also need bounded values |

When `apply` asks about release decisions that it cannot make, it saves the reviewed answers to `better-auth-migration.json`. The file identifies the `1.6-to-1.7` transition, so you can inspect and replay the same choices in another environment:

```bash title="Terminal"
npx auth migrate plan better-auth-migration.json
npx auth migrate apply better-auth-migration.json --yes
```

With Drizzle or Prisma, run `npx auth generate` first and point the adapter at the generated 1.7 schema, but do not apply that schema to the database: the CLI migrates the 1.6 data and schema together. The plan resolves the physical table and column names from the configured Drizzle schema or Prisma model metadata, including snake\_case names and Prisma `@map` declarations. For a Drizzle SQLite or PostgreSQL migration, set the adapter's `transaction` option to `true` so the release migration can run atomically. MySQL databases with populated legacy SCIM accounts also require a transaction-capable migration connection so account retirement can roll back independently of MySQL's non-transactional schema changes. Record the cutover in your ORM migration history afterwards, so a later deployment does not apply the same changes twice.

An adapter with no SQL migration connection needs the manual path instead:

1. Complete the manual preparation in [Account identity](#choose-account-identity-strategy), [OAuth client records](#migrate-oauth-client-records), [SCIM](#scim-requires-full-reprovisioning), and [Device Authorization](#device-authorization) when those sections apply.
2. Apply the 1.7 schema with `npx auth generate` and your own migration tooling.
3. Deploy the 1.7 packages and configuration changes together.
4. Complete any post-deployment work, including SCIM reprovisioning.

***

## As a login client [#as-a-login-client]

This covers email and password, social login, the generic OAuth plugin, One Tap, and consuming SSO.

### Choose account identity strategy [#choose-account-identity-strategy]

providerId identifies the configured connection; accountId is the provider subject; issuer stores the identity namespace—verified authority under issuer strategy, deterministic provider namespace under provider-id strategy.

Every generated 1.7 schema has the same physical identity fields: required `issuer`, required `accountId`, and a unique compound index on `(issuer, accountId)`. `account.identityStrategy` selects what the `issuer` value means. Newly generated configurations explicitly select `"provider-id"`. Omitting the option is a deprecated v1.7 compatibility mode that continues using issuer identity and warns once per auth instance. Explicit `"issuer"` and explicit `"provider-id"` are both valid and warning-free.

#### Populated 1.6: preserve provider-scoped account identity [#populated-16-preserve-provider-scoped-account-identity]

`"provider-id"` keeps the provider-scoped behavior configured in the migration path above. `auth migrate plan` inventories duplicate `(providerId, accountId)` identities and plans the required issuer backfill and `(issuer, accountId)` index. `auth migrate apply` stores deterministic namespaces such as `local:oauth:google` for external connections, preserves local namespaces such as `local:credential`, makes `issuer` required, and creates the compound index. Returning sign-ins continue to recognize the same logical `(providerId, accountId)` identity.

The synthetic namespace is storage identity, not protocol trust. OAuth, OpenID Connect, One Tap, OAuth Proxy, OIDC SSO, and SAML SSO still verify the provider's trusted issuer or equivalent protocol authority before storing the provider namespace. Keep this option identical on every instance that reads or writes the database. Renaming a provider changes its account namespace, and 2 provider aliases for the same authority remain separate identities, matching the provider-scoped behavior of 1.6.

The plan blocks on duplicate `(providerId, accountId)` rows because they project to the same synthetic issuer identity. Resolve those collisions before applying.

#### Populated 1.6: issuer identity requires a separate re-key [#populated-16-issuer-identity-requires-a-separate-re-key]

Do not set `account.identityStrategy` to `"issuer"` and expect the guided 1.6 migration to infer authorities:

```ts title="auth.ts"
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  account: {
    identityStrategy: "issuer",
  },
  // ...
});
```

`auth migrate plan` hard-fails this choice. A 1.6 row records the configured connection but does not reliably record the verified protocol authority needed to adopt issuer identity. The change can also merge provider aliases that share an authority. Use provider-id for the supported 1.6 upgrade, or prepare a separately reviewed re-key migration that establishes every trusted authority and resolves collisions before the 1.7 constraint is enforced.

#### Already migrated v1.7: keep issuer identity [#already-migrated-v17-keep-issuer-identity]

Set `account.identityStrategy` explicitly to `"issuer"` to keep the database unchanged without a warning. If the option is omitted, `auth migrate plan` still detects the complete issuer data and index and reports no account identity schema or data actions, while runtime uses v1.7 issuer compatibility mode and warns once per auth instance. In both cases, `auth migrate apply` is a no-op for account identity.

Use this one-line configuration to make the existing behavior explicit:

```ts title="auth.ts"
account: { identityStrategy: "issuer" }
```

#### Already migrated provider-id data: keep provider identity [#already-migrated-provider-id-data-keep-provider-identity]

Set `account.identityStrategy` explicitly to `"provider-id"`. The plan detects deterministic namespaces such as `local:oauth:google`, and apply is a no-op. Omitting the strategy or selecting `"issuer"` is blocked and instructs you to restore `account: { identityStrategy: "provider-id" }`.

<Callout type="warn">
  Changing `account.identityStrategy` on populated v1.7 data is a re-key migration, not a configuration-only toggle. Setting `"provider-id"` against an issuer-keyed database is blocked; keep `"issuer"` until a separate reviewed migration rewrites every namespace and resolves collisions. Runtime behavior never selects a strategy by introspecting the database.
</Callout>

<Callout type="warn">
  Use a maintenance window and stop authentication writes, including background jobs and admin APIs that insert into `account` directly, before changing the account schema. The generated migration cannot choose trusted issuers or resolve identity collisions for you.
</Callout>

<Callout type="warn">
  If you already ran `auth migrate` against MySQL before this backfill, check for corruption first. Adding a required column with no default fails safely on SQLite, Postgres, and SQL Server, but MySQL's default `sql_mode` silently accepts it and backfills every existing row's `issuer` with an empty string instead of raising an error.

  ```sql title="MySQL corruption check"
  SELECT COUNT(*) FROM account WHERE issuer = '';
  ```

  A nonzero count means the database needs repair, not just backfill: drop the compound unique index before re-backfilling, since it was built over the corrupted empty-string values and throws duplicate-key errors as rows resolve to the same real issuer. MySQL has no `DROP INDEX IF EXISTS` for tables, and the index may not exist yet if index creation never ran, so check first.

  ```sql title="Drop the compound index if it exists (MySQL)"
  SHOW INDEX FROM account WHERE Key_name = 'account_issuer_accountId_uidx';
  -- Run the DROP INDEX below only if the query above returned a row.
  DROP INDEX account_issuer_accountId_uidx ON account;
  ```

  Recreate it once every row has a correct value, as part of step 5 below.

  ```sql title="Recreate the compound index (MySQL)"
  CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
  ```
</Callout>

**Backfill the account identity:**

1. Back up the `account` and `user` tables, then inventory every distinct `providerId`. Include custom physical field names from `account.fields` in that inventory.
2. Add `issuer` as nullable during the backfill. Keep the existing physical `accountId` column.
3. Populate both fields according to the selected strategy and account type:

   | Account type                          | `issuer` strategy                         | `provider-id` strategy             | `accountId`                            |
   | ------------------------------------- | ----------------------------------------- | ---------------------------------- | -------------------------------------- |
   | Email/password credential             | `local:credential`                        | `local:credential`                 | Stable `id` from the linked `user` row |
   | SIWE wallet, `providerId` `siwe`      | `local:siwe`                              | `local:siwe`                       | Existing `<address>:<chainId>` value   |
   | Google One Tap, `providerId` `google` | `https://accounts.google.com`             | `local:oauth:google`               | Existing Google `sub`                  |
   | Provider with an issuer               | Exact trusted issuer used by the provider | `local:oauth:<encoded providerId>` | Existing provider account identifier   |
   | OAuth provider without an issuer      | `local:oauth:<encoded providerId>`        | `local:oauth:<encoded providerId>` | Existing provider account identifier   |

   The synthetic issuer percent-encodes its provider ID segment exactly as `encodeURIComponent(providerId)`; for example, `local:oauth:github` and `local:oauth:team%2Fgithub`. Build an explicit `providerId`-to-namespace map for the selected strategy. Under the `"issuer"` strategy, multiple provider configurations that represent the same OpenID Connect authority must use the same issuer. Under the `"provider-id"` strategy, every external connection keeps a distinct synthetic namespace. Do not derive an issuer from email, display name, an unverified request value, or a mutable authorization endpoint.
4. Find collisions before creating the unique index. Adapt this query to your physical table and field names:

   ```sql title="Identity collision check"
   SELECT issuer, accountId, COUNT(*) AS accountCount,
          COUNT(DISTINCT userId) AS userCount
   FROM account
   GROUP BY issuer, accountId
   HAVING COUNT(*) > 1;
   ```

   If duplicate rows belong to one user, choose the account record to keep and reconcile its provider configuration, tokens, scopes, and timestamps before deleting the others. If a key belongs to multiple users, stop the migration and establish the owner from trusted provider data. Never merge users by matching email alone.
5. Confirm that every row has both identity fields, then make `issuer` non-nullable and add the unique compound index on `issuer` and `accountId`. A generated schema migration never emits a statement that makes an existing nullable column non-nullable; run this DDL yourself.

   ```sql title="Postgres"
   ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
   CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
   ```

   ```sql title="MySQL"
   ALTER TABLE account MODIFY COLUMN issuer VARCHAR(255) NOT NULL;
   CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
   ```

   ```sql title="SQL Server"
   ALTER TABLE account ALTER COLUMN issuer VARCHAR(255) NOT NULL;
   CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
   ```

   SQLite has no `ALTER COLUMN` that can add a `NOT NULL` constraint, so rebuild the table instead: create a replacement with the constraint, copy every row across, drop the original, rename the replacement, then recreate every index and foreign key the original table had.

   ```sql title="SQLite table rebuild"
   CREATE TABLE account_new (/* same columns as account, with issuer TEXT NOT NULL */);
   INSERT INTO account_new SELECT * FROM account;
   DROP TABLE account;
   ALTER TABLE account_new RENAME TO account;
   CREATE UNIQUE INDEX account_issuer_accountId_uidx ON account (issuer, accountId);
   ```
6. Update custom adapters, database hooks, and generated schemas to include `issuer`. Credential accounts keep the linked user's stable `id` as `accountId`; email remains a mutable sign-in identifier and does not change the account key.

Account-specific APIs now use explicit, strongly typed selectors. Read `id` from `listAccounts`, then pass it as `accountId` to `unlinkAccount`. For `getAccessToken`, `refreshToken`, and `accountInfo`, choose exactly one of these request shapes:

* `{ accountId: account.id, userId? }` selects the local account row.
* `{ useAccountCookie: true, userId? }` selects the account from its signed cookie.

Remove `providerId` from every account selector. The selector's `accountId` value is the local `account.id`, not the provider-side `account.accountId`. A token or provider-profile request that previously omitted the selector to use the account cookie must now send `useAccountCookie: true`; omitting both supported selectors is invalid.

Deploy only after the collision query returns no rows and the required unique index exists. Verify email-and-password sign-in, returning OAuth and SSO sign-in, explicit account linking, unlinking, provider profile retrieval, and token refresh against the migrated data.

Review every custom OAuth provider's account subject. OpenID Connect discovery providers now use verified `sub`; plain OAuth providers use `id`; the previous runtime fallback between those fields is removed. Set `accountSubject` when either default is not the provider's immutable identifier. `getUserInfo().user` now contains only mutable local-user fields; keep the provider identifier in `getUserInfo().data`. `mapProfileToUser` can no longer return `id`. The `accountInfo` response exposes the selected identity as `account.accountId` instead of `user.id`.

#### Migrate Microsoft account identifiers [#migrate-microsoft-account-identifiers]

Microsoft accounts now use the stable directory `oid` claim instead of the pairwise, app-specific `sub` claim. Under issuer-scoped identity, complete this during step 3 of the account identity backfill, before checking for collisions or adding the compound account index. Under the explicit provider-id strategy, update the same account identifiers as a separate data migration before accepting production traffic on 1.7:

1. Inventory rows with `providerId: "microsoft"` for the built-in provider and `providerId: "microsoft-entra-id"` for the Generic OAuth helper.
2. Set each row's namespace according to the selected strategy: use the trusted Microsoft authority for `"issuer"`, or `local:oauth:<encoded providerId>` for `"provider-id"`. Replace the old `sub`-based `accountId` with the verified `oid` for that directory user.
3. When stored Microsoft ID tokens are available, verify each token and copy its `oid` claim. `mapProfileToUser` cannot override this provider-owned account identifier.
4. When stored ID tokens are unavailable, pause Microsoft sign-in and account linking during the cutover and obtain the mapping from a trusted Microsoft Entra export. Better Auth cannot derive `oid` from the old account row alone; accepting traffic before the migration can create duplicate accounts.

If you use the Generic OAuth `microsoftEntraId` helper with custom scopes or `getToken`, ensure the token exchange still returns Microsoft's `id_token` and discovery provides the issuer and JWKS metadata needed to verify it. The helper also requires a concrete tenant GUID. Replace `common`, `organizations`, or `consumers` configurations with the built-in Microsoft social provider, which validates tenant claims and derives the token's actual issuer.

### Generic OAuth is rebuilt on the social-provider path [#generic-oauth-is-rebuilt-on-the-social-provider-path]

The generic OAuth plugin now works like the built-in social providers.

**What to do:**

* Replace `signIn.oauth2({ providerId })` with `signIn.social({ provider })`.
* Replace `oauth2.link()` with `linkSocial()`.
* Update your provider callback URL from `/api/auth/oauth2/callback/:id` to `/api/auth/callback/:id`.
* Remove `genericOAuthClient()` from your client plugins and use the standard social client APIs.
* PKCE now defaults to on. Set `pkce: false` only for a provider that rejects it.
* Remove `issuer` and `requireIssuerValidation`; issuer validation is now automatic.
* `authorizationUrlParams` and `tokenUrlParams` now accept only plain string maps.

### Identity tokens go through one verifier [#identity-tokens-go-through-one-verifier]

Each provider used to verify its own identity tokens. Now there is one verifier, and each provider declares an `idToken` config: keys, issuer, and audience.

**What to do:** a custom provider should replace its `verifyIdToken` method with an `idToken` config. PayPal no longer accepts identity-token login; it uses its access token instead. Switch PayPal identity-token login to the redirect flow. The built-in provider options are unchanged.

### Electron requires modern PKCE [#electron-requires-modern-pkce]

The Electron login flow now requires S256 PKCE, no longer trusts a custom origin header, and matches custom URL schemes more safely.

**What to do:** upgrade the `@better-auth/electron` client and server together. Make sure your app URL scheme is in `trustedOrigins`. Remove the old `disableOriginOverride` option. Review trusted entries with a host, such as `myapp://callback`, because they no longer match lookalike hosts.

### `generateState()` signature changed [#generatestate-signature-changed]

The public `generateState()` helper now takes an options object instead of positional arguments.

**What to do:** if you call `generateState()` directly, replace `generateState(c, link, additionalData)` with the options form `generateState(c, options)`.

### OAuth callback error code renamed [#oauth-callback-error-code-renamed]

The OAuth callback redirect error value `email_doesn't_match` is renamed `email_does_not_match`.

**What to do:** if you read this error code from the callback redirect, update the string to `email_does_not_match`.

### Google One Tap requires a client ID [#google-one-tap-requires-a-client-id]

Set `clientId` on `oneTap()` or configure the Google social provider with a client ID. One Tap now binds accounts to the verified Google subject instead of matching by email.

### Scopes are kept across logins [#scopes-are-kept-across-logins]

Granted scopes used to overwrite each other. A permission granted earlier could disappear after a later login that asked for less access.

Better Auth now keeps the scopes already on the account across re-login and token refresh, using the existing `account.scope` field. This is a behavior fix: no schema change, no backfill, and no action for most projects.

### SIWE derives identity from the signed message [#siwe-derives-identity-from-the-signed-message]

Remove wallet address and chain fields from `authClient.siwe.nonce()` and `authClient.siwe.getNonce()` calls. If you configure a server-side `getNonce`, return an ERC-4361 nonce containing 8 to 250 alphanumeric characters. Better Auth reads the address and chain from the signed SIWE message.

### Discovery providers verify their identity tokens [#discovery-providers-verify-their-identity-tokens]

A generic OAuth provider configured with a discovery URL now verifies the provider identity token. A token that fails verification is rejected.

**What to do:** if a discovery provider returned a token that could not be verified and your app trusted it anyway, the login is now rejected. Confirm the provider's published keys, issuer, and audience.

### Signed-assertion login validates at startup [#signed-assertion-login-validates-at-startup]

A signed-assertion login setup, also called private-key JWT, is now checked when it is created. An unsupported algorithm, a key with no material, or a mismatch between the declared algorithm and the key now fails immediately instead of silently doing the wrong thing.

**What to do:** fix any signing setup whose declared algorithm disagrees with the key. Replace `createAuthorizationCodeRequest`, `createRefreshAccessTokenRequest`, and `createClientCredentialsTokenRequest` with the async `authorizationCodeRequest`, `refreshAccessTokenRequest`, and `clientCredentialsTokenRequest`.

### Anonymous account linking works in mobile and in-app browsers [#anonymous-account-linking-works-in-mobile-and-in-app-browsers]

Linking an anonymous account after a social login now works in Expo and other in-app browsers, where the callback returns without the usual cookie. A new `addOAuthServerContext` API carries trusted data across the login that a client cannot forge.

**What to do:** nothing for most apps. If you carried anonymous-link state across the OAuth redirect yourself, move it onto `addOAuthServerContext`.

***

## Expo and React Native [#expo-and-react-native]

### Secure storage access is asynchronous [#secure-storage-access-is-asynchronous]

The Expo client now uses the asynchronous SecureStore APIs for cookie and session-cache access. `authClient.getCookie()` returns a promise, so await it directly and make callbacks that read cookies asynchronous.

```ts
const cookie = await authClient.getCookie();
```

Passing `expo-secure-store` directly continues to work without a wrapper. A custom storage implementation must provide `getItem`, `getItemAsync`, `setItem`, and `setItemAsync`. The exported `storageAdapter.setItem()` method is synchronous; use `setItemAsync()` when the caller must wait for persistence.

***

## As an identity provider [#as-an-identity-provider]

This covers `@better-auth/oauth-provider`.

### Migrate OAuth client records [#migrate-oauth-client-records]

`auth migrate apply` moves these records for the adapters it supports, asking how the 1.6 client secrets are stored and whether stored consents move or users grant them again. Follow the steps below when you use another adapter.

If you use the 1.6 in-core `oidcProvider` or MCP plugin, copy or re-register each `oauthApplication` as an `oauthClient`. Map `redirectUrls` to `redirectUris`, convert `metadata` to JSON, and set the client's grant types and token-endpoint authentication method. Expire the old access tokens, then drop or rename the legacy `oauthAccessToken` table before creating the 1.7 token tables.

If you already use `@better-auth/oauth-provider`, migrate existing `oauthClient` rows as follows:

1. Add nullable `applicationType`, `clientDiscoveryId`, and `clientCredentialsScopes` columns. Leave `clientDiscoveryId` null unless you have trusted discovery provenance for the client.
2. Map existing `web` and `native` client types to `applicationType`. Review `user-agent-based` clients individually. Set `tokenEndpointAuthMethod` to `none` only for public clients; every other method is confidential.
3. Set `clientCredentialsScopes` to an empty array, then assign approved machine scopes to each client that uses the `client_credentials` grant. Remove `clientCredentialGrantDefaultScopes` from the provider configuration.
4. Remove duplicate `oauthClientResource` rows for the same `clientId` and `resourceId` before adding the compound unique index.
5. Drop the removed `type` and `public` columns after the backfill.

Replace bare JWK arrays in client configuration and registration payloads with JWK Set objects: `jwks: [key]` becomes `jwks: { keys: [key] }`. Remove the `oauthProvider.silenceWarnings` option.

### Protected resources replace the audience list [#protected-resources-replace-the-audience-list]

Audiences are now resources. Each resource can have its own token lifetime, scopes, claims, and signing keys. The old `validAudiences` list is removed.

**What to do:**

* Move each entry from `validAudiences` into `resources`.
* Link clients to specific resources through `oauthClientResource` or registration.
* Check your refresh-token lifetimes: the shortest applicable lifetime now wins, so a per-resource value longer than the provider default is capped at the default.

**If you accept dynamically registered clients,** the resource model enforces per-client resource access by default. A dynamic client's token request can be rejected with `invalid_target`. Set `enforcePerClientResources: false` for that case, and register each client's `grant_types` explicitly, or the token endpoint rejects them with `unauthorized_client`.

### Registration resources and scopes use provider policy [#registration-resources-and-scopes-use-provider-policy]

Configure `clientRegistrationDefaultResources` and `clientRegistrationAllowedResources` before enabling Dynamic Client Registration. Existing clients that send the `resources` parameter must request only identifiers allowed by those options. Review `clientRegistrationDefaultScopes` and `clientRegistrationAllowedScopes` as the capabilities a client may request, not as user consent.

### Token target is locked to the login [#token-target-is-locked-to-the-login]

The API a token is for is now captured at login and locked to that grant. A later request can narrow the target API but cannot widen it. Asking for an API the login did not cover is rejected. A custom-claims callback now receives a list of resources instead of one value.

**What to do:** update custom-claims callbacks to read the resource list. Make sure clients ask only for resources their login covered.

### DPoP renames the token verifier [#dpop-renames-the-token-verifier]

The plain token-checking helper `verifyAccessToken` is renamed `verifyBearerToken` and now rejects DPoP tokens. Use the new `verifyAccessTokenRequest` on endpoints that may receive DPoP requests.

**What to do:** rename `verifyAccessToken` to `verifyBearerToken`, and switch DPoP-capable endpoints to `verifyAccessTokenRequest`. To support DPoP, configure database-backed verification storage.

**Watch the proxy case.** Native DPoP checks the proof's `htu` claim against the URL the token endpoint computes for itself. Behind a TLS-terminating proxy or a custom server, that computed URL can be the internal bind address (`http://0.0.0.0:3000`) or the proxy's internal scheme and port. The client signed `htu` from your public discovery URL, so a valid proof can be rejected. Canonicalize the incoming request's scheme and host to your configured `baseURL` at the route boundary before the provider reads it.

### Protected-resource scope options are renamed [#protected-resource-scope-options-are-renamed]

Rename protected-operation `scopes` to `requiredScopes` and use `challengeScopes` for the `WWW-Authenticate` hint. If application code creates scope failures directly, use `createInsufficientScopeError` and pass recognized token or scope failures to `createResourceServerChallenge`.

### Sign-out revokes session tokens [#sign-out-revokes-session-tokens]

When a session ends, the access tokens tied to it are now revoked. They read as inactive at introspection and userinfo. Before, they lived until they expired. Your server also sends a logout message to each app that registered a logout URL.

**What to do:** expect session-bound tokens to stop working at sign-out. On serverless platforms, set `advanced.backgroundTasks.handler` so sending logout messages does not slow down sign-out.

If you import or retain clients with a `backchannel_logout_uri`, audit those registrations before cutover. Back-channel logout requires the JWT plugin, and every URI must be an absolute public HTTPS URL without credentials or a fragment. Private, reserved, tunneled, and cloud-metadata targets are rejected.

### RP-Initiated Logout can require browser confirmation [#rp-initiated-logout-can-require-browser-confirmation]

In 1.6, `/oauth2/end-session` accepted only `GET` requests and rejected requests without an `id_token_hint`. In 1.7, the endpoint also accepts form-encoded `POST` requests. For browser navigation without a valid hint, Better Auth asks the user to confirm before ending the current session. The same browser confirmation is required when the hint refers to a different session than the browser session. API calls receive a protocol error when confirmation is required.

The confirmation flow preserves the existing redirect rule: `post_logout_redirect_uri` must exactly match a registered URI. Better Auth adds `state` only to that verified redirect.

**What to do:** if your browser flow expected a missing or invalid hint to fail immediately, update its flow and tests to handle the confirmation page. Standard clients that send a valid hint do not need changes. Enable `enable_end_session` only for trusted clients, and register every allowed post-logout redirect URI exactly.

### Custom ID-token claims cannot override protocol claims [#custom-id-token-claims-cannot-override-protocol-claims]

Your custom ID-token claims can no longer set protocol claims that the standard reserves for the server: issuer, subject, audience, expiry, nonce, session binding, `auth_time`, `acr`, `amr`, and `azp`. Your own namespaced claims still appear. ID tokens also report `acr: "0"` rather than a vendor-specific value.

**What to do:** if a `customIdTokenClaims` callback, an extension claim contributor, or a per-issuance `idTokenClaims` set one of those reserved claims, that value is now ignored. Move the data into a namespaced claim of your own, or rely on the server's value.

### ID tokens drop profile and email scope claims [#id-tokens-drop-profile-and-email-scope-claims]

ID tokens issued through the authorization-code flow no longer carry the profile and email scope claims. Those claims are available from the UserInfo endpoint.

**What to do:** read profile and email claims from UserInfo instead of the ID token.

### `jwt.sign` callbacks must match the configured alg [#jwtsign-callbacks-must-match-the-configured-alg]

A custom `jwt.sign` callback is rejected when its algorithm differs from `keyPairConfig.alg` during ID-token issuance.

**What to do:** align your custom signing algorithm with `keyPairConfig.alg`.

### `/oauth2/revoke` rejects valid JWT access tokens [#oauth2revoke-rejects-valid-jwt-access-tokens]

Revoking a still-valid JWT access token now returns `400 unsupported_token_type`.

**What to do:** revoke refresh tokens or opaque access tokens; do not call revoke on JWT access tokens.

### `max_age` is enforced [#max_age-is-enforced]

When a client asks for `max_age` and the user's login is older than that, the provider sends them back to log in. Before, the request was ignored.

**What to do:** nothing to configure. If a client sent `max_age` expecting it to be ignored, expect a re-login prompt now.

**Watch your date columns.** The `max_age` check reads a session's creation time back as a date. If your custom schema stores session timestamps as text instead of a real date or integer-timestamp type, the check can misread the value and send users into a login loop. Store `user`, `session`, `account`, and `verification` timestamps with a date or timestamp type. The Better Auth CLI generates the correct type.

### Client creation returns 201 [#client-creation-returns-201]

Creating a client now returns `201 Created` instead of `200 OK`, and the registration endpoint enforces the same permission checks as the manual create endpoints.

**What to do:** update any client that expects a `200` from client creation to accept `201`. To allow machine clients to register, configure `validateInitialAccessToken`.

### Unauthenticated registration keeps the client's auth method [#unauthenticated-registration-keeps-the-clients-auth-method]

Dynamic Client Registration without a logged-in user no longer forces the client to be public. A client that omits `token_endpoint_auth_method` is now confidential with the RFC 7591 default `client_secret_basic` and a generated secret; it becomes public only when it registers `token_endpoint_auth_method: "none"`.

**What to do:** if you relied on unauthenticated registrations being downgraded to public, register `token_endpoint_auth_method: "none"` explicitly for clients that must stay public.

### Token requests use the registered client authentication method [#token-requests-use-the-registered-client-authentication-method]

The OAuth Provider now rejects confidential-client credentials sent through a method different from the client's `token_endpoint_auth_method`. Failed body authentication returns `400 invalid_client`; failed Basic authentication returns `401 invalid_client` with a Basic challenge.

**What to do:** make each client's token requests match its registered method. Better Auth's generic OAuth request helpers use body authentication by default when given a client secret, so pass `authentication: "basic"` or `tokenEndpointAuth: { method: "client_secret_basic" }` when calling a client registered with the RFC 7591 default. Register `client_secret_post` explicitly when body authentication is required.

### Registration requires reciprocal response and grant types [#registration-requires-reciprocal-response-and-grant-types]

A registered client's `response_types` and `grant_types` must now be reciprocal: a `code` response type requires the `authorization_code` grant, and a token grant requires its matching response type. Mismatched registrations are rejected.

**What to do:** register matching `response_types` and `grant_types` for each client.

### OAuth endpoints return standard error envelopes [#oauth-endpoints-return-standard-error-envelopes]

Validation and malformed-request failures on the OAuth endpoints (token, authorize, revoke, introspect, register, end-session) now return RFC 6749 `{ error, error_description }` envelopes instead of the previous generic validation-error shape.

**What to do:** if a client or tool parsed the old error shape, update it to read `error` and `error_description`.

### Introspection returns consistent claims [#introspection-returns-consistent-claims]

`/oauth2/introspect` now returns the same claims for an opaque token as it does for a JWT, and a resource server can introspect a token issued to a different client.

**What to do:** nothing. Expect richer, consistent introspection responses for opaque tokens.

### UserInfo accepts a bearer token in the form body [#userinfo-accepts-a-bearer-token-in-the-form-body]

The userinfo endpoint now accepts the access token in a form-encoded body and rejects a request that sends the token in both the header and the body.

**What to do:** send the access token in one place, the `Authorization` header or the form body, not both.

### Client authentication is tied to the grant [#client-authentication-is-tied-to-the-grant]

A custom client-authentication method registered through the extension surface can now only prove which client is calling. The server decides what that client is allowed to do.

**What to do:** companion plugins that added a client-authentication method should rely on the server-resolved client rather than returning their own client decision.

### Server-side OAuth requests refuse redirects [#server-side-oauth-requests-refuse-redirects]

Better Auth now refuses HTTP redirects on the server-side OAuth requests it makes: token exchange, token refresh, client credentials, token introspection, and JWKS requests. Conformant OAuth providers answer these endpoints directly and do not redirect.

**What to do:** nothing for standard providers. If a custom provider endpoint redirects, make it return the final response directly.

### PKCE requirements for confidential and OIDC clients [#pkce-requirements-for-confidential-and-oidc-clients]

PKCE is always required for public clients. A confidential client registered through Dynamic Client Registration can opt out with `clientRegistrationRequirePKCE: false`. A request that carries the `offline_access` scope still requires PKCE unless it is an OIDC request with a `nonce`, which a confidential client can use instead.

**What to do:** set `clientRegistrationRequirePKCE: false` only for confidential clients that cannot use PKCE. Send a `nonce` if a confidential OIDC client needs `offline_access` without PKCE.

### Authorize accepts form-encoded requests and rejects request objects [#authorize-accepts-form-encoded-requests-and-rejects-request-objects]

The authorization and userinfo endpoints now accept form-encoded (POST) requests, and the authorization endpoint explicitly rejects the OIDC `request` and `request_uri` parameters it does not support.

**What to do:** nothing for standard clients.

### Refresh-token retries can be tolerated [#refresh-token-retries-can-be-tolerated]

The OAuth provider can replay the same refresh response for duplicate refresh requests during `refreshTokenReuseInterval`. Strict refresh-token replay handling remains the default.

**What to do:** set `refreshTokenReuseInterval` only when a client can retry a refresh request with an old token after another local session already rotated it. OAuth Provider keeps strict replay handling at `0`; `mcp()` defaults the interval to 30 seconds for every client.

### If you extended the OAuth provider by hand [#if-you-extended-the-oauth-provider-by-hand]

If you added custom grants, claims, or client-authentication methods by patching or forking the OAuth provider, use the supported extension surface instead of re-applying a patch. Register your contributions with `extendOAuthProvider(ctx, ...)` from your plugin's `init(ctx)` hook. Mint tokens with `provider.issueTokens(...)`, authenticate a client with `provider.authenticateClient(...)`, and hash a token with `provider.hashToken(...)`. Bind a token's audience by passing `resources` to `issueTokens`; the server owns the audience.

A contribution written in an older or hand-rolled shape can fail silently here. It may type-check and run while the grant or claim never reaches a token. After moving each one into `init()`, confirm the grant or claim reaches a real token.

### The old `oidcProvider` plugin is removed [#the-old-oidcprovider-plugin-is-removed]

**What to do:** replace `oidcProvider` from `better-auth/plugins` with `oauthProvider` from `@better-auth/oauth-provider` and move your configuration across.

***

## MCP [#mcp]

### MCP moves to its own package [#mcp-moves-to-its-own-package]

The MCP plugin moves from `better-auth` to `@better-auth/mcp` and uses `@better-auth/oauth-provider`.

**What to do:**

1. Install `@better-auth/mcp`, `@better-auth/cimd`, and the official version 2 MCP client or server package your application needs. Import MCP authorization and protected-request helpers from `@better-auth/mcp`. Replace the removed in-core client and adapters with `@modelcontextprotocol/client` or `@modelcontextprotocol/server`.
2. Add the required `jwt()` plugin. Compose `mcp()` with `cimd({ fetchClientMetadataResource, metadataProfile: "mcp-2026-07-28" })` for client registration.
3. Move options from `oidcConfig` to the top level of `mcp({ ... })`. Set one canonical HTTPS `resource`, such as `https://api.example.com/mcp`, and replace `resourceMetadataMappings` with that value.
4. Rename `withMcpAuth` to `requireMcpAuth` and `mcpHandler` to `createMcpProtectedRequestHandler`. Apply the [protected-resource scope option changes](#protected-resource-scope-options-are-renamed).
5. Use the version 2 `createMcpHandler` with `legacy: "reject"`, wrap it with `requireMcpAuth`, and expose only `POST`. Remove MCP-route `GET` and `DELETE` exports and session-store options such as `redisUrl`.
6. Move registered clients through the [OAuth client records](#migrate-oauth-client-records) migration.

OAuth endpoints move from `/mcp/*` to `/oauth2/*`. Discovery-based clients find the new endpoints automatically. The MCP refresh-token reuse interval now defaults to 30 seconds for every client; set `refreshTokenReuseInterval: 0` on `mcp()` to require strict replay handling.

`mcp()` no longer enables unauthenticated Dynamic Client Registration. If you deliberately support it, set both `allowDynamicClientRegistration` and `allowUnauthenticatedClientRegistration`. Otherwise, use the CIMD configuration above.

***

## Enterprise SSO [#enterprise-sso]

### SSO account subjects are protocol-defined [#sso-account-subjects-are-protocol-defined]

OIDC SSO now uses the verified `sub` claim as the account subject, and SAML uses the signed `NameID`. Profile mappings can still select email, name, image, and additional fields, but `oidcConfig.mapping.id` and `samlConfig.mapping.id` are removed. Manual SAML configurations that do not provide `idpMetadata.metadata` must set `idpMetadata.entityID`; `samlConfig.issuer` identifies the service provider and no longer acts as an IdP fallback.

**What to do:** remove `id` from every OIDC and SAML mapping, then confirm that each OIDC provider returns a stable `sub` and each SAML provider returns a stable, signed `NameID`. During the account-identity backfill, keep that protocol-defined subject and store either the exact OIDC issuer or SAML IdP entity ID under the `"issuer"` strategy, or the deterministic provider namespace under `"provider-id"`. Provider aliases with the same authority and subject deduplicate one external identity only under issuer strategy; this change does not introduce independent grant or provider lifecycle records for those aliases.

If a previous mapping used `mapping.id`, prepare its replacement before the maintenance window. Build a trusted mapping from every old account subject to the protocol-defined identity, then rewrite those account rows before adding the compound index. Under issuer strategy, use the exact OIDC issuer and verified `sub`, or the actual SAML IdP metadata entity ID and signed `NameID`. Under provider-id strategy, keep the verified subject or signed `NameID` but store the deterministic provider namespace. Obtain the subject mapping from the identity provider, not email or a mutable profile attribute. Do not deploy a runtime fallback for the old mapping.

### IdP-initiated SAML is off by default [#idp-initiated-saml-is-off-by-default]

Unsolicited logins started by the identity provider are now disabled by default. A login response is validated for `InResponseTo`, so it cannot be replayed against a request it was not issued for, and Single Logout requests are matched to their session by `SessionIndex`.

**What to do:** set `saml.allowIdpInitiated: true` to restore the old behavior if you depend on it.

### SAML certificates can be a list [#saml-certificates-can-be-a-list]

Signing certificates now accept a single value or a list, which lets you rotate certificates without downtime. The management endpoints return the certificate as a list, or omit it when the certificates live inside an `idpMetadata` document.

**What to do:** update any code that reads the certificate from those endpoints to expect a list or its absence. Make sure every SAML config supplies a signing-cert source, an explicit certificate or an `idpMetadata` document, or registration fails.

### SAML configuration is simplified [#saml-configuration-is-simplified]

The callback URL is derived automatically, service-provider metadata is generated for you, and several fields are removed. One endpoint path changes, and error codes in the redirect change from short aliases to the full lowercased internal code (for example `saml_multiple_assertions`).

**What to do:** remove the empty `spMetadata` and the removed fields from your config. Register the ACS URL with your IdP as your base URL plus `/sso/saml2/sp/acs/:providerId`; with the default base path that is `https://yourapp.com/api/auth/sso/saml2/sp/acs/:providerId`. For SP-initiated logins, set the post-login redirect with `callbackURL` in `signIn.sso()`. The `callbackUrl` config field is no longer the ACS URL and is now optional, but it remains the post-login redirect for IdP-initiated logins. Keep it if you enable `allowIdpInitiated` and need a specific landing page. If you read SAML error codes from the redirect URL, switch to the lowercased codes.

### SAML signature enforcement matches its configuration [#saml-signature-enforcement-matches-its-configuration]

In 1.6, `wantAssertionsSigned` enforced a signature on the SAML response message instead of the assertion. 1.7 verifies the assertion element itself and applies the configured requirement to it, so sign-ins from an IdP that signs only the response message fail when the service provider requires signed assertions. A callback that carries RelayState validates it unconditionally, and provider registration rejects service-provider metadata that weakens a signed-assertion policy, exceeds the metadata size limit, or declares an ACS location containing a URL fragment.

**What to do:** confirm each IdP signs assertions, or set `wantAssertionsSigned: false` deliberately for an IdP that cannot. Fix or re-register stored SP metadata that no longer validates. If a custom integration supplies its own RelayState on SAML callbacks, make sure it round-trips the value Better Auth issued.

### `/sso/update-provider` rejects partial mappings [#ssoupdate-provider-rejects-partial-mappings]

Updating an SSO provider now rejects a partial OIDC or SAML mapping object.

**What to do:** send a complete mapping object when you call `/sso/update-provider`.

### OIDC SSO works on Cloudflare Workers [#oidc-sso-works-on-cloudflare-workers]

OIDC SSO with discovery now works on Cloudflare Workers. A discovery or token endpoint that redirects is rejected with a clear configuration error instead of failing in a runtime-specific way.

**What to do:** nothing. If a provider endpoint redirects, point your config at the final URL.

***

## SCIM [#scim]

### SCIM supports three connection modes [#scim-supports-three-connection-modes]

Every SCIM request resolves an immutable connection ID, credential identity, scopes, and provisioning domain. The plugin no longer depends on the Organization or SSO plugins, and applications can choose one of three connection modes:

* **Static code-defined:** declare connections and bearer credentials in `scim({ connections })`.
* **Application-owned runtime:** verify the bearer token and return its connection atomically from `authentication.verifyBearerToken`.
* **Plugin-managed runtime:** configure `managedConnections`, then call its trusted server APIs from an application-authorized administrator workflow.

The legacy runtime connection-management endpoints, SCIM client plugin, CLI scaffolding, `defaultSCIM`, `staticProviders`, `trustedDomains`, `providerOwnership`, and organization-scoped provider configuration are removed. Provisioned identities no longer create authentication accounts. Use `identity` callbacks to link existing Better Auth Users and apply lifecycle state, and use `projection` callbacks to map Groups into your application's roles.

**What to do:** choose one supported connection mode, assign a stable `provisioningDomainId` to the application boundary that receives lifecycle and access changes, and configure a separate sign-in method for provisioned Users. The following example uses the static mode:

```ts title="auth.ts"
scim({
  connections: [
    {
      id: "workforce-acme",
      provisioningDomainId: "workspace-acme",
      credentials: [
        { type: "bearer", id: "workforce-primary", token: workforceToken },
      ],
    },
  ],
});
```

### SCIM requires full reprovisioning [#scim-requires-full-reprovisioning]

The new SCIM models do not read or convert the 1.6 SCIM tables, so provisioned state cannot carry over. `auth migrate apply` retires the legacy providers and their accounts once you confirm the exact inventory, but every directory user must still be provisioned again afterwards.

<Callout type="warn">
  Use a maintenance window. Pause provisioning and stop every application instance that runs the old SCIM plugin before changing the schema.
</Callout>

**What to do:**

1. Back up every legacy SCIM table. Build a reviewed inventory of the exact `scimProvider` rows, SCIM-created `account` rows, their linked `user` rows, and any organization membership or team state created by provisioning. Use the configured providers and directory subjects to identify them. Do not classify rows by a `providerId` prefix alone.
2. Decide how each legacy User and account row will be handled. To retain a User, copy a stable connection-and-subject-to-`userId` mapping into application-owned storage, remove only the confirmed legacy SCIM account row, and configure `identity.resolveUser` before reprovisioning. To recreate a User, remove the confirmed SCIM account and delete the User only after proving that no other sign-in method or application data depends on it. Preserve every unrelated account and application row; Better Auth does not identify or remove legacy SCIM account rows automatically.
3. Choose the static, application-owned runtime, or plugin-managed connection mode, and prepare new opaque connection and credential identifiers. Never import a legacy token hash, reuse a legacy raw token, or add a compatibility path that accepts the legacy bearer syntax. Static and application-owned modes use a new high-entropy secret; the managed mode issues its new secret in step 6, after step 5 creates the managed tables.
4. While the old plugin is stopped, clear its SCIM-owned resources in dependency order through the old SCIM endpoints or an operator-controlled transaction. Confirm that the legacy plugin tables are empty and that every inventoried SCIM account row has a reviewed disposition. Drop or rename the incompatible legacy physical tables, including `scimProvider`, only after the reviewed backup and cleanup are complete. Use the physical table names from your schema if you customized model names.
5. Enable native interactive transactions in your database adapter, then apply the 1.7 schema through the [adapter-specific workflow](#before-you-upgrade) to create `scimConnectionBinding`, `scimIdentityTombstone`, `scimSubject`, `scimUser`, `scimGroup`, `scimGroupMember`, and `scimProjectionGrant`. The managed mode also creates `scimManagedConnection`, `scimManagedCredential`, and `scimManagedConnectionEvent`. Cloudflare D1 cannot provide the required transaction behavior.
6. Deploy the selected mode. For the managed mode, create the connection and issue its first credential through the trusted server API after the new schema exists. Configure the directory with the new credential, then trigger a complete User and Group provisioning cycle.
7. Verify User linking, Group state, lifecycle, and role projection in your application, then resume provisioning.

<Callout type="warn">
  The new plugin never links by email. Retained legacy Users will cause `409 Conflict` during reprovisioning unless `identity.resolveUser` returns an explicit link decision for them.
</Callout>

See the [SCIM plugin documentation](/docs/plugins/scim) for the final configuration and supported protocol behavior.

***

## Stripe [#stripe]

### Organization subscriptions require `organization.enabled` [#organization-subscriptions-require-organizationenabled]

`referenceMiddleware` now rejects organization-scoped subscriptions unless `organization: { enabled: true }` is set in the Stripe plugin config.

**What to do:** set `organization: { enabled: true }` in your `stripe()` plugin options for organization-scoped subscriptions. The organization plugin is still needed separately to resolve the active organization.

### `onSubscriptionCancel` event is required [#onsubscriptioncancel-event-is-required]

The `event` parameter on the `onSubscriptionCancel` callback is now required.

**What to do:** update your `onSubscriptionCancel` callback to expect a non-optional `event`.

***

## Behind a proxy [#behind-a-proxy]

### Dynamic base URLs do not trust forwarded headers by default [#dynamic-base-urls-do-not-trust-forwarded-headers-by-default]

When `baseURL` uses `allowedHosts`, Better Auth ignores forwarded headers unless you opt in.

**What to do:** if your proxy exposes the public hostname only through `x-forwarded-host`, opt in:

```ts
betterAuth({
  baseURL: { allowedHosts: [...] },
  advanced: {
    trustedProxyHeaders: true, // [!code highlight]
  },
});
```

Setups where the proxy rewrites the host for you, such as nginx, Vercel, Cloudflare, and Netlify, need no change.

Without `baseURL.fallback`, dynamic base URLs fail closed when a request has no usable host or its host does not match `allowedHosts`. Direct server API calls must therefore include request URL or header data that resolves to an allowed public origin, or configure a trusted fallback. Better Auth rebuilds the base URL, trusted origins, provider URLs, and cookies for the resolved origin on each request; OAuth issuer, discovery, protected-resource, and JWKS URLs follow the same request origin.

### IdP redirects and DPoP need your canonical origin [#idp-redirects-and-dpop-need-your-canonical-origin]

Two OAuth-provider behaviors read the incoming request origin. Behind a custom server or TLS-terminating proxy, that origin can be the internal bind address rather than your public origin. The provider returns your `consentPage` and `loginPage` as relative paths, so a server-side redirect, such as `NextResponse.redirect`, needs them resolved against an absolute origin. Native DPoP also compares the proof's `htu` against the URL the token endpoint computes for itself.

**What to do:** treat `baseURL` as the server identity, and canonicalize the incoming request scheme and host to it at the route boundary before the provider reads the request. One helper, applied wherever a route forwards to the provider, fixes consent redirects, the DPoP `htu` check, and origin checks together.

***

## Custom adapters and storage [#custom-adapters-and-storage]

The atomic-state work introduces required methods. If you use only the built-in adapters and storage, you can skip this.

### Database adapters must implement `incrementOne` and `consumeOne` [#database-adapters-must-implement-incrementone-and-consumeone]

`incrementOne` updates one row's counter atomically and returns the row, or null when the guard did not match. `consumeOne` reads and deletes a row in one step for single-use credentials. Both are now required, and the old fallback is gone.

**What to do:** implement both `incrementOne` and `consumeOne` in any custom adapter. A missing `consumeOne` throws at runtime. All built-in adapters already do.

### Secondary storage must implement `increment` and `getAndDelete` [#secondary-storage-must-implement-increment-and-getanddelete]

`increment(key, ttl)` bumps a counter by one and sets the expiry only when the key is first created. `getAndDelete(key)` reads and removes a key in one step. Both were optional before and are now required.

**What to do:** implement both `increment` and `getAndDelete` in custom secondary storage. Redis storage already does.

### Rate-limit storage uses `consume` [#rate-limit-storage-uses-consume]

Rate-limit storage now needs a single `consume(key, rule)` method that checks and increments in one step. Separate `get` and `set` are no longer accepted.

**What to do:** replace `get` and `set` in custom rate-limit storage with `consume`.

### Database joins moved out of `experimental` [#database-joins-moved-out-of-experimental]

Replace `experimental: { joins: true }` with `advanced: { database: { joins: true } }`. Regenerate Drizzle or Prisma relations if you enable joins.

### Drizzle relation keys are singular with `usePlural` [#drizzle-relation-keys-are-singular-with-useplural]

When `usePlural: true`, the Drizzle schema generator now uses singular keys for many-to-one relations. Regenerate the schema and update code that reads the previous plural relation keys. The default `usePlural: false` configuration is unchanged.

### `getIp` is renamed `getIP` [#getip-is-renamed-getip]

The public `getIp` export is renamed `getIP`.

**What to do:** update imports of the IP helper to `getIP`.

***

## Captcha [#captcha]

### Captcha matches full paths [#captcha-matches-full-paths]

Captcha rules now match full request paths or explicit wildcards, which closes a way to skip a captcha rule through partial path matching.

**What to do:** replace a partial path like `/sign-in` with `/sign-in/*` or `/sign-in/**`.

A wildcard such as `/sign-in/*` also matches `/sign-in/email-otp`. If that route should remain exempt, list the exact protected endpoints instead of using a wildcard.

***

## Device Authorization [#device-authorization]

### Device codes use unique indexes and bounded values [#device-codes-use-unique-indexes-and-bounded-values]

The stable 1.6 schema does not enforce uniqueness for these lookup values. The 1.7 schema creates unique indexes on `deviceCode` and `userCode`, so resolve duplicate values in both columns on every adapter before applying the migration. MySQL and SQL Server installations must also convert both columns to bounded strings and clean up values longer than 191 characters. Custom `generateDeviceCode` and `generateUserCode` functions must stay within the 191-character limit.

### OAuth device grants are opt-in [#oauth-device-grants-are-opt-in]

In 1.6, `deviceAuthorization()` signed a device into the same Better Auth application and returned a Better Auth session token from `/device/token`. That standalone flow remains available in 1.7 and does not accept RFC 8707 resource indicators or add OAuth fields to the `deviceCode` table.

To let a registered CLI, TV app, or other limited-input client obtain OAuth tokens, add the OAuth Device Authorization integration alongside OAuth Provider:

```ts title="auth.ts"
import {
  oauthDeviceAuthorization,
  oauthProvider,
} from "@better-auth/oauth-provider";
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    jwt(),
    oauthProvider({
      loginPage: "/sign-in",
      consentPage: "/consent",
      scopes: ["openid", "profile", "offline_access", "api:read"],
      resources: ["https://api.example.com"],
    }),
    oauthDeviceAuthorization(),
  ],
});
```

This integration adds nullable `oauthClientId` and `resources` fields to `deviceCode`, validates the OAuth client, scopes, and resources when the code is created, advertises the device authorization endpoint in discovery, and exchanges approved codes at `/oauth2/token`. Regenerate and apply the schema when you enable it. Existing 1.6 rows need no backfill and remain on the session-token path, even if their `clientId` later matches a registered OAuth client. Allow pending 1.6 clients to finish polling `/device/token` or let their codes expire before moving those clients to the OAuth flow.

The client type only exposes the RFC 8707 `resource` request field when the server uses the OAuth grant:

```ts title="auth-client.ts"
import { oauthDeviceAuthorizationClient } from "@better-auth/oauth-provider/client";
import { createAuthClient } from "better-auth/client";

export const authClient = createAuthClient({
  plugins: [oauthDeviceAuthorizationClient()],
});
```

See [Authorize a CLI to call an API](/docs/plugins/device-authorization#authorize-a-cli-to-call-an-api) for client registration, approval, and polling examples.

***

## Two-factor and passwordless security [#two-factor-and-passwordless-security]

### Two-factor `enableTwoFactor` returns a discriminated response [#two-factor-enabletwofactor-returns-a-discriminated-response]

`enableTwoFactor` now accepts a `method` of `"otp"` or `"totp"` (default `"totp"`) and returns that method in the response. `totpURI` and backup codes are present only for `"totp"`. The `skipVerificationOnEnable` option still works.

**What to do:** update callers that read `totpURI` or backup codes to branch on the returned `method`.

### Magic-link and email-OTP sign-in can clear unproven credentials [#magic-link-and-email-otp-sign-in-can-clear-unproven-credentials]

Magic-link and email-OTP sign-in now treat proven mailbox control as the source of truth for an account whose email had never been confirmed. If that account had an unproven password or other linked accounts, Better Auth removes all of them and revokes existing sessions before signing the user in.

**What to do:** if a user signed up with email and password but first signs in through a magic link or email OTP instead of confirming the verification email, ask them to set a new password through password reset.

***

## Behavior changes worth noticing [#behavior-changes-worth-noticing]

These do not need action for most setups, but they change what you see.

* **Safer OAuth token handling:** a refresh token used by a different client, a replayed authorization code, and a `redirect_uri` that does not match the one used at login are now rejected with the correct standard error. A replayed code also revokes the tokens it already issued. Well-behaved clients are unaffected.
* **No caching of credentials:** token, introspection, userinfo, registration, and device-authorization responses now send `Cache-Control: no-store` so proxies and browsers do not cache them.
* **userinfo rejects bad tokens:** an invalid access token at the userinfo endpoint returns `401 invalid_token` with a `WWW-Authenticate` header.
* **OAuth authorize error redirect:** a missing `response_type` now redirects the error to the verified client `redirect_uri` instead of a generic error.
* **Drizzle affected-row validation:** the Drizzle adapter throws on an invalid affected-row count instead of returning `0`.
* **`organization.updateTeam` immutable fields:** `id`, `createdAt`, and `updatedAt` are no longer accepted in the request body.
* **`updateMemberRole` ordering:** role-existence validation now runs after authorization checks.
* **CLI `generate --output` to a directory:** picks an adapter-specific default filename.
* **Generated schema and disabled migrations:** references to migration-disabled models are omitted.
* **Cookie-cache session binding:** the cached session is now tied to the `session_token` cookie.
* **SSRF host checks:** outbound-host classification now blocks additional reserved ranges (6to4 relay anycast, site-local IPv6, and IPv4-compatible IPv6).
* **Standard token-redemption errors:** authorization-code redemption failures return `400 invalid_grant` instead of `401 invalid_client` or `invalid_request`.
* **Sign-out hooks with external session stores:** `session.delete` hooks now run on sign-out even with `secondaryStorage` and `preserveSessionInDatabase`.
* **Two-factor invalidation error code:** a failed two-factor challenge cleanup now returns `FAILED_TO_INVALIDATE_TWO_FACTOR_CHALLENGE`.

***

## Verify the upgrade [#verify-the-upgrade]

After deploying 1.7, verify the paths that your application uses:

* Sign in with email and password, each OAuth or SSO provider, and Google One Tap when configured.
* Link, list, refresh, and unlink external accounts.
* Exercise OAuth authorization, refresh, revocation, discovery, and protected-resource checks when you run an identity provider.
* Connect an MCP client and complete one protected request.
* Confirm SAML SP-initiated login and, when enabled, IdP-initiated login.
* Confirm SCIM User linking, Group membership, lifecycle changes, and application projections before resuming provisioning.
* Confirm proxy deployments generate public callback, issuer, discovery, and JWKS URLs.

