Oauth Error callback URL discrepencies #2172

Open
opened 2026-03-13 09:31:55 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @ZiggyQubert on GitHub (Oct 21, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Specify an errorCallbackURL URL when calling the google social provider, ensure that skipStateCookieCheck is false, code takes you to the default error page rather than using the calculated error url

if (!skipStateCookieCheck && (!stateCookieValue || stateCookieValue !== state)) {
    const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
    throw c.redirect(`${errorURL}?error=state_mismatch`);
  }

Note that its using c.context.options.onAPIError rather than the parsedData.errorURL

Current vs. Expected behavior

I expect to be taken to the specified error page rather than the default error page

What version of Better Auth are you using?

1.3.28

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Home",
    "release": "10.0.26100",
    "cpuCount": 16,
    "cpuModel": "Intel(R) Core(TM) Ultra 9 285H",
    "totalMemory": "31.50 GB",
    "freeMemory": "9.94 GB"
  },
  "node": {
    "version": "v22.15.1",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.2"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "15.5.3"
    },
    {
      "name": "react",
      "version": "^19.0.0"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "^6.9.0"
    }
  ],
  "betterAuth": {
    "version": "^1.3.28",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
});

Additional context

note that this directly effects expo where you need to be taken to a native page, rather than a backend page

Originally created by @ZiggyQubert on GitHub (Oct 21, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Specify an errorCallbackURL URL when calling the google social provider, ensure that skipStateCookieCheck is false, code takes you to the default error page rather than using the calculated error url ``` if (!skipStateCookieCheck && (!stateCookieValue || stateCookieValue !== state)) { const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`; throw c.redirect(`${errorURL}?error=state_mismatch`); } ``` Note that its using c.context.options.onAPIError rather than the parsedData.errorURL ### Current vs. Expected behavior I expect to be taken to the specified error page rather than the default error page ### What version of Better Auth are you using? 1.3.28 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Home", "release": "10.0.26100", "cpuCount": 16, "cpuModel": "Intel(R) Core(TM) Ultra 9 285H", "totalMemory": "31.50 GB", "freeMemory": "9.94 GB" }, "node": { "version": "v22.15.1", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.2" }, "frameworks": [ { "name": "next", "version": "15.5.3" }, { "name": "react", "version": "^19.0.0" } ], "databases": [ { "name": "@prisma/client", "version": "^6.9.0" } ], "betterAuth": { "version": "^1.3.28", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailAndPassword: { enabled: true }, }); ``` ### Additional context note that this directly effects expo where you need to be taken to a native page, rather than a backend page
GiteaMirror added the social-provider label 2026-03-13 09:31:55 -05:00
Author
Owner

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

Hi @ZiggyQubert! I'm Dosu and I’m helping the better-auth team.

This is a known limitation in Better Auth: when the OAuth state check fails (like with skipStateCookieCheck set to false and a state mismatch), the code always redirects to onAPIError.errorURL or the default backend error page, not the errorCallbackURL you passed in the social provider call. This happens because errorCallbackURL is stored in the OAuth state, but if the state is missing or invalid, Better Auth can't retrieve it and falls back to the global errorURL or backend page. This is especially problematic for Expo/native apps, where you need to redirect to a native page instead of a backend error page. There’s currently no advanced config or hook to override this fallback—it's a common complaint and feature request in the community [1] [2] [3].

