> You are reading Better Auth documentation for `v1.8 (Beta)`. This is not the current stable release. APIs may differ from the latest stable version.

# OAuth Proxy (/docs/beta/plugins/oauth-proxy)

OAuth sign-in for development and preview deployments



OAuth Proxy is intended for local development and trusted preview deployments whose callback URLs change. It lets these deployments use an OAuth client with a fixed callback URL registered on your production server.

Your production server handles the provider callback, exchanges the authorization code, and returns encrypted profile data to the development or preview deployment. That deployment creates the user and session in its own database. Install the plugin on both the production callback server and each trusted development or preview deployment that participates in the flow.

For ordinary production sign-ins, use the normal OAuth provider flow. When the current origin matches `productionURL`, the plugin skips proxying the sign-in request. The production server still handles proxy callbacks for participating development and preview deployments.

## Installation [#installation]

<Steps>
  <Step>
    ### Add the plugin to your auth config [#add-the-plugin-to-your-auth-config]

    Add this configuration to the production callback server and each trusted development or preview deployment, using the same `productionURL` and proxy `secret`.

    <Callout type="warn">
      Share the proxy secret only with environments and code you trust to authenticate users in every participating deployment, including production. Anyone holding this secret can assert provider identities through the proxy. Keep it out of preview deployments that run untrusted contributor code. Separate `BETTER_AUTH_SECRET` values protect other encrypted data, but do not remove this shared authentication authority.
    </Callout>

    ```ts title="auth.ts"
    import { betterAuth } from "better-auth"
    import { oAuthProxy } from "better-auth/plugins" // [!code highlight]

    export const auth = betterAuth({
      plugins: [
        oAuthProxy({ // [!code highlight]
          productionURL: "https://my-production-app.com", // [!code highlight]
          secret: process.env.OAUTH_PROXY_SECRET, // [!code highlight]
        }), // [!code highlight]
      ],
      socialProviders: {
        github: {
          clientId: process.env.GITHUB_CLIENT_ID || "",
          clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
        },
      },
    })
    ```

    Set `OAUTH_PROXY_SECRET` to the same value on all trusted participating environments (production, preview, localhost). The plugin derives separate encryption keys for OAuth state transport, proxy packages, and provider profiles from this value.

    The plugin routes OAuth requests from participating development and preview deployments through your production callback server.
  </Step>

  <Step>
    ### Register the callback URL with your OAuth provider [#register-the-callback-url-with-your-oauth-provider]

    In your OAuth provider's developer console (e.g. GitHub, Google), register the callback URL using your **production** domain. For example:

    ```
    https://my-production-app.com/api/auth/callback/github
    ```

    Only the production callback URL needs to be registered. The plugin handles routing OAuth requests from preview and development environments through production automatically.
  </Step>

  <Step>
    ### Add trusted origins [#add-trusted-origins]

    Since preview and development servers redirect through production, you need to add them as `trustedOrigins` in your auth config:

    ```ts title="auth.ts"
    export const auth = betterAuth({
      // ...other config
      trustedOrigins: [ // [!code highlight]
        "http://localhost:3000", // [!code highlight]
        "https://my-app-*-preview.example.com", // [!code highlight]
      ], // [!code highlight]
    })
    ```
  </Step>
</Steps>

<Callout type="warn">
  **Important: Shared Secret Required**

  All trusted participating environments (production, preview, localhost) must use the same proxy secret to communicate. Configure a dedicated `secret` in the plugin options:

  ```ts title="auth.ts"
  oAuthProxy({
    productionURL: "https://my-production-app.com",
    secret: process.env.OAUTH_PROXY_SECRET, // Same value on ALL environments // [!code highlight]
  })
  ```

  If you don't configure a shared `secret`, the plugin falls back to `BETTER_AUTH_SECRET`. Since production and preview typically have different main secrets (which is correct for security), the OAuth flow will fail with a `state_mismatch` error. Even when the same secret material is used for multiple features, OAuth proxy payloads and OAuth state cookies use separate derived keys.
</Callout>

## Upgrading existing deployments [#upgrading-existing-deployments]

