Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/kitcn/references/features/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({
|--------|-------------|
| `createContext` | RSC context with auth |
| `createCaller` | Server-side caller factory |
| `handler` | Next.js API route handler (`export const { GET, POST } = handler;`) |
| `handler` | Next.js API route handler (`export const { GET, POST, OPTIONS } = handler;`) |

Options: `api`, `convexSiteUrl`, `auth.jwtCache` (default true), `auth.isUnauthorized`.

Expand All @@ -530,7 +530,7 @@ return <ConvexProvider token={token}>{children}</ConvexProvider>;
```ts
// src/app/api/auth/[...all]/route.ts
import { handler } from '@/lib/convex/server';
export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

---
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/kitcn/references/setup/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({
```ts
import { handler } from "@/lib/convex/server";

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

### 8.A.3 RSC helpers
Expand Down
5 changes: 5 additions & 0 deletions .changeset/next-auth-options-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kitcn": patch
---

Support `OPTIONS` preflight forwarding in `kitcn/auth/nextjs` route handlers and generated Next auth routes.
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: Next.js auth proxy must forward OPTIONS preflight
date: 2026-05-22
category: integration-issues
module: auth-nextjs
problem_type: integration_issue
component: authentication
symptoms:
- cross-origin Better Auth clients need `OPTIONS /api/auth/*` preflight support
- the generated Next.js auth route only exported `GET` and `POST`
- users had to wrap `handler.GET` and `handler.POST` manually to add preflight
root_cause: wrong_api
resolution_type: code_fix
severity: medium
tags: [auth, nextjs, better-auth, cors, preflight, options, convex, proxy]
---

# Next.js auth proxy must forward OPTIONS preflight

## Problem

The `kitcn/auth/nextjs` proxy only exposed `GET` and `POST`. That forced
cross-origin clients, such as local WebViews, to add a custom Next.js
`OPTIONS` handler even though the Convex auth route already owned CORS policy.

## Symptoms

- `convex/functions/http.ts` had `registerRoutes(http, getAuth, { cors })`.
- Better Auth `trustedOrigins` included the cross-origin app origin.
- The Next.js route still needed manual preflight code because
`export const { GET, POST } = handler` left `OPTIONS` unhandled.

## What Didn't Work

- Duplicating CORS decisions in the Next.js route is the wrong ownership. The
Convex-side `registerRoutes` helper already combines Better Auth trusted
origins with explicit allowed origins.
- Treating a deployment-wide generic Convex HTTP 500 as this proxy bug is also
wrong. If a bare `/_debug-auth` HTTP action returns the same generic 500
before handler logs, the failure is below KitCN's Better Auth handler.

## Solution

Expose `OPTIONS` from the Next.js proxy and forward it to the Convex site URL.
Do not attach an empty request body to preflight.

```ts
const requestCanHaveBody = (method: string) =>
method !== "GET" && method !== "HEAD" && method !== "OPTIONS";

const nextJsHandler = (siteUrl: string) => ({
GET: (request: Request) => handler(request, siteUrl),
OPTIONS: (request: Request) => handler(request, siteUrl),
POST: (request: Request) => handler(request, siteUrl),
});
```

Generated Next.js auth routes should export all three methods:

```ts
import { handler } from "@/lib/convex/server";

export const { GET, POST, OPTIONS } = handler;
```

## Why This Works

The Next.js route is only a proxy. Forwarding `OPTIONS` lets Convex's HTTP
router answer preflight with the same `registerRoutes` CORS configuration that
answers direct Convex auth requests.

Keeping preflight bodyless avoids subtle fetch/runtime behavior around methods
that do not need a body.

## Prevention

1. When adding a server-side auth proxy, expose every method needed by browser
CORS, not just the methods Better Auth uses for real work.
2. Keep CORS allow-list decisions at one layer. For KitCN auth, that layer is
Convex `registerRoutes`, not the Next.js proxy.
3. Test the public `convexBetterAuth(...).handler` surface for `OPTIONS`
forwarding and generated route templates for `GET, POST, OPTIONS`.

## Related Issues

- [Next.js auth proxy must forward POST bodies explicitly](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/nextjs-auth-proxy-must-forward-post-bodies-explicitly-20260410.md)
- [Convex auth JWKS routes should not trigger Better Auth IP warnings](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/convex-auth-jwks-routes-should-not-trigger-better-auth-ip-warnings-20260325.md)
2 changes: 1 addition & 1 deletion fixtures/next-auth/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { handler } from '@/lib/convex/server';

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
4 changes: 2 additions & 2 deletions packages/kitcn/skills/kitcn/references/features/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({
|--------|-------------|
| `createContext` | RSC context with auth |
| `createCaller` | Server-side caller factory |
| `handler` | Next.js API route handler (`export const { GET, POST } = handler;`) |
| `handler` | Next.js API route handler (`export const { GET, POST, OPTIONS } = handler;`) |

Options: `api`, `convexSiteUrl`, `auth.jwtCache` (default true), `auth.isUnauthorized`.

Expand All @@ -530,7 +530,7 @@ return <ConvexProvider token={token}>{children}</ConvexProvider>;
```ts
// src/app/api/auth/[...all]/route.ts
import { handler } from '@/lib/convex/server';
export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

---
Expand Down
2 changes: 1 addition & 1 deletion packages/kitcn/skills/kitcn/references/setup/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({
```ts
import { handler } from "@/lib/convex/server";

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

### 8.A.3 RSC helpers
Expand Down
25 changes: 23 additions & 2 deletions packages/kitcn/src/auth-nextjs/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { convexBetterAuth } from './index';

describe('convexBetterAuth', () => {
test('creates GET/POST handlers that rewrite request URL to convex site', async () => {
test('creates GET/POST/OPTIONS handlers that rewrite request URL to convex site', async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{
input: RequestInfo | URL;
Expand Down Expand Up @@ -34,8 +34,17 @@ describe('convexBetterAuth', () => {
method: 'POST',
})
);
const preflightRequest = {
headers: {
'access-control-request-method': 'POST',
origin: 'http://localhost:1420',
},
method: 'OPTIONS',
url: 'https://example.com/api/auth/session',
} as unknown as Request;
await result.handler.OPTIONS(preflightRequest);

expect(calls).toHaveLength(2);
expect(calls).toHaveLength(3);

expect(calls[0]?.input).toBe('https://my-app.convex.site/path?a=1');
expect(calls[0]?.init?.method).toBe('GET');
Expand Down Expand Up @@ -66,6 +75,18 @@ describe('convexBetterAuth', () => {
await expect(
new Response(calls[1]?.init?.body as BodyInit).text()
).resolves.toBe(JSON.stringify({ email: 'user@example.com' }));

expect(calls[2]?.input).toBe(
'https://my-app.convex.site/api/auth/session'
);
expect(calls[2]?.init?.method).toBe('OPTIONS');
expect(calls[2]?.init?.redirect).toBe('manual');
const optionsHeaders = new Headers(calls[2]?.init?.headers);
expect(optionsHeaders.get('host')).toBe('my-app.convex.site');
expect(optionsHeaders.get('x-forwarded-host')).toBe('example.com');
expect(optionsHeaders.get('x-forwarded-proto')).toBe('https');
expect(optionsHeaders.get('origin')).toBe('http://localhost:1420');
expect(calls[2]?.init?.body).toBeUndefined();
} finally {
globalThis.fetch = originalFetch;
}
Expand Down
11 changes: 7 additions & 4 deletions packages/kitcn/src/auth-nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { createCallerFactory } from '../server/caller-factory';

const TRAILING_COLON_RE = /:$/;

const requestCanHaveBody = (method: string) =>
method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';

const handler = async (request: Request, siteUrl: string) => {
const requestUrl = new URL(request.url);
const nextUrl = `${siteUrl}${requestUrl.pathname}${requestUrl.search}`;
Expand All @@ -26,10 +29,9 @@ const handler = async (request: Request, siteUrl: string) => {
'x-better-auth-forwarded-proto',
requestUrl.protocol.replace(TRAILING_COLON_RE, '')
);
const body =
request.method !== 'GET' && request.method !== 'HEAD'
? await request.arrayBuffer()
: undefined;
const body = requestCanHaveBody(request.method)
? await request.arrayBuffer()
: undefined;

return fetch(nextUrl, {
body,
Expand All @@ -41,6 +43,7 @@ const handler = async (request: Request, siteUrl: string) => {

const nextJsHandler = (siteUrl: string) => ({
GET: (request: Request) => handler(request, siteUrl),
OPTIONS: (request: Request) => handler(request, siteUrl),
POST: (request: Request) => handler(request, siteUrl),
});

Expand Down
4 changes: 3 additions & 1 deletion packages/kitcn/src/cli/cli.commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,7 +1944,9 @@ describe('cli/cli', () => {
expect(routeSource).toContain(
"import { handler } from '@/lib/convex/server';"
);
expect(routeSource).toContain('export const { GET, POST } = handler;');
expect(routeSource).toContain(
'export const { GET, POST, OPTIONS } = handler;'
);

const authPageSource = fs.readFileSync(
path.join(dir, 'app', 'auth', 'page.tsx'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const AUTH_NEXT_ROUTE_TEMPLATE = `import { handler } from '@/lib/convex/server';

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
`;
2 changes: 1 addition & 1 deletion www/content/docs/migrations/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ API route:
```ts showLineNumbers title="app/api/auth/[...all]/route.ts"
import { handler } from "@/lib/auth-server";

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

Root layout:
Expand Down
2 changes: 1 addition & 1 deletion www/content/docs/nextjs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Create the Next.js API route for Better Auth:
```ts title="src/app/api/auth/[...all]/route.ts" showLineNumbers {3}
import { handler } from '@/lib/convex/server';

export const { GET, POST } = handler;
export const { GET, POST, OPTIONS } = handler;
```

## RSC Setup
Expand Down
Loading