[GH-ISSUE #5073] OAuth state mismatch error with oAuthProxy plugin on Vercel preview deployments despite skipStateCookieCheck configuration #10158

Closed
opened 2026-04-13 06:06:54 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @Balance8 on GitHub (Oct 3, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5073

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Set up Better Auth with oAuthProxy plugin for Vercel deployments
  2. Configure GitHub OAuth provider with production callback URL
  3. Deploy to Vercel preview branch (e.g., https://myapp-git-feature.vercel.app)
  4. Attempt GitHub OAuth login from preview deployment
  5. OAuth redirects to GitHub successfully
  6. GitHub redirects back to callback URL
  7. State mismatch error occurs: redirected to /error?error=state_mismatch

Current vs. Expected behavior

Current vs. Expected behavior:

Following the OAuth proxy documentation, I expected the OAuth flow to work correctly on preview deployments. However, I consistently get a state mismatch error when the OAuth callback is processed.

The OAuth proxy appears to be working correctly (requests are being proxied), but the state validation is still failing despite the configuration to skip it. Based on commit a3c1d9fb, the oAuthProxy plugin should automatically set skipStateCookieCheck: true for proxy callbacks, but this doesn't seem to be working in practice.

What version of Better Auth are you using?

1.3.26

System info

{
  "platform": "darwin",
  "arch": "arm64", 
  "node": "v20.x.x",
  "framework": "Next.js 15.6.0-canary.27",
  "deployment": "Vercel"
}

Which area(s) are affected? (Select all that apply)

Package

Auth config (if applicable)

import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { oAuthProxy } from 'better-auth/plugins'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: {
      // Drizzle schema configuration
    },
  }),

  plugins: [
    oAuthProxy({
      productionURL: 'https://myapp.vercel.app',
    }),
    nextCookies(),
  ],

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
      redirectURI: 'https://myapp.vercel.app/api/auth/callback/github',
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    updateAge: 60 * 60 * 24, // 1 day
  },

  trustedOrigins: ['https://*.vercel.app', 'http://localhost:3000'],
})

Additional context

Client configuration:

'use client'
import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient()
export const { signIn, signUp, signOut, useSession } = authClient

OAuth Provider Settings:

GitHub OAuth App configuration:

  • Homepage URL: https://myapp.vercel.app/
  • Authorization callback URL: https://myapp.vercel.app/api/auth/callback/github

Call Site

'use client'

import { useState } from 'react'
import { signIn } from '@/lib/auth-client'

export function LoginForm() {
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState('')

  const handleGitHubAuth = async () => {
    setIsLoading(true)
    setError('')

    try {
      await signIn.social({
        provider: 'github',
        callbackURL: '/',
      })
    } catch (_err) {
      setError('GitHub authentication failed')
      setIsLoading(false)
    }
  }

  return (
    <button onClick={handleGitHubAuth} disabled={isLoading}>
      {isLoading ? 'Signing in...' : 'Continue with GitHub'}
    </button>
  )
}

Middleware

import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Allow access to login page and all API routes
  if (pathname === '/login' || pathname.startsWith('/api/')) {
    return NextResponse.next()
  }

  try {
    const session = await auth.api.getSession({
      headers: request.headers,
    })

    if (!session) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    return NextResponse.next()
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  runtime: 'nodejs',
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|login).*)',
  ],
}

Additional context:

  1. The issue only occurs on Vercel preview deployments, not production
  2. The OAuth proxy is functioning (requests are being proxied to production)
  3. According to commit a3c1d9fb, the oAuthProxy plugin should automatically handle skipStateCookieCheck but this doesn't seem to be working
  4. Even manually setting oauthConfig.skipStateCookieCheck: true doesn't resolve the issue
  5. Using the same database for all environments
  6. The state parameter appears to be correctly passed in the OAuth flow but validation fails on callback

The documentation states that the plugin "requires skipping the state cookie check" but the automatic handling doesn't seem to be working as expected. This makes OAuth unusable on preview deployments, which is the primary use case for the oAuthProxy plugin.

