@better-auth/expo server plugin breaks route matching on Bun due to new Request() cloning #2990

Closed
opened 2026-03-13 10:32:41 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @NathanColosimo on GitHub (Mar 5, 2026).

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Set up Better Auth with the expo() server plugin on a Bun-based server (e.g., Next.js running on Bun)
Send a POST to /api/auth/sign-in/social without an Origin header but with expo-origin: myapp:// (this is exactly what the @better-auth/expo client sends from React Native)
The endpoint returns 404 with an empty body

Current vs. Expected behavior

Current behavior:

The expo server plugin's onRequest hook creates a new Request to copy expo-origin to origin:

newHeaders.set("origin", expoOrigin);
return { request: new Request(request, { headers: newHeaders }) };```

On Bun, new Request(existingRequest, { headers }) doesn't correctly preserve the URL/body when the original Request comes from Bun.serve(). 

The resulting Request fails route matching in better-call's router, returning a bare 404. 

This affects all Expo mobile social sign-in on Bun-based servers. 

Web sign-in is unaffected because the browser sets the Origin header natively, so the plugin skips its onRequest hook.



### What version of Better Auth are you using?

1.5.3

### System info

```bash
{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020",
    "release": "25.3.0",
    "cpuCount": 12,
    "cpuModel": "Apple M2 Pro",
    "totalMemory": "16.00 GB",
    "freeMemory": "0.15 GB"
  },
  "node": {
    "version": "v25.7.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.3.10"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "Unknown",
    "config": null
  }
}

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

Client, Package, Backend

Auth config (if applicable)

export const auth = betterAuth({
  baseURL: env.NEXT_PUBLIC_WEB_URL,
  advanced: {
    cookiePrefix: "kompose",
  },
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),
  /** Redis-backed storage for sessions and rate limit counters. */
  secondaryStorage: redisSecondaryStorage,
  /** Rate limiting for auth endpoints (sign-in, token refresh, etc.). */
  rateLimit: {
    enabled: true,
    window: 60,
    max: 100,
    storage: "secondary-storage",
  },
  trustedOrigins: [
    env.NEXT_PUBLIC_WEB_URL,
    "kompose://",
    "exp://",
    "http://localhost:3000",
    "tauri://localhost",
    "https://appleid.apple.com",
  ],
  emailAndPassword: {
    enabled: false,
  },
  account: {
    accountLinking: {
      enabled: true,
      // Support linking additional Google accounts that may use different emails.
      allowDifferentEmails: true,
      trustedProviders: ["google", "apple"],
    },
  },
  socialProviders: {
    google: {
      prompt: "select_account consent",
      accessType: "offline",
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
      scope: ["https://www.googleapis.com/auth/calendar"],
    },
    apple: {
      clientId: env.APPLE_CLIENT_ID,
      clientSecret: env.APPLE_CLIENT_SECRET,
      appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER,
    },
  },
  logger: {
    level: "warn",
  },
  plugins: [
    expo(),
    nextCookies(),
    lastLoginMethod({
      cookieName: "kompose.last_used_login_method",
      storeInDatabase: false,
    }),
    // Bearer token auth for Tauri desktop. The Tauri webview cannot use
    // cookies cross-origin (WKWebView ITP blocks Set-Cookie), so it
    // authenticates via Authorization header instead.
    bearer({ requireSignature: true }),
    // One-time tokens for cross-context auth (Tauri deep-link OAuth flow).
    oneTimeToken({
      storeToken: "hashed",
    }),
  ],
});

Additional context

Suggested fix in packages/expo/src/index.ts — lines 47-54

// Before (broken on Bun):

newHeaders.set("origin", expoOrigin);
return { request: new Request(request, { headers: newHeaders }) };```

// After:
```request.headers.set("origin", expoOrigin);
return { request: request };

This fixed it for me locally:

import { toNextJsHandler } from "better-auth/next-js";

const handlers = toNextJsHandler(auth.handler);

