fix(oauth-proxy): missing state-cookie skip for oauth-proxy (#9385)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Maxwell
2026-05-30 01:06:34 +10:00
committed by GitHub
parent c92cd74162
commit 17cd433c66
6 changed files with 543 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"better-auth": patch
---
Fix OAuth proxy flows that failed with `state_mismatch` when production and preview use different `BETTER_AUTH_SECRET` values.
Two issues are addressed. The proxy callback's state cleanup now skips the state-cookie check, which could not be satisfied at the bounced cross-origin hop and left the verification record uncleaned. And with the cookie state strategy, the `oauth_state` cookie (encrypted with the local environment secret) is now re-encrypted with the proxy key before it is handed to production, mirroring the database strategy; previously a dedicated proxy `secret` that differed from `BETTER_AUTH_SECRET` broke cookie-strategy proxy flows because production could not decrypt the inner state.

View File

@@ -19,6 +19,7 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a
plugins: [
oAuthProxy({ // [!code highlight]
productionURL: "https://my-production-app.com", // [!code highlight]
secret: process.env.OAUTH_PROXY_SECRET, // [!code highlight]
}), // [!code highlight]
],
socialProviders: {
@@ -30,6 +31,8 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a
})
```
Set `OAUTH_PROXY_SECRET` to the same value on all environments (production, preview, localhost).
The plugin will automatically route OAuth requests through your production server.
</Step>
@@ -62,8 +65,19 @@ A proxy plugin that allows you to proxy OAuth requests. Useful for development a
</Step>
</Steps>
<Callout type="info">
All environments participating in the OAuth proxy flow must share the same encryption key. By default, the plugin uses `BETTER_AUTH_SECRET`. To avoid sharing your main secret across environments, you can provide a dedicated `secret` option (see [Options](#options)).
<Callout type="warn">
**Important: Shared Secret Required**
All environments (production, preview, localhost) must use the same encryption key 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.
</Callout>
## How it works
@@ -100,3 +114,39 @@ The encrypted profile data is passed via URL query parameters and can only be de
**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 for encrypting and decrypting data during the OAuth proxy flow. When set, this is used **instead of** the global `BETTER_AUTH_SECRET`, limiting the blast radius if the key is shared across environments — a leaked proxy secret cannot forge sessions or decrypt other data protected by the main secret. 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:**
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, preview, and localhost.
<Callout type="info">
Using a dedicated proxy secret (instead of sharing `BETTER_AUTH_SECRET`) is recommended for security. If the proxy secret is compromised, attackers cannot forge sessions or access other encrypted data — they can only potentially hijack OAuth flows during the short `maxAge` window.
</Callout>
### OAuth works on production but fails on preview/localhost
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`)

View File

