OAuth Proxy State Mismatch Issue with Localhost → Production Flow

@Better Auth We're using better-auth with the oAuthProxy plugin to enable OAuth login from localhost through our production deployment, since our OAuth provider (AWS Cognito) only has the production URL whitelisted. Expected Flow 1. User clicks sign-in on localhost:3000 2. OAuth state is created and stored (cookie/database) 3. User redirected to OAuth provider with redirect_uri=https://production.example.com/api/auth/callback/cognito&state=xyz 4. OAuth provider redirects back to https://production.example.com/api/auth/callback/cognito?code=...&state=xyz 5. Production should skip state cookie check (since it was initiated from localhost) 6. Production validates the code and proxies back to localhost with encrypted cookies Actual Behavior Steps 1-4 work correctly, but at step 5: - Production receives the callback with state=<state-value> - Production tries to verify the state cookie, but the cookie is on localhost, not production - Production returns state_mismatch error
4 Replies
michidk
michidkOP18h ago
Configuration:
// Localhost .env.local
AUTH_REDIRECT_PROXY_BASE_URL=https://production.example.com
BETTER_AUTH_SECRET=<same-secret-as-production>

// Production env vars
AUTH_REDIRECT_PROXY_BASE_URL=https://production.example.com
BETTER_AUTH_SECRET=<same-secret-as-localhost>
NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT=production
NEXT_PUBLIC_PRODUCTION_URL=https://production.example.com

// auth config (server.ts) - same code runs on both environments
const serverUrl = getServerUrl(); // localhost:3000 in dev, production.example.com in prod
const productionURL = env.AUTH_REDIRECT_PROXY_BASE_URL || serverUrl;

export const config = {
baseURL: serverUrl,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { /* shared production database */ }),
plugins: [
genericOAuth({
config: [{
providerId: "cognito",
clientId: env.COGNITO_CLIENT_ID,
clientSecret: env.COGNITO_CLIENT_SECRET,
discoveryUrl: "...",
scopes: ["openid", "email", "profile"],
pkce: true,
redirectURI: `${productionURL}/api/auth/callback/cognito`,
}],
}),
oAuthProxy({
productionURL: productionURL, // production.example.com
currentURL: serverUrl,
}),
],
};
// Localhost .env.local
AUTH_REDIRECT_PROXY_BASE_URL=https://production.example.com
BETTER_AUTH_SECRET=<same-secret-as-production>

// Production env vars
AUTH_REDIRECT_PROXY_BASE_URL=https://production.example.com
BETTER_AUTH_SECRET=<same-secret-as-localhost>
NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT=production
NEXT_PUBLIC_PRODUCTION_URL=https://production.example.com

// auth config (server.ts) - same code runs on both environments
const serverUrl = getServerUrl(); // localhost:3000 in dev, production.example.com in prod
const productionURL = env.AUTH_REDIRECT_PROXY_BASE_URL || serverUrl;

export const config = {
baseURL: serverUrl,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { /* shared production database */ }),
plugins: [
genericOAuth({
config: [{
providerId: "cognito",
clientId: env.COGNITO_CLIENT_ID,
clientSecret: env.COGNITO_CLIENT_SECRET,
discoveryUrl: "...",
scopes: ["openid", "email", "profile"],
pkce: true,
redirectURI: `${productionURL}/api/auth/callback/cognito`,
}],
}),
oAuthProxy({
productionURL: productionURL, // production.example.com
currentURL: serverUrl,
}),
],
};
Debug Output: Localhost:
{
"baseURL": "http://localhost:3000",
"productionURL": "https://production.example.com",
"redirectURI": "https://production.example.com/api/auth/callback/cognito"
}
{
"baseURL": "http://localhost:3000",
"productionURL": "https://production.example.com",
"redirectURI": "https://production.example.com/api/auth/callback/cognito"
}
Production:
{
"baseURL": "https://production.example.com",
"productionURL": "https://production.example.com",
"redirectURI": "https://production.example.com/api/auth/callback/cognito"
}
{
"baseURL": "https://production.example.com",
"productionURL": "https://production.example.com",
"redirectURI": "https://production.example.com/api/auth/callback/cognito"
}
Better Auth
Better Auth18h ago
It sounds like you’re running into the scenario where production is still trying to validate a state cookie that was set on localhost, which is exactly what the oAuthProxy plugin is meant to handle by skipping the state cookie check during proxy flows. The intended behavior is that when your production deployment receives the OAuth callback from AWS Cognito, it should bypass the state cookie verification because the initiation happened at localhost [3]. A few things to double-check: 1. Make sure your oAuthProxy configuration includes both the productionURL and currentURL options so the plugin knows that the initial request was on localhost. For example:
import { betterAuth } from "better-auth";
import { oAuthProxy } from "better-auth/plugins";