Originally created by @Balance8 on GitHub (Oct 3, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5073 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Set up Better Auth with oAuthProxy plugin for Vercel deployments 2. Configure GitHub OAuth provider with production callback URL 3. Deploy to Vercel preview branch (e.g., `https://myapp-git-feature.vercel.app`) 4. Attempt GitHub OAuth login from preview deployment 5. OAuth redirects to GitHub successfully 6. GitHub redirects back to callback URL 7. State mismatch error occurs: redirected to `/error?error=state_mismatch` ### Current vs. Expected behavior **Current vs. Expected behavior:** Following the OAuth proxy documentation, I expected the OAuth flow to work correctly on preview deployments. However, I consistently get a state mismatch error when the OAuth callback is processed. The OAuth proxy appears to be working correctly (requests are being proxied), but the state validation is still failing despite the configuration to skip it. Based on commit a3c1d9fb, the oAuthProxy plugin should automatically set `skipStateCookieCheck: true` for proxy callbacks, but this doesn't seem to be working in practice. ### What version of Better Auth are you using? 1.3.26 ### System info ```bash { "platform": "darwin", "arch": "arm64", "node": "v20.x.x", "framework": "Next.js 15.6.0-canary.27", "deployment": "Vercel" } ``` ### Which area(s) are affected? (Select all that apply) Package ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { nextCookies } from 'better-auth/next-js' import { oAuthProxy } from 'better-auth/plugins' export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: { // Drizzle schema configuration }, }), plugins: [ oAuthProxy({ productionURL: 'https://myapp.vercel.app', }), nextCookies(), ], socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, redirectURI: 'https://myapp.vercel.app/api/auth/callback/github', }, }, session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24, // 1 day }, trustedOrigins: ['https://*.vercel.app', 'http://localhost:3000'], }) ``` ### Additional context **Client configuration:** ```typescript 'use client' import { createAuthClient } from 'better-auth/react' export const authClient = createAuthClient() export const { signIn, signUp, signOut, useSession } = authClient ``` **OAuth Provider Settings:** GitHub OAuth App configuration: - Homepage URL: `https://myapp.vercel.app/` - Authorization callback URL: `https://myapp.vercel.app/api/auth/callback/github` **Call Site** ```typescript 'use client' import { useState } from 'react' import { signIn } from '@/lib/auth-client' export function LoginForm() { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const handleGitHubAuth = async () => { setIsLoading(true) setError('') try { await signIn.social({ provider: 'github', callbackURL: '/', }) } catch (_err) { setError('GitHub authentication failed') setIsLoading(false) } } return ( <button onClick={handleGitHubAuth} disabled={isLoading}> {isLoading ? 'Signing in...' : 'Continue with GitHub'} </button> ) } ``` **Middleware** ```typescript import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl // Allow access to login page and all API routes if (pathname === '/login' || pathname.startsWith('/api/')) { return NextResponse.next() } try { const session = await auth.api.getSession({ headers: request.headers, }) if (!session) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } catch { return NextResponse.redirect(new URL('/login', request.url)) } } export const config = { runtime: 'nodejs', matcher: [ '/((?!_next/static|_next/image|favicon.ico|login).*)', ], } ``` **Additional context:** 1. The issue only occurs on Vercel preview deployments, not production 2. The OAuth proxy is functioning (requests are being proxied to production) 3. According to commit a3c1d9fb, the oAuthProxy plugin should automatically handle `skipStateCookieCheck` but this doesn't seem to be working 4. Even manually setting `oauthConfig.skipStateCookieCheck: true` doesn't resolve the issue 5. Using the same database for all environments 6. The state parameter appears to be correctly passed in the OAuth flow but validation fails on callback The documentation states that the plugin "requires skipping the state cookie check" but the automatic handling doesn't seem to be working as expected. This makes OAuth unusable on preview deployments, which is the primary use case for the oAuthProxy plugin.
GiteaMirror added the lockedbug labels 2026-04-13 06:06:54 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Oct 3, 2025):