@@ -142,6 +142,7 @@ CSRF protection. This error means the cookie is missing or its value does not ma
* Cross-origin POST callbacks (common with SAML IdPs) do not send `SameSite=Lax` cookies.
* Preview vs. production domain mismatch.
* The user opened multiple sign-in tabs - each overwrites the state cookie, so only the last one is valid.
* **OAuth Proxy with mismatched secrets:** When using the [OAuth Proxy plugin](/docs/plugins/oauth-proxy) and production/preview have different `BETTER_AUTH_SECRET` values without a shared `secret` configured in the plugin options, the proxy's before hook fails to decrypt the state package, causing the regular callback handler to run and fail.
### How to fix
@@ -192,6 +193,7 @@ triggers, and what to do about it.
| Likelihood | Root cause | Error code | Fix |
| ------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Very high** | Cookie blocked or missing (Safari ITP, cross-domain, preview domains) | `state_security_mismatch` or `state_mismatch` (cookie strategy) | Use a stable custom domain; verify `SameSite` / `Secure` attributes |
| **Very high** | OAuth Proxy: different secrets on production vs preview | `state_security_mismatch` or `state_mismatch` | Configure a shared `secret` in the [OAuth Proxy plugin](/docs/plugins/oauth-proxy) |
| **High** | Callback URL replayed (refresh, back button, redirect loop) | `state_mismatch` (DB) | Ensure your error/redirect page does not re-trigger the callback |
| **High** | User took too long (>10 min) on the provider page | `state_mismatch` (DB) or request expired | Inform users to retry; consider switching to cookie strategy |
| **High** | Multiple tabs / concurrent sign-in attempts | `state_security_mismatch` | Only the last-opened tab's cookie is valid; earlier tabs will fail |
@@ -200,7 +202,7 @@ triggers, and what to do about it.
| **Medium** | Signed cookie expired (5 min) while DB record is still valid (10 min) | `state_security_mismatch` | The cookie has a shorter TTL than the DB record; users between 510 min will hit this |
| **Low** | Secret rotation mid-flow | `state_invalid` (cookie) or `state_mismatch` (DB + hashed) | Rotate secrets during low-traffic windows |
| **Low** | OAuth provider altered the state parameter | `state_mismatch` (DB) | Verify the provider preserves `state` exactly; check for URL encoding issues |
| **Low** | Missing verification table / migration not run | `state_generation_error` | Run `npx auth migrate` |
| **Low** | Missing verification table / migration not run | `state_generation_error` | Run `npx auth migrate` |
| **Very low** | Clock skew between server instances | request expired | Enable NTP on all servers |
***

View File