Workarounds: set onAPIError.errorURL in your Better Auth config to your frontend/native error page as a global fallback, or (for Next.js) use middleware to intercept and redirect from the default error page to your custom page [3]. However, true per-flow errorCallbackURL support in state-mismatch scenarios would require a code change to prioritize runtime error URLs when available.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Oct 21, 2025): <!-- Greeting --> Hi @ZiggyQubert! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known limitation in Better Auth: when the OAuth state check fails (like with skipStateCookieCheck set to false and a state mismatch), the code always redirects to onAPIError.errorURL or the default backend error page, not the errorCallbackURL you passed in the social provider call. This happens because errorCallbackURL is stored in the OAuth state, but if the state is missing or invalid, Better Auth can't retrieve it and falls back to the global errorURL or backend page. This is especially problematic for Expo/native apps, where you need to redirect to a native page instead of a backend error page. There’s currently no advanced config or hook to override this fallback—it's a common complaint and feature request in the community [[1]](https://github.com/better-auth/better-auth/issues/4694) [[2]](https://github.com/better-auth/better-auth/issues/1580) [[3]](https://github.com/better-auth/better-auth/issues/2519). Workarounds: set onAPIError.errorURL in your Better Auth config to your frontend/native error page as a global fallback, or (for Next.js) use middleware to intercept and redirect from the default error page to your custom page [[3]](https://github.com/better-auth/better-auth/issues/2519). However, true per-flow errorCallbackURL support in state-mismatch scenarios would require a code change to prioritize runtime error URLs when available. <!-- 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/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/74ae1a9b-44b1-4207-b1d6-d5749c517939?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/5467)
Author
Owner

@ZiggyQubert commented on GitHub (Oct 21, 2025):

@dosu true however previous to using the error the OAuth state has already ben retrieved and the errorURL parsed out and defaulted if necessary, its just not being used after its ben calculated

@ZiggyQubert commented on GitHub (Oct 21, 2025): @dosu true however previous to using the error the OAuth state has already ben retrieved and the errorURL parsed out and defaulted if necessary, its just not being used after its ben calculated
Author
Owner

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

You're right—the errorURL is parsed from the OAuth state and available in memory, but in the state cookie check (and other early validation failures), Better Auth intentionally ignores the parsed errorURL and always falls back to the global onAPIError.errorURL or the default backend error page. This is a deliberate security measure: if the state or its cookie is missing or mismatched, the state payload (including errorURL) is considered untrusted and not used for redirects, to avoid open redirect vulnerabilities or leaking information from potentially tampered state data. Only after all state and cookie checks pass does the code use the parsed errorURL for error handling in the rest of the flow. This design is consistent throughout the codebase and is not an oversight, but it does limit per-flow error handling in these scenarios, especially for native/Expo apps that need custom error pages. More details and code references are available here:
generic-oauth/index.ts
state.ts

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Oct 21, 2025): <!-- Answer --> You're right—the errorURL is parsed from the OAuth state and available in memory, but in the state cookie check (and other early validation failures), Better Auth intentionally ignores the parsed errorURL and always falls back to the global onAPIError.errorURL or the default backend error page. This is a deliberate security measure: if the state or its cookie is missing or mismatched, the state payload (including errorURL) is considered untrusted and not used for redirects, to avoid open redirect vulnerabilities or leaking information from potentially tampered state data. Only after all state and cookie checks pass does the code use the parsed errorURL for error handling in the rest of the flow. This design is consistent throughout the codebase and is not an oversight, but it does limit per-flow error handling in these scenarios, especially for native/Expo apps that need custom error pages. More details and code references are available here: [generic-oauth/index.ts](https://github.com/better-auth/better-auth/blob/fa6ee9d68214c6a51e7253b53f957407b116b0c4/packages/better-auth/src/plugins/generic-oauth/index.ts) [state.ts](https://github.com/better-auth/better-auth/blob/fa6ee9d68214c6a51e7253b53f957407b116b0c4/packages/better-auth/src/oauth2/state.ts) <!-- 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/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c2ae79bf-70cb-4d2e-b489-c35da51c7518?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/5467)
Author
Owner

@ping-maxwell commented on GitHub (Jan 9, 2026):

Hello can you confirm this is still an issue on the latest version of Better Auth?

@ping-maxwell commented on GitHub (Jan 9, 2026): Hello can you confirm this is still an issue on the latest version of Better Auth?
Author
Owner

@arunanshub commented on GitHub (Jan 10, 2026):

@ping-maxwell yes still an issue. For some reason when using oauth2 provider (auth0 in our case), and the provider returns an oauth error (say "access_denied"), the callbackURL/errorCallbackURL query param is not forwarded.

State parsed too late

from the source:

  // Step 1: Error check happens FIRST (before state parsing)
  if (ctx.query.error || !ctx.query.code) {
      throw ctx.redirect(
          `${defaultErrorURL}?error=${ctx.query.error}&error_description=${ctx.query.error_description}`
      );
  }

  // Step 2: State parsing happens SECOND (too late!)
  const parsedState = await parseState(ctx);
  const { callbackURL, errorURL, ... } = parsedState;

f0a6f94485/packages/better-auth/src/plugins/generic-oauth/routes.ts (L277-L310)

When Auth0 returns access_denied, better-auth redirects immediately using the global defaultErrorURL (from onAPIError.errorURL) before it ever parses the state to get errorCallbackURL.

The onAPIError.onError(error, ctx) Hook

This hook is called for internal API errors, but OAuth provider redirect errors (like access_denied) bypass this hook entirely - they throw a redirect exception before error handling runs.

Even if it were called, the ctx parameter doesn't contain the OAuth state with the original callbackURL.

We were wondering if this is a priority for the better-auth team. For now we are using a hacky cookie based setup.

@arunanshub commented on GitHub (Jan 10, 2026): @ping-maxwell yes still an issue. For some reason when using oauth2 provider (auth0 in our case), and the provider returns an oauth error (say "access_denied"), the callbackURL/errorCallbackURL query param is not forwarded. ## State parsed too late from the source: ```ts // Step 1: Error check happens FIRST (before state parsing) if (ctx.query.error || !ctx.query.code) { throw ctx.redirect( `${defaultErrorURL}?error=${ctx.query.error}&error_description=${ctx.query.error_description}` ); } // Step 2: State parsing happens SECOND (too late!) const parsedState = await parseState(ctx); const { callbackURL, errorURL, ... } = parsedState; ``` https://github.com/better-auth/better-auth/blob/f0a6f9448566ffa7f40f51bc881f72448f28836f/packages/better-auth/src/plugins/generic-oauth/routes.ts#L277-L310 When Auth0 returns access_denied, better-auth redirects immediately using the global defaultErrorURL (from onAPIError.errorURL) before it ever parses the state to get errorCallbackURL. ## The `onAPIError.onError(error, ctx)` Hook This hook is called for internal API errors, but OAuth provider redirect errors (like access_denied) bypass this hook entirely - they throw a redirect exception before error handling runs. Even if it were called, the ctx parameter doesn't contain the OAuth state with the original callbackURL. We were wondering if this is a priority for the better-auth team. For now we are using a hacky cookie based setup.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2172