Debugging

Fixing the Access-Control-Allow-Headers error

Your code sets a custom header, say an API version or a client ID, and the request never reaches your server at all. The browser console blames Access-Control-Allow-Headers, which looks like a server-side CORS config problem but is really about one specific response: the preflight. This post covers what that preflight is checking, the wildcard exception that trips people up, and the fix on both ends of the connection.

· ~6 min read

The error you're looking at

In Chrome and Edge it reads roughly like this:

Access to fetch at 'https://api.example.com/orders' from origin
'https://app.mysite.com' has been blocked by CORS policy: Request
header field x-client-id is not allowed by Access-Control-Allow-Headers
in preflight response.

The message names the exact header your code tried to send, and says the server's preflight answer didn't include it. Open DevTools → Network, find the OPTIONS request right before the failed one, and check its Access-Control-Allow-Headers response value against the header your fetch call actually set. That's the whole diagnosis: one is missing a name the other one needs.

Why only some requests hit this

A preflight only fires for requests outside the browser's small "simple request" safelist: GET/HEAD/POST with nothing but a few safelisted headers, and for POST a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. The moment you add a header like X-Client-ID, X-API-Version, or even Content-Type: application/json, the browser sends an OPTIONS first and reads its Access-Control-Allow-Headers and Access-Control-Allow-Methods before it will send the real request. If the header you're setting isn't in that list, the browser stops there; your server, and your server's logs, never see the real request. That's different from the plain "no Access-Control-Allow-Origin" error covered in why am I getting a CORS error, where the request does go out and the response comes back blocked. Here, nothing reaches the server at all.

The fix on a server you control

The server has to answer the OPTIONS preflight itself, separately from the real request, and that answer has to list every custom header the client sends. A minimal Node server that gets this right:

import { createServer } from 'node:http';

const ALLOWED_ORIGIN = 'https://app.mysite.com';
const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Client-ID';

createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS);

  if (req.method === 'OPTIONS') {
    res.writeHead(204).end();
    return;
  }

  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ ok: true }));
}).listen(8080);

The common mistake isn't forgetting the header on the real response, it's forgetting it on the OPTIONS one, or updating one and not the other after adding a new client header later. You can check your own server without a browser by sending the preflight yourself:

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://app.mysite.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: x-client-id"

If the response is missing Access-Control-Allow-Headers, or its value doesn't include x-client-id, that's the bug, and no browser is needed to see it.

The wildcard doesn't cover Authorization

Browsers also accept a wildcard, Access-Control-Allow-Headers: *, but it only works for requests made without credentials, and it has one exception even then: Authorization is never covered by * and has to be listed by name regardless. If your preflight response is Access-Control-Allow-Headers: * and the failing header is specifically Authorization, add it explicitly; the wildcard won't cover it:

Access-Control-Allow-Headers: Authorization, *

This is a narrower claim than "credentials block the wildcard" in general. A credentialed request (credentials: 'include') rules out * on Access-Control-Allow-Origin entirely, which is a separate rule covered in credentialed requests. The Authorization-header exception above applies even on an uncredentialed request, which is the part that catches people off guard.

When you don't control the server

If the API is a third party and the missing header is one it genuinely doesn't support from a browser, there are two real options. The first is to stop sending that header from the browser, if the API has any other way to carry the same information, such as a query parameter it already accepts. The second is to stop calling it directly from the browser: proxy the request through something that isn't subject to CORS, whether that's your own backend or a CORS proxy, and set the header there instead.

On corsproxy.dev specifically, a header you can't get a third-party preflight to accept can be added with reqHeaders=name:value in the query string, including Authorization, and the proxy attaches it to the upstream request server-side. Because the header lives in the query string rather than as a real header on your own fetch call, your browser's request to the proxy stays a plain GET or POST with no extra header to preflight on that axis, so this specific rejection doesn't come up on that leg of the request at all. The security tradeoffs of that approach, and the managed-upstream-header alternative that keeps the value out of the browser entirely, are covered in how a CORS proxy works, and how it can be abused. It's a workaround for a server you don't control, not a substitute for fixing one you do.