[GH-ISSUE #8889] oauth-proxy ignores Apple form_post callback body and fails with no_code #19854

Closed
opened 2026-04-15 19:12:30 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @sarkarghya on GitHub (Apr 1, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8889

Originally assigned to: @bytaesu on GitHub.

Describe the bug

When oAuthProxy is enabled and Apple Sign In uses its default Better Auth browser flow, the callback can fail with no_code even though Apple posts a valid authorization code to the callback endpoint.

The root cause appears to be that the oauth-proxy callback hook reads state from either ctx.query or ctx.body, but then validates/parses only ctx.query for code and error. Apple uses response_mode=form_post, so the authorization code is in the POST body, not the query string.

Versions

  • better-auth: 1.5.6
  • @better-auth/core: 1.5.6
  • better-call: 1.3.2
  • Runtime: Bun + Hono
  • Deployment: HTTPS app behind a reverse proxy

Configuration

This is the relevant app configuration, with all private values redacted:

betterAuth({
  trustedOrigins: [
    "https://app.example.com",
    "https://appleid.apple.com",
  ],
  advanced: {
    cookiePrefix: "<redacted>",
    cookies: {
      state: {
        attributes: {
          sameSite: "none",
          secure: true,
        },
      },
      pkceCodeVerifier: {
        attributes: {
          sameSite: "none",
          secure: true,
        },
      },
      nonce: {
        attributes: {
          sameSite: "none",
          secure: true,
        },
      },
    },
  },
  plugins: [
    oAuthProxy({
      productionURL: "https://app.example.com",
    }),
  ],
  socialProviders: {
    apple: {
      clientId: "<redacted service id>",
      clientSecret: "<generated JWT>",
      appBundleIdentifier: "<redacted bundle id>",
      enabled: true,
    },
  },
  onAPIError: {
    throw: true,
    errorURL: "/error",
  },
})

The auth routes are mounted as a raw passthrough, e.g. auth.handler(c.req.raw).

Relevant provider behavior

Better Auth's Apple provider sets:

responseMode: "form_post"
responseType: "code id_token"

from @better-auth/core/dist/social-providers/apple.mjs.

Reproduction flow

  1. Call POST /api/auth/sign-in/social with provider apple
  2. Better Auth redirects to Apple with response_mode=form_post
  3. Apple POSTs back to /api/auth/callback/apple
  4. The request body contains code, state, id_token, and user
  5. Better Auth redirects to /error?error=no_code

Anonymized HAR evidence

Sign-in response:

POST /api/auth/sign-in/social

Location: https://appleid.apple.com/auth/authorize?response_type=code+id_token&client_id=<redacted>&state=<redacted>&scope=email+name&redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback%2Fapple&response_mode=form_post
Set-Cookie: <state cookie present>

Callback request from Apple:

POST /api/auth/callback/apple
Content-Type: application/x-www-form-urlencoded
Origin: https://appleid.apple.com

Body keys present:
- code
- state
- id_token
- user

Observed server result:

302 Location: /error?error=no_code

And the log line is:

ERROR [Better Auth]: OAuth callback missing authorization code

Exact failing code path

In dist/plugins/oauth-proxy/index.mjs:

const state = ctx.query?.state || ctx.body?.state;
if (!state || typeof state !== "string") return;

const query = oauthCallbackQuerySchema.safeParse(ctx.query);
if (!query.success) {
  ctx.context.logger.warn("Invalid OAuth callback query", query.error);
  return;
}

const { code, error } = query.data;

if (!code) {
  ctx.context.logger.error("OAuth callback missing authorization code");
  throw redirectOnError(ctx, errorURL, "no_code");
}

The hook accepts state from the body, but still reads code and error only from ctx.query.

Why this seems specific to Apple

The normal callback route already handles POST callbacks by merging body + query and redirecting to a GET callback URL. But the oauth-proxy hook intercepts /callback/:id earlier and does not do the same merge.

Apple is the provider that exposes this because it uses form_post.

Expected behavior

When the callback is a POST with application/x-www-form-urlencoded, the proxy hook should read callback parameters from the POST body as well, or merge body + query the same way the normal callback route does.

Possible fix

In the oauth-proxy /callback/:id hook, merge ctx.body and ctx.query before parsing code/error, similar to the normal callback route.

Something along these lines:

const callbackData = oauthCallbackQuerySchema.safeParse({
  ...(ctx.body ?? {}),
  ...(ctx.query ?? {}),
});

It may also make sense to preserve Apple's posted user payload for consistency with the normal callback flow.

Additional note

I confirmed that the lower-level request parser does handle application/x-www-form-urlencoded, so this does not look like a body parsing bug in better-call; it looks isolated to the oauth-proxy callback hook logic.

Originally created by @sarkarghya on GitHub (Apr 1, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8889 Originally assigned to: @bytaesu on GitHub. ## Describe the bug When `oAuthProxy` is enabled and Apple Sign In uses its default Better Auth browser flow, the callback can fail with `no_code` even though Apple posts a valid authorization code to the callback endpoint. The root cause appears to be that the `oauth-proxy` callback hook reads `state` from either `ctx.query` or `ctx.body`, but then validates/parses only `ctx.query` for `code` and `error`. Apple uses `response_mode=form_post`, so the authorization `code` is in the POST body, not the query string. ## Versions - `better-auth`: `1.5.6` - `@better-auth/core`: `1.5.6` - `better-call`: `1.3.2` - Runtime: Bun + Hono - Deployment: HTTPS app behind a reverse proxy ## Configuration This is the relevant app configuration, with all private values redacted: ```ts betterAuth({ trustedOrigins: [ "https://app.example.com", "https://appleid.apple.com", ], advanced: { cookiePrefix: "<redacted>", cookies: { state: { attributes: { sameSite: "none", secure: true, }, }, pkceCodeVerifier: { attributes: { sameSite: "none", secure: true, }, }, nonce: { attributes: { sameSite: "none", secure: true, }, }, }, }, plugins: [ oAuthProxy({ productionURL: "https://app.example.com", }), ], socialProviders: { apple: { clientId: "<redacted service id>", clientSecret: "<generated JWT>", appBundleIdentifier: "<redacted bundle id>", enabled: true, }, }, onAPIError: { throw: true, errorURL: "/error", }, }) ``` The auth routes are mounted as a raw passthrough, e.g. `auth.handler(c.req.raw)`. ## Relevant provider behavior Better Auth's Apple provider sets: ```ts responseMode: "form_post" responseType: "code id_token" ``` from `@better-auth/core/dist/social-providers/apple.mjs`. ## Reproduction flow 1. Call `POST /api/auth/sign-in/social` with provider `apple` 2. Better Auth redirects to Apple with `response_mode=form_post` 3. Apple POSTs back to `/api/auth/callback/apple` 4. The request body contains `code`, `state`, `id_token`, and `user` 5. Better Auth redirects to `/error?error=no_code` ## Anonymized HAR evidence Sign-in response: ```http POST /api/auth/sign-in/social Location: https://appleid.apple.com/auth/authorize?response_type=code+id_token&client_id=<redacted>&state=<redacted>&scope=email+name&redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback%2Fapple&response_mode=form_post Set-Cookie: <state cookie present> ``` Callback request from Apple: ```http POST /api/auth/callback/apple Content-Type: application/x-www-form-urlencoded Origin: https://appleid.apple.com Body keys present: - code - state - id_token - user ``` Observed server result: ```http 302 Location: /error?error=no_code ``` And the log line is: ```text ERROR [Better Auth]: OAuth callback missing authorization code ``` ## Exact failing code path In `dist/plugins/oauth-proxy/index.mjs`: ```ts const state = ctx.query?.state || ctx.body?.state; if (!state || typeof state !== "string") return; const query = oauthCallbackQuerySchema.safeParse(ctx.query); if (!query.success) { ctx.context.logger.warn("Invalid OAuth callback query", query.error); return; } const { code, error } = query.data; if (!code) { ctx.context.logger.error("OAuth callback missing authorization code"); throw redirectOnError(ctx, errorURL, "no_code"); } ``` The hook accepts `state` from the body, but still reads `code` and `error` only from `ctx.query`. ## Why this seems specific to Apple The normal callback route already handles POST callbacks by merging body + query and redirecting to a GET callback URL. But the `oauth-proxy` hook intercepts `/callback/:id` earlier and does not do the same merge. Apple is the provider that exposes this because it uses `form_post`. ## Expected behavior When the callback is a POST with `application/x-www-form-urlencoded`, the proxy hook should read callback parameters from the POST body as well, or merge body + query the same way the normal callback route does. ## Possible fix In the `oauth-proxy` `/callback/:id` hook, merge `ctx.body` and `ctx.query` before parsing `code`/`error`, similar to the normal callback route. Something along these lines: ```ts const callbackData = oauthCallbackQuerySchema.safeParse({ ...(ctx.body ?? {}), ...(ctx.query ?? {}), }); ``` It may also make sense to preserve Apple's posted `user` payload for consistency with the normal callback flow. ## Additional note I confirmed that the lower-level request parser does handle `application/x-www-form-urlencoded`, so this does not look like a body parsing bug in `better-call`; it looks isolated to the `oauth-proxy` callback hook logic.
GiteaMirror added the bug label 2026-04-15 19:12:30 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 1, 2026):

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

This is a confirmed bug. The oauth-proxy callback hook reads state from both ctx.query and ctx.body [1], but then only parses code/error from ctx.query [2], which breaks Apple's form_post flow.

The normal callback route was already fixed in PR #7181 to use a queryOrBody pattern that merges both sources, but that fix was never applied to the oauth-proxy plugin's callback hook. PR #8082 added generic OAuth support to oauth-proxy and partially addressed this by reading state from both sources, but left code and error reading from ctx.query only.

Your suggested fix is exactly right — the oauth-proxy hook at line 344 should merge ctx.body and ctx.query before parsing:

const query = oauthCallbackQuerySchema.safeParse({
  ...(ctx.body ?? {}),
  ...(ctx.query ?? {}),
});

This would align it with the pattern already used in the normal callback route.

To reply, just mention @dosu.


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

<!-- gh-comment-id:4171426692 --> @dosubot[bot] commented on GitHub (Apr 1, 2026): <!-- Greeting --> Hi @sarkarghya! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a confirmed bug. The oauth-proxy callback hook reads `state` from both `ctx.query` and `ctx.body` [[1]](https://github.com/better-auth/better-auth/blob/04e91d1643eb9ef02e67c83bd2e0be0412126a60/packages/better-auth/src/plugins/oauth-proxy/index.ts#L316), but then only parses `code`/`error` from `ctx.query` [[2]](https://github.com/better-auth/better-auth/blob/04e91d1643eb9ef02e67c83bd2e0be0412126a60/packages/better-auth/src/plugins/oauth-proxy/index.ts#L344-L352), which breaks Apple's `form_post` flow. The normal callback route was already fixed in [PR #7181](https://github.com/better-auth/better-auth/pull/7181) to use a `queryOrBody` pattern that merges both sources, but that fix was never applied to the oauth-proxy plugin's callback hook. [PR #8082](https://github.com/better-auth/better-auth/pull/8082) added generic OAuth support to oauth-proxy and partially addressed this by reading `state` from both sources, but left `code` and `error` reading from `ctx.query` only. Your suggested fix is exactly right — the oauth-proxy hook at line 344 should merge `ctx.body` and `ctx.query` before parsing: ```ts const query = oauthCallbackQuerySchema.safeParse({ ...(ctx.body ?? {}), ...(ctx.query ?? {}), }); ``` This would align it with the pattern already used in the normal callback route. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=4e73368b-5dd9-4be4-801d-7eaab392d39c&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20confirmed%20bug.%20The%20oauth-proxy%20callback%20hook%20reads%20%60state%60%20from%20both%20%60ctx.query%60%20and%20%60ctx.body%60%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/04e91d1643eb9ef02e67c83bd2e0be0412126a60/packages/better-auth/src/plugins/oauth-proxy/index.ts%23L316%29%2C%20but%20then%20only%20parses%20%60code%60/%60error%60%20from%20%60ctx.query%60%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/04e91d1643eb9ef02e67c83bd2e0be0412126a60/packages/better-auth/src/plugins/oauth-proxy/index.ts%23L344-L352%29%2C%20which%20breaks%20Apple%27s%20%60form_post%60%20flow.%0A%0AThe%20normal%20callback%20route%20was%20already%20fixed%20in%20%5BPR%20%237181%5D%28https%3A//github.com/better-auth/better-auth/pull/7181%29%20to%20use%20a%20%60queryOrBody%60%20pattern%20that%20merges%20both%20sources%2C%20but%20that%20fix%20was%20never%20applied%20to%20the%20oauth-proxy%20plugin%27s%20callback%20hook.%20%5BPR%20%238082%5D%28https%3A//github.com/better-auth/better-auth/pull/8082%29%20added%20generic%20OAuth%20support%20to%20oauth-proxy%20and%20partially%20addressed%20this%20by%20reading%20%60state%60%20from%20both%20sources%2C%20but%20left%20%60code%60%20and%20%60error%60%20reading%20from%20%60ctx.query%60%20only.%0A%0AYour%20suggested%20fix%20is%20exactly%20right%20%E2%80%94%20the%20oauth-proxy%20hook%20at%20line%20344%20should%20merge%20%60ctx.body%60%20and%20%60ctx.query%60%20before%20parsing%3A%0A%0A%60%60%60ts%0Aconst%20query%20%3D%20oauthCallbackQuerySchema.safeParse%28%7B%0A%20%20...%28ctx.body%20%3F%3F%20%7B%7D%29%2C%0A%20%20...%28ctx.query%20%3F%3F%20%7B%7D%29%2C%0A%7D%29%3B%0A%60%60%60%0A%0AThis%20would%20align%20it%20with%20the%20pattern%20already%20used%20in%20the%20normal%20callback%20route.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&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/8889)
Author
Owner

@bytaesu commented on GitHub (Apr 1, 2026):

Hi @sarkarghya, let me check 🧐

<!-- gh-comment-id:4171745954 --> @bytaesu commented on GitHub (Apr 1, 2026): Hi @sarkarghya, let me check 🧐
Author
Owner

@sarkarghya commented on GitHub (Apr 1, 2026):

@bytaesu thanks! the issue is definitely with oauth proxy with apple since when i comment out the oauth proxy everything works as expected

<!-- gh-comment-id:4172818583 --> @sarkarghya commented on GitHub (Apr 1, 2026): @bytaesu thanks! the issue is definitely with oauth proxy with apple since when i comment out the oauth proxy everything works as expected
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19854