export const GET = handlers.GET;
export function POST(request: Request) {
  // Bun's `new Request(req, { headers })` breaks route matching inside
  // better-call's router. The @better-auth/expo server plugin uses that
  // pattern to copy expo-origin → origin, which triggers the bug.
  // Workaround: mutate the header in-place so the expo plugin sees origin
  // already set and skips its Request cloning.
  const expoOrigin = request.headers.get("expo-origin");
  if (!request.headers.get("origin") && expoOrigin) {
    request.headers.set("origin", expoOrigin);
  }
  return handlers.POST(request);
}
Originally created by @NathanColosimo on GitHub (Mar 5, 2026). Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Set up Better Auth with the expo() server plugin on a Bun-based server (e.g., Next.js running on Bun) Send a POST to /api/auth/sign-in/social without an Origin header but with expo-origin: myapp:// (this is exactly what the @better-auth/expo client sends from React Native) The endpoint returns 404 with an empty body ### Current vs. Expected behavior Current behavior: The expo server plugin's onRequest hook creates a new Request to copy expo-origin to origin: ```const newHeaders = new Headers(request.headers); newHeaders.set("origin", expoOrigin); return { request: new Request(request, { headers: newHeaders }) };``` On Bun, new Request(existingRequest, { headers }) doesn't correctly preserve the URL/body when the original Request comes from Bun.serve(). The resulting Request fails route matching in better-call's router, returning a bare 404. This affects all Expo mobile social sign-in on Bun-based servers. Web sign-in is unaffected because the browser sets the Origin header natively, so the plugin skips its onRequest hook. ### What version of Better Auth are you using? 1.5.3 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020", "release": "25.3.0", "cpuCount": 12, "cpuModel": "Apple M2 Pro", "totalMemory": "16.00 GB", "freeMemory": "0.15 GB" }, "node": { "version": "v25.7.0", "env": "development" }, "packageManager": { "name": "bun", "version": "1.3.10" }, "frameworks": null, "databases": null, "betterAuth": { "version": "Unknown", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client, Package, Backend ### Auth config (if applicable) ```typescript export const auth = betterAuth({ baseURL: env.NEXT_PUBLIC_WEB_URL, advanced: { cookiePrefix: "kompose", }, database: drizzleAdapter(db, { provider: "pg", schema, }), /** Redis-backed storage for sessions and rate limit counters. */ secondaryStorage: redisSecondaryStorage, /** Rate limiting for auth endpoints (sign-in, token refresh, etc.). */ rateLimit: { enabled: true, window: 60, max: 100, storage: "secondary-storage", }, trustedOrigins: [ env.NEXT_PUBLIC_WEB_URL, "kompose://", "exp://", "http://localhost:3000", "tauri://localhost", "https://appleid.apple.com", ], emailAndPassword: { enabled: false, }, account: { accountLinking: { enabled: true, // Support linking additional Google accounts that may use different emails. allowDifferentEmails: true, trustedProviders: ["google", "apple"], }, }, socialProviders: { google: { prompt: "select_account consent", accessType: "offline", clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, scope: ["https://www.googleapis.com/auth/calendar"], }, apple: { clientId: env.APPLE_CLIENT_ID, clientSecret: env.APPLE_CLIENT_SECRET, appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER, }, }, logger: { level: "warn", }, plugins: [ expo(), nextCookies(), lastLoginMethod({ cookieName: "kompose.last_used_login_method", storeInDatabase: false, }), // Bearer token auth for Tauri desktop. The Tauri webview cannot use // cookies cross-origin (WKWebView ITP blocks Set-Cookie), so it // authenticates via Authorization header instead. bearer({ requireSignature: true }), // One-time tokens for cross-context auth (Tauri deep-link OAuth flow). oneTimeToken({ storeToken: "hashed", }), ], }); ``` ### Additional context ## Suggested fix in packages/expo/src/index.ts — lines 47-54 // Before (broken on Bun): ```const newHeaders = new Headers(request.headers); newHeaders.set("origin", expoOrigin); return { request: new Request(request, { headers: newHeaders }) };``` // After: ```request.headers.set("origin", expoOrigin); return { request: request }; ``` ## This fixed it for me locally: ```import { auth } from "@kompose/auth"; import { toNextJsHandler } from "better-auth/next-js"; const handlers = toNextJsHandler(auth.handler); export const GET = handlers.GET; export function POST(request: Request) { // Bun's `new Request(req, { headers })` breaks route matching inside // better-call's router. The @better-auth/expo server plugin uses that // pattern to copy expo-origin → origin, which triggers the bug. // Workaround: mutate the header in-place so the expo plugin sees origin // already set and skips its Request cloning. const expoOrigin = request.headers.get("expo-origin"); if (!request.headers.get("origin") && expoOrigin) { request.headers.set("origin", expoOrigin); } return handlers.POST(request); } ```
GiteaMirror added the expobug labels 2026-03-13 10:32:41 -05:00
Author
Owner

@NathanColosimo commented on GitHub (Mar 5, 2026):

I will open up PR to fix shortly

@NathanColosimo commented on GitHub (Mar 5, 2026): I will open up PR to fix shortly
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2990