A 401 on OPTIONS is a CORS failure, not an auth failure
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.