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
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.
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.
import { betterAuth } from "better-auth"
import { oAuthProxy } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
oAuthProxy({
productionURL: "https://my-production-app.com",
secret: process.env.OAUTH_PROXY_SECRET,
}),
],
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.
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/githubOnly the production callback URL needs to be registered. The plugin handles routing OAuth requests from preview and development environments through production automatically.
Add trusted origins
Since preview and development servers redirect through production, you need to add them as trustedOrigins in your auth config:
export const auth = betterAuth({
// ...other config
trustedOrigins: [
"http://localhost:3000",
"https://my-app-*-preview.example.com",
],
})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:
oAuthProxy({
productionURL: "https://my-production-app.com",
secret: process.env.OAUTH_PROXY_SECRET, // Same value on ALL environments
})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.
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
The plugin allows you to use a single OAuth client (registered with your production URL) across multiple environments like preview deployments or local development.
- Preview server initiates OAuth, redirecting to the OAuth provider with production's redirect URI
- OAuth provider callbacks to production server
- Production server exchanges the code for tokens and fetches user info
- Production server encrypts the profile data and redirects to preview server (no database write on production)
- Preview server decrypts the profile, creates user/session in its own database, and sets the session cookie
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
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
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:
- Preview encrypts the OAuth state with its secret
- OAuth provider redirects to production
- Production tries to decrypt with a different secret → fails
- 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:
oAuthProxy({
productionURL: "https://my-production-app.com",
secret: process.env.OAUTH_PROXY_SECRET,
})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.
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.
OAuth works on production but fails on preview/localhost
Ensure all of the following:
- Shared secret is configured (see above)
- Trusted origins include your preview/localhost URLs
- Production callback URL is registered with your OAuth provider (e.g.,
https://production.com/api/auth/callback/github)