The `oAuthProxy` configuration does not change. Keep the existing `productionURL` and shared `secret`. This upgrade does not require rotating the secret; any separate rotation must still keep every participating environment on the same value.

Upgrade production and every preview or development deployment that participates in the same OAuth proxy flow to the same Better Auth version in one coordinated cutover. Mixed versions cannot exchange proxy state packages or provider profiles. Sign-in and account-linking flows started before the cutover must be restarted, whether they use database-backed or cookie-backed OAuth state. When using cookie-backed state, upgrade every node that serves the same auth base together because the state cookie encryption key also changes. The plugin does not fall back to the previous shared encryption key.

## How it works [#how-it-works]

The plugin allows you to use a single OAuth client (registered with your production URL) across multiple environments like preview deployments or local development.

1. Preview server initiates OAuth, redirecting to the OAuth provider with production's redirect URI
2. OAuth provider callbacks to production server
3. Production server exchanges the code for tokens and fetches user info
4. Production server encrypts the profile data and redirects to preview server (**no database write on production**)
5. Preview server decrypts the profile, creates user/session in its own database, and sets the session cookie

```ts
import { authClient } from "@/lib/auth-client"

await authClient.signIn.social({
    provider: "github",
    callbackURL: "/dashboard"
})
```

The encrypted profile data is passed via URL query parameters and can only be decrypted by servers sharing the same secret. This also allows preview deployments to use separate databases from production if needed.

## Options [#options]

**productionURL**: The URL of the production server that handles provider callbacks for development and preview sign-ins. Sign-in requests are not proxied when the current origin matches this URL's origin. Defaults to the `BETTER_AUTH_URL` environment variable, then the auth `baseURL`.

**currentURL**: The application's current URL is automatically determined by the plugin. It first checks the request URL, then vendor-specific environment variables from popular hosting providers, and finally falls back to the `baseURL` in your auth config. You only need to set this if the URL isn't being inferred correctly in your environment.

**maxAge**: Maximum age in seconds for encrypted profile payloads. Payloads older than this will be rejected to prevent replay attacks. Keep this value short (e.g., 30-60 seconds) to minimize the window for potential replay attacks while still allowing normal OAuth flows. Defaults to `60` seconds.

**secret**: A dedicated secret used to derive keys for the OAuth proxy flow. When set, this is used **instead of** the global `BETTER_AUTH_SECRET`, so a leaked proxy secret cannot decrypt data protected only by the main secret. Holders of the proxy secret can assert provider identities to participating deployments and obtain sessions when those identities are accepted. Share it only with trusted environments and code. All environments participating in the proxy flow must share the same `secret` value.

## Troubleshooting [#troubleshooting]

### `state_mismatch` or "State not persisted correctly" error [#state_mismatch-or-state-not-persisted-correctly-error]

This error typically occurs when production and preview environments have **different secrets** and no shared `secret` is configured in the plugin options.

**What happens:**

1. Preview encrypts the OAuth state with its secret
2. OAuth provider redirects to production
3. Production tries to decrypt with a different secret → **fails**
4. The regular OAuth callback runs and fails because the state cookie doesn't exist on production

**Solution:** Configure a shared `secret` in the `oAuthProxy` options on **all** environments:

```ts title="auth.ts"
oAuthProxy({
  productionURL: "https://my-production-app.com",
  secret: process.env.OAUTH_PROXY_SECRET, // [!code highlight]
})
```

Make sure `OAUTH_PROXY_SECRET` has the same value on production and each trusted preview or localhost deployment participating in the flow. Keep it out of deployments running untrusted contributor code.

<Callout type="info">
  Using a dedicated proxy secret (instead of sharing `BETTER_AUTH_SECRET`) keeps data protected only by the main secret separate. It does not prevent a holder of the proxy secret from asserting provider identities through the proxy. Treat the proxy secret as an authentication credential for every participating deployment.
</Callout>

### OAuth works on production but fails on preview/localhost [#oauth-works-on-production-but-fails-on-previewlocalhost]

Ensure all of the following:

1. **Shared secret** is configured (see above)
2. **Trusted origins** include your preview/localhost URLs
3. **Production callback URL** is registered with your OAuth provider (e.g., `https://production.com/api/auth/callback/github`)

