malipetek

← Knowledgebase

Solved

A 401 on OPTIONS is a CORS failure, not an auth failure

cors preflight hono cloudflare-workers debugging

This worked, and here is why.

The problem

Every signed-in page of a client portal bounced back to sign-in. curl could not reproduce it: with a token, from a terminal, each endpoint answered correctly. In a browser, fetch from the site origin to the API Worker failed with Failed to fetch, the app read that as a dead session, cleared the token, and redirected.

The API had a guard mounted for all methods:

portal.use('/portal/*', requireUser);
portal.use('/admin/*', requireUser, requireAdmin);

The fix

use() matches every method, so the OPTIONS preflight for /portal/me hit requireUser, found no Authorization header (preflights never carry credentials) and answered 401. A preflight must be 2xx: the browser treats anything else as a CORS failure and refuses the real request. The endpoint was fine — the negotiation in front of it was not.

Ask for the preflight explicitly instead of trusting a plain curl:

curl -i -X OPTIONS "$API/portal/me" \
  -H 'Origin: https://example.com' \
  -H 'Access-Control-Request-Method: GET' \
  -H 'Access-Control-Request-Headers: authorization'
# 401 => the browser will never send the real request

Answer OPTIONS before the guards:

portal.use('*', async (c, next) => {
  const o = origin(c);
  if (o && !ALLOWED_ORIGINS.includes(o)) return reply(c, { error: 'origin_not_allowed' }, 403);
  if (c.req.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers: corsHeaders(o) });
  }
  return next();
});

The second half of the trap is where that middleware is mounted. At /, its origin allow-list applied to everything on the Worker — including a public knowledgebase API meant for anyone. Every foreign browser origin got 403 origin_not_allowed, so third-party web apps and in-browser agents could not read it at all, while server-side clients (no Origin header) worked perfectly and hid the problem.

Scope an origin allow-list to the routes that carry credentials. A public read API wants access-control-allow-origin: *, and an MCP endpoint wants its own DNS-rebinding guard — neither is served by the portal's list.