This state mismatch error on Vercel preview deployments is caused by how the oAuthProxy plugin determines when to skip the state cookie check. The plugin only sets skipStateCookieCheck: true for OAuth callback requests if the request path matches /callback/:id and the request is recognized as being proxied (i.e., your preview deployment's baseURL is different from your productionURL, and no special skip header is set). If the baseURL and productionURL are the same, or if the proxy detection fails, the state check is not skipped, which leads to the error you're seeing details.

A recent bug fix in v1.3.25+ improved the handling of skipStateCookieCheck for proxy flows, so upgrading to at least v1.3.25 is recommended details. Make sure your preview deployment's baseURL is different from your productionURL and that your oAuthProxy plugin is configured with the correct productionURL. Also, double-check that your callbackURL in signIn.social points to a user-facing route (like /), not the API callback endpoint details.

If you have custom cookie settings, ensure you do not set partitioned: true in your cookie config, as this can prevent cookies from being shared across domains and cause state mismatch errors in cross-domain OAuth flows details.

In summary: upgrade to v1.3.25 or later, verify your oAuthProxy and callbackURL configuration, and review your cookie settings for cross-domain compatibility. This should resolve the state mismatch issue on Vercel preview deployments.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3367199022 --> @dosubot[bot] commented on GitHub (Oct 3, 2025): <!-- Answer --> This state mismatch error on Vercel preview deployments is caused by how the oAuthProxy plugin determines when to skip the state cookie check. The plugin only sets skipStateCookieCheck: true for OAuth callback requests if the request path matches `/callback/:id` and the request is recognized as being proxied (i.e., your preview deployment's baseURL is different from your productionURL, and no special skip header is set). If the baseURL and productionURL are the same, or if the proxy detection fails, the state check is not skipped, which leads to the error you're seeing [details](https://github.com/better-auth/better-auth/pull/4991). A recent bug fix in v1.3.25+ improved the handling of skipStateCookieCheck for proxy flows, so upgrading to at least v1.3.25 is recommended [details](https://github.com/better-auth/better-auth/pull/5044). Make sure your preview deployment's baseURL is different from your productionURL and that your oAuthProxy plugin is configured with the correct productionURL. Also, double-check that your callbackURL in signIn.social points to a user-facing route (like `/`), not the API callback endpoint [details](https://github.com/better-auth/better-auth/issues/3488). If you have custom cookie settings, ensure you do not set `partitioned: true` in your cookie config, as this can prevent cookies from being shared across domains and cause state mismatch errors in cross-domain OAuth flows [details](https://github.com/better-auth/better-auth/discussions/3262). In summary: upgrade to v1.3.25 or later, verify your oAuthProxy and callbackURL configuration, and review your cookie settings for cross-domain compatibility. This should resolve the state mismatch issue on Vercel preview deployments. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/a1e0d87e-b8f4-4e1c-8450-cdc5d00e2731?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/5073)
Author
Owner

@Balance8 commented on GitHub (Oct 3, 2025):

@dosu We are already on the latest version (1.3.26) and have followed the configuration exactly as documented. The issue persists despite the recent bug fixes.

Here's what's happening:

Preview URL: https://myapp-git-feature-branch-username.vercel.app/login
Production URL: https://myapp.vercel.app

  1. User clicks login on preview deployment
  2. OAuth flow initiates correctly and redirects to GitHub
  3. GitHub redirects back to the callback
  4. User gets redirected to: https://myapp.vercel.app/api/auth/error?error=state_mismatch

The URLs are clearly different (preview vs production), so the proxy should be active and skipStateCookieCheck should be automatically set. The callbackURL in signIn.social is set to / (user-facing route), not the API endpoint. We don't have any custom cookie settings with partitioned: true.

The oAuthProxy plugin appears to be working (requests are being proxied to production), but the state validation is still failing despite the automatic handling that was supposed to be fixed in the recent commits. Even manually setting oauthConfig.skipStateCookieCheck: true doesn't resolve the issue.

This suggests there may still be an issue with the automatic skipStateCookieCheck detection in the oAuthProxy plugin, or the state validation is happening before the plugin can set the skip flag.

<!-- gh-comment-id:3367205273 --> @Balance8 commented on GitHub (Oct 3, 2025): @dosu We are already on the latest version (1.3.26) and have followed the configuration exactly as documented. The issue persists despite the recent bug fixes. Here's what's happening: **Preview URL:** `https://myapp-git-feature-branch-username.vercel.app/login` **Production URL:** `https://myapp.vercel.app` 1. User clicks login on preview deployment 2. OAuth flow initiates correctly and redirects to GitHub 3. GitHub redirects back to the callback 4. User gets redirected to: `https://myapp.vercel.app/api/auth/error?error=state_mismatch` The URLs are clearly different (preview vs production), so the proxy should be active and `skipStateCookieCheck` should be automatically set. The `callbackURL` in `signIn.social` is set to `/` (user-facing route), not the API endpoint. We don't have any custom cookie settings with `partitioned: true`. The oAuthProxy plugin appears to be working (requests are being proxied to production), but the state validation is still failing despite the automatic handling that was supposed to be fixed in the recent commits. Even manually setting `oauthConfig.skipStateCookieCheck: true` doesn't resolve the issue. This suggests there may still be an issue with the automatic `skipStateCookieCheck` detection in the oAuthProxy plugin, or the state validation is happening before the plugin can set the skip flag.
Author
Owner

@dosubot[bot] commented on GitHub (Oct 3, 2025):

This persistent state mismatch on Vercel previews is a known limitation of cross-domain OAuth flows on .vercel.app domains. Even with the latest Better Auth and correct oAuthProxy config, browsers block cookies from being shared across .vercel.app subdomains because they’re on the Public Suffix List. This means the state cookie set on your preview domain isn’t accessible to the production domain during the callback, so state validation fails—even if skipStateCookieCheck is set or manually forced (details, details).

The only reliable workaround is to use a custom domain for both production and previews, and set your cookie domain to that custom domain (e.g., .yourdomain.com). This allows cookies to be shared across all subdomains, so the proxy flow works as intended (details). If you stay on .vercel.app, cross-subdomain cookies simply won’t work due to browser security rules.

Other things to double-check: make sure all preview URLs are in trustedOrigins (using https://${process.env.VERCEL_URL} for dynamic previews), set your client’s baseURL to window.location.origin, and ensure your OAuth provider’s redirect URI is the production API callback endpoint, while your app’s callbackURL is a user-facing route like / (details, details).

But fundamentally, this is a browser-level cookie isolation issue on .vercel.app, not a bug in Better Auth or the oAuthProxy plugin. Moving to a custom domain is the only way to make cross-subdomain OAuth proxying work reliably on Vercel.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3367214593 --> @dosubot[bot] commented on GitHub (Oct 3, 2025): <!-- Answer --> This persistent state mismatch on Vercel previews is a known limitation of cross-domain OAuth flows on .vercel.app domains. Even with the latest Better Auth and correct oAuthProxy config, browsers block cookies from being shared across .vercel.app subdomains because they’re on the Public Suffix List. This means the state cookie set on your preview domain isn’t accessible to the production domain during the callback, so state validation fails—even if skipStateCookieCheck is set or manually forced ([details](https://github.com/better-auth/better-auth/issues/4985#issuecomment-3352378191), [details](https://github.com/better-auth/better-auth/issues/4953#issuecomment-3342058545)). The only reliable workaround is to use a custom domain for both production and previews, and set your cookie domain to that custom domain (e.g., `.yourdomain.com`). This allows cookies to be shared across all subdomains, so the proxy flow works as intended ([details](https://github.com/better-auth/better-auth/issues/4270)). If you stay on .vercel.app, cross-subdomain cookies simply won’t work due to browser security rules. Other things to double-check: make sure all preview URLs are in `trustedOrigins` (using `https://${process.env.VERCEL_URL}` for dynamic previews), set your client’s `baseURL` to `window.location.origin`, and ensure your OAuth provider’s redirect URI is the production API callback endpoint, while your app’s `callbackURL` is a user-facing route like `/` ([details](https://github.com/better-auth/better-auth/issues/2203#issuecomment-2793423139), [details](https://github.com/better-auth/better-auth/issues/3488)). But fundamentally, this is a browser-level cookie isolation issue on .vercel.app, not a bug in Better Auth or the oAuthProxy plugin. Moving to a custom domain is the only way to make cross-subdomain OAuth proxying work reliably on Vercel. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/7458be64-6050-48ae-bd1f-9ba4d0e57eb2?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/5073)
Author
Owner

@Balance8 commented on GitHub (Oct 3, 2025):

@Bekacru is this true?

<!-- gh-comment-id:3367217811 --> @Balance8 commented on GitHub (Oct 3, 2025): @Bekacru is this true?
Author
Owner

@david-yan-1 commented on GitHub (Oct 6, 2025):

Having the same issues, any workarounds?

<!-- gh-comment-id:3372105101 --> @david-yan-1 commented on GitHub (Oct 6, 2025): Having the same issues, any workarounds?
Author
Owner

@ping-maxwell commented on GitHub (Oct 7, 2025):

Well it's certainly true that vercel.app is on there, so it's plausible that is the cause

<!-- gh-comment-id:3375653689 --> @ping-maxwell commented on GitHub (Oct 7, 2025): Well it's certainly true that vercel.app is on there, so it's plausible that is the cause
Author
Owner

@ping-maxwell commented on GitHub (Oct 10, 2025):

Looking into it more, it does seem like the only solution is to have your own domain. This is not something Better-Auth can fix as it realtes to browser-level cookie isolation via .vercel.app - as @dosu bot has explained.

<!-- gh-comment-id:3390398793 --> @ping-maxwell commented on GitHub (Oct 10, 2025): Looking into it more, it does seem like the only solution is to have your own domain. This is not something Better-Auth can fix as it realtes to browser-level cookie isolation via .vercel.app - as @dosu bot has explained.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10158