export const auth = betterAuth({
plugins: [
oAuthProxy({
productionURL: "https://production.example.com",
currentURL: "http://localhost:3000",
}),
],
// ...other configuration options
});
import { betterAuth } from "better-auth";
import { oAuthProxy } from "better-auth/plugins";

export const auth = betterAuth({
plugins: [
oAuthProxy({
productionURL: "https://production.example.com",
currentURL: "http://localhost:3000",
}),
],
// ...other configuration options
});
2. Verify you are using a version of Better Auth that includes the fix to automatically skip the state cookie check for proxied flows [9]. Earlier versions might not handle this correctly. If your configuration and version are correct but the issue persists, it could be worth tagging @bekacru or @ping__ so they can assist further. Are you currently on the version that includes the state check skip fix, or would you like more guidance on ensuring your configuration is set up correctly? Sources: [3] OAuth Proxy | Better Auth [9] fix(oauth-proxy): should skip state check for oauth proxy — Better Auth is powered by https://clarm.com
GitHub
Build software better, together
GitHub is where people build software. More than 150 million people use GitHub to discover, fork, and contribute to over 420 million projects.
From An unknown user
From An unknown user
From An unknown user
OAuth Proxy | Better Auth
OAuth Proxy plugin for Better Auth
GitHub
fix(oauth-proxy): should skip state check for oauth proxy by Bekacr...
Summary by cubic Fixes state_mismatch errors in OAuth Proxy flows by skipping the state cookie check only when proxying, while keeping strict state validation on same-origin flows. Adds safeguards ...
michidk
michidkOP18h ago
@Better Auth Looking at the oAuthProxy plugin source code, the before hook that sets skipStateCookieCheck: true has this logic:
const skipProxy = checkSkipProxy(ctx);
if (skipProxy || ctx.path !== "/callback/:id") {
return; // Don't skip state check
}
return {
context: {
context: {
oauthConfig: {
skipStateCookieCheck: true,
},
},
},
};
const skipProxy = checkSkipProxy(ctx);
if (skipProxy || ctx.path !== "/callback/:id") {
return; // Don't skip state check
}
return {
context: {
context: {
oauthConfig: {
skipStateCookieCheck: true,
},
},
},
};
And checkSkipProxy() returns true when:
const productionURL = opts?.productionURL || env.BETTER_AUTH_URL;
if (productionURL === ctx.context.options.baseURL) {
return true; // Skip proxy = true
}
const productionURL = opts?.productionURL || env.BETTER_AUTH_URL;
if (productionURL === ctx.context.options.baseURL) {
return true; // Skip proxy = true
}
On production: productionURL === baseURL (both are production.example.com), so skipProxy = true, which means the hook returns early and never sets skipStateCookieCheck: true. This causes production to perform normal state cookie validation, which fails because the state cookie was created on localhost. Additional Context - Both environments use the same BETTER_AUTH_SECRET - Both environments share the same production database (PostgreSQL with Drizzle adapter) - OAuth provider only has production URL whitelisted (cannot add localhost) - The same auth configuration code runs on both environments
michidk
michidkOP17h ago
okay turns out the oauth proxy does not work cross-domain. so when running on localhost or vercel preview environmennts (*.vercel.app) but you main production env is on a different domain. That makes it quite useless. However, there is a community plugin that works: https://github.com/better-auth/better-auth/issues/1819 Next-auth has this functionality built-in.
GitHub
Proposal: Stateless OAuth State Management for Cross-Environment Au...
Is this suited for github? Yes, this is suited for github Is your feature request related to a problem? Please describe. When using the OAuth proxy plugin with different environments (e.g., develop...

Did you find this page helpful?