@@ -82,6 +82,11 @@ export interface OAuthProxyOptions {
*/
type OAuthProxyStatePackage = {
state: string;
/**
* The OAuth state, encrypted under the proxy key (`getEncryptionKey`), not
* the per-environment `oauth_state` cookie key. Production decrypts it with
* the same proxy key, so both state strategies must produce it that way.
*/
stateCookie: string;
isOAuthProxy: boolean;
};
@@ -234,7 +239,9 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
// Clean up OAuth state
try {
await parseGenericState(ctx, payload.state);
await parseGenericState(ctx, payload.state, {
skipStateCookieCheck: true,
});
} catch (e) {
ctx.context.logger.warn("Failed to clean up OAuth state", e);
}
@@ -347,7 +354,13 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
statePackage =
parseJSON<OAuthProxyStatePackage>(decryptedPackage);
} catch {
// Not an OAuth proxy state, continue normally
// State is either a regular (non-proxy) state, or an encrypted proxy
// package that can't be decrypted (e.g. different secrets on preview vs production).
// If you're using oauth-proxy and seeing state_mismatch errors, ensure all
// environments share the same `secret` in the oAuthProxy plugin options.
ctx.context.logger.debug(
"OAuth proxy: could not decrypt state package, falling back to regular callback",
);
return;
}
@@ -536,42 +549,53 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
return;
}
// Get state value based on storage strategy
let stateCookieValue: string | undefined;
if (ctx.context.oauthConfig.storeStateStrategy === "cookie") {
// Cookie mode - extract from response headers
const headers = ctx.context.responseHeaders;
const setCookieHeader = headers?.get("set-cookie");
if (setCookieHeader) {
const parsedCookies = parseSetCookieHeader(setCookieHeader);
const stateCookie = ctx.context.createAuthCookie("oauth_state");
const stateCookieAttrs = parsedCookies.get(stateCookie.name);
stateCookieValue = stateCookieAttrs?.value;
}
} else {
// Database mode - read from DB
const verification =
await ctx.context.internalAdapter.findVerificationValue(
originalState,
);
if (verification) {
// Encrypt the verification value so it matches cookie mode format
stateCookieValue = await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: verification.value,
});
}
}
if (!stateCookieValue) {
ctx.context.logger.warn("No OAuth state cookie value found");
return;
}
// Recover the plaintext OAuth state for the configured strategy,
// then re-encrypt it under `getEncryptionKey` (the shared/proxy
// secret) so production can read it back; production does not have
// this environment's `BETTER_AUTH_SECRET`. Any failure (malformed
// cookie, decrypt, or encrypt) falls back to a non-proxied flow.
try {
// Create and encrypt state package
let plaintextState: string | undefined;
if (ctx.context.oauthConfig.storeStateStrategy === "cookie") {
// Cookie mode: the `oauth_state` cookie is encrypted with this
// environment's secret, so decrypt it locally to recover the state.
const setCookieHeader =
ctx.context.responseHeaders?.get("set-cookie");
if (setCookieHeader) {
const oauthStateCookie =
ctx.context.createAuthCookie("oauth_state");
const encryptedCookieValue = parseSetCookieHeader(
setCookieHeader,
).get(oauthStateCookie.name)?.value;
if (encryptedCookieValue) {
plaintextState = await symmetricDecrypt({
key: ctx.context.secretConfig,
data: encryptedCookieValue,
});
}
}
} else {
// Database mode: the verification value is already plaintext JSON.
const verification =
await ctx.context.internalAdapter.findVerificationValue(
originalState,
);
plaintextState = verification?.value;
}
if (!plaintextState) {
ctx.context.logger.warn("No OAuth state found for proxy");
return;
}
// Re-encrypt the state under the proxy key, then wrap it in the
// package production reads back with that same key.
const stateCookie = await symmetricEncrypt({
key: getEncryptionKey(ctx),
data: plaintextState,
});
const statePackage: OAuthProxyStatePackage = {
state: originalState,
stateCookie: stateCookieValue,
stateCookie,
isOAuthProxy: true,
};
const encryptedPackage = await symmetricEncrypt({
@@ -589,7 +613,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
};
} catch (e) {
ctx.context.logger.error(
"Failed to encrypt OAuth proxy state package:",
"Failed to prepare OAuth proxy state:",
e,
);
// Continue without proxy

View File

@@ -505,6 +505,58 @@ describe("oauth-proxy", async () => {
expect(payload.timestamp).toBeDefined();
});
/**
* Cookie strategy with a dedicated proxy `secret` that differs from
* `BETTER_AUTH_SECRET`. The `oauth_state` cookie is encrypted with the
* environment secret, so the proxy must re-encrypt it with the proxy key
* for the production callback to recover the inner state. Without that,
* the callback fails to decrypt the state package and produces no
* passthrough profile.
*
* @see https://github.com/better-auth/better-auth/pull/9385
*/
it("recovers cookie-strategy state when the proxy secret differs from the env secret", async () => {
const { client } = await getTestInstance({
secret: "env-secret-not-shared",
account: { storeStateStrategy: "cookie" },
plugins: [
oAuthProxy({
currentURL: "http://preview.example.com",
secret: "shared-proxy-secret",
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
});
const res = await client.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
const state = new URL(res.url!).searchParams.get("state");
let encryptedProfile: string | null = null;
await client.$fetch(`/callback/google?code=test&state=${state}`, {
onError(context) {
const location = context.response.headers.get("location");
if (location?.includes("profile=")) {
encryptedProfile = new URL(location).searchParams.get("profile");
}
},
});
expect(encryptedProfile).toBeTruthy();
});
it("should create user/session on preview from profile data", async () => {
// Production instance - handles OAuth callback
const production = await getTestInstance(
@@ -1159,6 +1211,374 @@ describe("oauth-proxy", async () => {
});
});
/**
*
* Tests that oauth-proxy-callback correctly skips the state cookie check
* when cleaning up OAuth state. In the proxy flow:
* 1. User starts OAuth on preview - state cookie set on preview domain
* 2. OAuth provider redirects to production
* 3. Production redirects to preview's oauth-proxy-callback
* 4. parseGenericState is called to clean up, but the state cookie
* may not be present (cross-origin redirect) or may not match
*
* The fix is to pass `skipStateCookieCheck: true` when calling parseGenericState
* in the oauth-proxy-callback endpoint.
*/
describe("database mode state cleanup", () => {
it("should not fail when state cookie is missing during proxy callback cleanup", async () => {
// This test simulates the scenario where:
// - Both preview and production share the SAME database
// - State storage is "database" (default)
// - The state cookie was set on preview, but when oauth-proxy-callback
// is called, the cookie may not be present
// Use a shared database for both instances
const { client: productionClient, auth: productionAuth } =
await getTestInstance(
{
baseURL: "http://localhost:3000",
plugins: [
oAuthProxy({
currentURL: "http://preview.example.com",
productionURL: "http://localhost:3000",
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
// Step 1: Initiate OAuth flow
const res = await productionClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
const state = new URL(res.url!).searchParams.get("state");
// Step 2: Complete OAuth callback to get encrypted profile
let encryptedProfile: string | null = null;
let callbackURL: string | null = null;
await productionClient.$fetch(
`/callback/google?code=test&state=${state}`,
{
onError(context) {
const location = context.response.headers.get("location");
if (location && location.includes("profile=")) {
const url = new URL(location);
encryptedProfile = url.searchParams.get("profile");
callbackURL = url.searchParams.get("callbackURL");
}
},
},
);
expect(encryptedProfile).toBeTruthy();
expect(callbackURL).toBeTruthy();
// Step 3: Call oauth-proxy-callback WITHOUT cookies (simulating cross-origin)
// This is the key: we explicitly DON'T pass any cookies/headers
// to simulate the state cookie not being present
const _response = await productionClient.$fetch(
`/oauth-proxy-callback?callbackURL=${encodeURIComponent(callbackURL!)}&profile=${encodeURIComponent(encryptedProfile!)}`,
{
onError(context) {
const location = context.response.headers.get("location");
// The callback should succeed and redirect to dashboard
// NOT fail with state_mismatch error
expect(location).not.toContain("error=");
expect(location).toContain("/dashboard");
},
},
);
// Verify user was created successfully
const ctx = await productionAuth.$context;
const users = await ctx.internalAdapter.listUsers();
expect(users.length).toBe(1);
expect(users[0]?.email).toBe("user@email.com");
});
it("should handle state cleanup gracefully when verification is already deleted", async () => {
// This tests that parseGenericState errors are caught and don't break the flow
const { client, auth } = await getTestInstance(
{
plugins: [
oAuthProxy({
currentURL: "http://preview.example.com",
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
const { secret, internalAdapter } = await auth.$context;
// Create a valid payload with a state that doesn't exist in DB
const payload = {
userInfo: {
id: "123",
email: "test@email.com",
name: "Test User",
emailVerified: true,
},
account: {
providerId: "google",
accountId: "123",
accessToken: "test",
},
state: "non-existent-state-id",
callbackURL: "/dashboard",
timestamp: Date.now(),
};
const encryptedProfile = await symmetricEncrypt({
key: secret,
data: JSON.stringify(payload),
});
// The callback should still succeed even if state cleanup fails
await client.$fetch(
`/oauth-proxy-callback?callbackURL=/dashboard&profile=${encodeURIComponent(encryptedProfile)}`,
{
onError(context) {
const location = context.response.headers.get("location");
// Should redirect to dashboard, not error
expect(location).not.toContain("error=state_mismatch");
expect(location).toContain("/dashboard");
},
},
);
// Verify user was created
const users = await internalAdapter.listUsers();
expect(users.length).toBe(1);
});
});
/**
* Tests for secret configuration across environments.
* When production and preview have different BETTER_AUTH_SECRET values,
* a shared `secret` must be configured in the oAuthProxy options.
*/
describe("secret configuration", () => {
/**
* This test verifies that when production and preview have DIFFERENT secrets
* (without a shared oAuthProxy secret configured), the callback fails because
* the before hook can't decrypt the state package.
*
* This is the root cause of the issue where users see:
* "ERROR [Better Auth]: Failed to parse state BetterAuthError: State mismatch: State not persisted correctly"
*/
it("should fail when preview and production have different secrets (no shared secret)", async () => {
// Preview instance with its own secret
const preview = await getTestInstance(
{
baseURL: "http://preview.example.com",
secret: "preview-secret-key-that-is-different",
plugins: [
oAuthProxy({
productionURL: "http://localhost:3000",
// Note: NO shared secret configured - this is the problem
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
// Production instance with a DIFFERENT secret
const production = await getTestInstance(
{
baseURL: "http://localhost:3000",
secret: "production-secret-key-that-is-different",
plugins: [
oAuthProxy({
// Note: NO shared secret configured
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
// Step 1: Start OAuth on preview
const res = await preview.client.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
// The state is encrypted with preview's secret
const encryptedState = new URL(res.url!).searchParams.get("state");
expect(encryptedState).toBeTruthy();
// Step 2: OAuth callback arrives at production
// Production tries to decrypt with its own secret - THIS FAILS
// The before hook catches the error and returns early
// The regular callback handler runs and fails
await production.client.$fetch(
`/callback/google?code=test&state=${encryptedState}`,
{
onError(context) {
const location = context.response.headers.get("location");
// Should fail with state error because regular callback runs
expect(location).toMatch(/error=.*state|please_restart/i);
},
},
);
});
/**
* This test verifies the CORRECT configuration: using a shared secret
* for the oauth-proxy plugin across all environments.
*/
it("should work correctly when a shared secret is configured", async () => {
const sharedProxySecret = "shared-oauth-proxy-secret-for-all-envs";
// Preview instance with shared proxy secret
const preview = await getTestInstance(
{
baseURL: "http://preview.example.com",
secret: "preview-main-secret", // Main secret can be different
plugins: [
oAuthProxy({
productionURL: "http://localhost:3000",
secret: sharedProxySecret, // SHARED secret for proxy
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
// Production instance with the SAME shared proxy secret
const production = await getTestInstance(
{
baseURL: "http://localhost:3000",
secret: "production-main-secret", // Main secret can be different
plugins: [
oAuthProxy({
secret: sharedProxySecret, // SAME shared secret for proxy
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
},
{
disableTestUser: true,
},
);
// Step 1: Start OAuth on preview (the non-production environment)
const res = await preview.client.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
const encryptedState = new URL(res.url!).searchParams.get("state");
expect(encryptedState).toBeTruthy();
// Step 2: OAuth callback arrives at production
// Production can decrypt because it uses the same shared secret
let encryptedProfile: string | null = null;
let callbackURL: string | null = null;
await production.client.$fetch(
`/callback/google?code=test&state=${encryptedState}`,
{
onError(context) {
const location = context.response.headers.get("location");
// Should redirect to preview's oauth-proxy-callback
expect(location).toContain("preview.example.com");
expect(location).toContain("/oauth-proxy-callback");
if (location && location.includes("profile=")) {
const url = new URL(location);
encryptedProfile = url.searchParams.get("profile");
callbackURL = url.searchParams.get("callbackURL");
}
},
},
);
expect(encryptedProfile).toBeTruthy();
// Step 3: Preview receives the callback
// Preview can decrypt the profile because it uses the same shared secret
await preview.client.$fetch(
`/oauth-proxy-callback?callbackURL=${encodeURIComponent(callbackURL!)}&profile=${encodeURIComponent(encryptedProfile!)}`,
{
onError(context) {
const location = context.response.headers.get("location");
// Should successfully redirect to dashboard
expect(location).not.toContain("error=");
expect(location).toContain("/dashboard");
},
},
);
// Verify user was created on preview
const previewCtx = await preview.auth.$context;
const users = await previewCtx.internalAdapter.listUsers();
expect(users.length).toBe(1);
expect(users[0]?.email).toBe("user@email.com");
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/8889
*/

View File

@@ -149,7 +149,7 @@ export async function generateGenericState(
export async function parseGenericState(
c: GenericEndpointContext,
state: string | undefined,
settings?: { cookieName: string; skipStateCookieCheck?: boolean },
settings?: { cookieName?: string; skipStateCookieCheck?: boolean },
) {
if (!state) {
throw new StateError("State not found in OAuth callback", {