Notes

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response:fixing the preflight allow-list

CORS Doctor

You add a token or a JSON body to an API call, and the browser console prints this:

Access to XMLHttpRequest at 'https://api.example.com/items' from origin 'http://localhost:3000' has been blocked by CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.

Before the real request, the browser sent an OPTIONS request to the same URL, the preflight, and named the headers it was about to send in Access-Control-Request-Headers. Your server answered with an Access-Control-Allow-Headers list that did not contain one of those names, or with no Access-Control-Allow-Headers at all, which Chromium treats as an empty list. The browser stopped there, so the real request never left it.

The wording also tells you what already works. In Chromium's source (Chrome and the browsers built on it), the header list is the last thing checked on a preflight answer: first Access-Control-Allow-Origin and a 2xx status, then the method, and only then the headers. So with this exact message, the preflight passed everything else: it got a 2xx, the origin matched, and the method was allowed. What is left is the header list: a name missing from the server's answer, or a name your frontend should not be sending at all.

The name is lowercase because Chromium lowercases header names for this check: authorization is the Authorization header your code sets, and HTTP header names are case-insensitive anyway. XMLHttpRequest means the call went through XHR, which is what axios uses in the browser; a fetch() call says Access to fetch at instead. Firefox reports the same problem as Reason: missing token 'xyz' in CORS header 'Access-Control-Allow-Headers' from CORS preflight channel.

What the browser compares, name by name

Only headers outside the CORS-safelisted set need to be on the list. That set is short: Accept, Accept-Language, Content-Language, Content-Type and Range, each with extra rules. Content-Type only counts when its value is application/x-www-form-urlencoded, multipart/form-data or text/plain, and no safelisted value may be longer than 128 bytes. Everything else has to be allowed by name: Authorization, every X- header, anything an SDK adds, and Content-Type once it says application/json.

The browser sends those names as one sorted, lowercase, comma-separated list. The server answers with its own list, and the browser looks up each requested name in it, ignoring case:

How the preflight header check fails On the OPTIONS request the browser sends Access-Control-Request-Headers: authorization,content-type,x-requested-with. The server answers with Access-Control-Allow-Headers: Content-Type, X-Requested-With. Each requested name is looked up in the answer, ignoring case. content-type matches Content-Type and x-requested-with matches X-Requested-With, but authorization is not in the list. Chrome prints: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. The fix is to answer the OPTIONS request with Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With. THE BROWSER ASKS, ON OPTIONS Access-Control-Request-Headers: authorization,content-type,x-requested-with YOUR SERVER ANSWERS Access-Control-Allow-Headers: Content-Type, X-Requested-With EACH NAME IS LOOKED UP, IGNORING CASE authorization not in the list content-type Content-Type x-requested-with X-Requested-With WHAT CHROME PRINTS Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. Fix: add the name to the OPTIONS answer Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
The browser names every non-safelisted header it is about to send, in lowercase. The server's list can use any letter case, but every name has to be on it. Here two match and authorization does not, so the real request is never sent.

Chrome stops at the first name it cannot find and reports only that one. If two names are missing, you fix the first, reload, and get the same error again with the second name. So take the full list from Access-Control-Request-Headers on the preflight, not the single name from the console.

01

The token: Authorization is not on the list

The message in this note's title names authorization: the frontend sends a bearer token, and the server's list was written for a public API, or before anyone added login. Add Authorization to Access-Control-Allow-Headers on the preflight answer. Do not reach for * instead; why is under "Things that look like fixes" below.

One detail from a Stack Overflow question on this exact message with over 250,000 views: the asker's Node code already listed Authorization in res.header('Access-Control-Allow-Headers', ...), and the error appeared anyway. The answers there disagree on why. The lesson holds either way: the browser reads the answer to the OPTIONS request it actually sent, not your source code. Check that answer with curl (step 2 below) before you rewrite code that looks right.

02

A JSON body: content-type

If the message names content-type, the request sends a Content-Type outside the three safelisted values, typically application/json. From that moment Content-Type is treated like any custom header and has to be listed. It was the first error in a Stack Overflow question with over 600,000 views: a POST from AngularJS's $http failing with Request header field Content-Type is not allowed by Access-Control-Allow-Headers. Add Content-Type to the list.

03

A header you did not add by hand

SDKs and wrappers add headers you never typed, and every one of them ends up in the preflight:

  • supabase-js sends authorization, apikey and x-client-info on every request and content-type when there is a body. Supabase's documentation lists more that only appear in specific cases: x-retry-count on automatic retries, and traceparent, tracestate and baggage only when trace propagation is turned on. That is why a list copied from an old example can fall behind. From @supabase/supabase-js 2.95.0, Supabase recommends importing corsHeaders from the SDK, so the allow-list follows the client.
  • Sentry adds sentry-trace and baggage to requests that match tracePropagationTargets. The default matches any URL containing localhost and any URL starting with /, so a frontend on localhost:5173 calling an API on localhost:8000 sends them as soon as browserTracingIntegration is on, without touching tracePropagationTargets. Sentry's docs say to allow both on the API, plus traceparent if you turn on propagateTraceparent.
  • Your own interceptors: an axios or fetch wrapper that adds X-Request-Id, X-CSRF-Token or Cache-Control to every call. None of them is on the safelist.

04

Access-Control-Allow-* headers sent from the frontend

If the message names access-control-allow-origin or another Access-Control-Allow-* header, the frontend is sending response headers. In the 600,000-view question, the asker added Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers to the request after the first error, and the message changed to Request header field Access-Control-Allow-Origin is not allowed by Access-Control-Allow-Headers.

These headers only mean something on the server's answer. On a request they are three more non-safelisted headers, each of which would need its own place on the list. Delete them from the client, as the accepted answer there says.

05

A default list that does not know your header

Most CORS layers ship with a fixed list or take one static string, and adding a header in the frontend does not update it:

  • django-cors-headers: CORS_ALLOW_HEADERS defaults to accept, authorization, content-type, user-agent, x-csrftoken and x-requested-with. Anything else, such as x-api-key, has to be added.
  • Express with the cors package: once you set allowedHeaders, only those names are sent. If you leave the option out, the package reflects whatever the browser asked for in Access-Control-Request-Headers.
  • AWS API Gateway (REST API), Enable CORS in the console: Access-Control-Allow-Headers is a static string, prefilled with Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token. A header outside it has to be added there, and the API has to be deployed again before the change takes effect.

Hand-written middleware can fail the same way in one more place: headers set inside a GET or POST handler never reach the preflight, because the OPTIONS request does not run that handler. Set them wherever OPTIONS is answered.

06

Someone else's API that does not accept the header

When the API belongs to someone else, you cannot change its list. In a 2022 question, a Next.js app called the Slack Web API from the browser with Authorization: Bearer .... Slack's preflight answer listed only its own headers (slack-route, x-slack-version-ts and a few x-b3- tracing headers), so authorization failed. In the accepted answer, the asker reports that Slack support told them requests from browsers were not supported at that time.

The way out is to make the call from a server you control, which calls the API and hands the result to your page; MDN describes the same approach for servers you do not control. It also fixes a second problem in that code: the token sat in a NEXT_PUBLIC_ variable, and Next.js inlines those into the JavaScript bundle it sends to the browser, where anyone can read it.

The fix, step by step

  1. Get the full list the browser asked for. In DevTools, open the Network tab, find the OPTIONS request to the same URL and copy Access-Control-Request-Headers from its request headers. If you cannot find it, write the list yourself from the headers your code, your interceptors and your SDKs set.
  2. Reproduce the preflight with curl, using that list:

    Terminal

    curl -i -X OPTIONS https://api.example.com/items \
      -H "Origin: http://localhost:3000" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: authorization,content-type,x-requested-with"
  3. Sort the names into two groups. Some should not be sent at all: Access-Control-* headers, a stray Cache-Control, trace headers going to an API that does not use them. Remove those in the frontend. Everything else goes on the server's list.
  4. Add the names to Access-Control-Allow-Headers on the OPTIONS answer. The answer should end up shaped like this:

    What the preflight needs back

    HTTP/1.1 204 No Content
    Access-Control-Allow-Origin: http://localhost:3000
    Access-Control-Allow-Methods: POST
    Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With

    Express, with the cors package

    const cors = require('cors');
    
    app.use(cors({
      origin: 'http://localhost:3000',
      allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
    }));

    Django, settings.py with django-cors-headers

    from corsheaders.defaults import default_headers
    
    CORS_ALLOW_HEADERS = (
        *default_headers,
        "x-api-key",
    )

    Supabase Edge Function, supabase-js 2.95.0 or later (fragment)

    import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors'
    
    // inside your fetch handler, before reading the body
    if (req.method === 'OPTIONS') {
      return Response.json({ ok: true }, { headers: corsHeaders })
    }
  5. Run the same curl command again and check the answer against the next section.

How to tell it is fixed

In the curl output:

  • The status is 2xx (usually 200 or 204).
  • access-control-allow-headers contains every name you passed in Access-Control-Request-Headers, in any letter case.
  • access-control-allow-origin is your page's exact origin, or * for a request sent without credentials.

Then reload the page with the Network tab open: the OPTIONS request should now be followed by your real request. There is no cache to wait out, because the Fetch Standard only stores a preflight result after a successful preflight. If the error comes back naming a different header, that is the next missing name, reported one at a time; add it too. If the message changes to "It does not have HTTP ok status", the server now rejects the OPTIONS request itself, and that case has its own note.

Things that look like fixes but are not

  • Access-Control-Allow-Headers: *. With credentials (credentials: 'include' or withCredentials), * is not a wildcard at all, just a header literally named *, so every custom header still fails. Without credentials, the Fetch Standard says the wildcard never covers Authorization. MDN's compatibility data shows that Chrome and Safari do not enforce that rule yet, and Firefox only behind a preference since version 115, so * with a bearer token can pass today and still go against the standard. List Authorization by name. The cookie side of the wildcard rules is in the note on credentials mode 'include'.
  • Adding OPTIONS to Access-Control-Allow-Methods. That is the accepted answer on the 250,000-view question. The browser checks the real request's method against that list, and the header list separately, so it does not change this error.
  • Switching to form encoding to avoid the preflight. A highly voted answer on the 600,000-view question does this, and Content-Type: application/x-www-form-urlencoded does make that header safelisted again. It only helps if no other unsafe header is left, because Authorization alone still triggers a preflight, and only if your server parses form bodies.
  • mode: 'no-cors'. In that mode, fetch only lets you set safelisted headers, so the token cannot be sent, and the response is opaque: status 0, empty headers and a body your code cannot read. MDN names analytics beacons and loading resources into a service worker cache as the use cases, not API calls whose response you need to read.

Check your preflight values

CORS Doctor compares the headers your request sends with the Access-Control-Allow-Headers value from the preflight, ignoring case, and names every missing header at once instead of one at a time. Fill in the fields rather than pasting only the console message: the message names one header, the fields show every header the preflight left out.

Open CORS Doctor with this note's example filled in: a POST with Authorization, X-Requested-With and a JSON body, answered with Access-Control-Allow-Headers: Content-Type, X-Requested-With. Then replace the values with yours from the curl output. If you pick your stack, it adds a server snippet. For Express, FastAPI, Go, ASP.NET Core, Supabase Edge Functions, Cloudflare Workers and S3, that snippet carries the header names from your request. The Django snippet does not set CORS_ALLOW_HEADERS, so add your extra names there by hand as in step 4 above. The Supabase snippet writes corsHeaders out by hand; on supabase-js 2.95.0 or later, the import from step 4 keeps the list in step with the client. It treats * the way today's browsers do, so the wildcard point above is one to check by hand. The check runs in your browser and does not call your API.

Questions

Why is the header name lowercase in the error when my code sends Authorization?

Chromium lowercases header names before it compares them with the allow-list, and it reports the lowercased name. HTTP field names are case-insensitive, so Authorization, authorization and AUTHORIZATION are the same header, and your Access-Control-Allow-Headers value can use any letter case.

Do I need to list Content-Type or Accept in Access-Control-Allow-Headers?

When the value breaks the safelist rules, for example Content-Type with anything other than application/x-www-form-urlencoded, multipart/form-data or text/plain (so a JSON body needs it), or any safelisted header with a value longer than 128 bytes. MDN and the Fetch Standard list the other rules, such as the characters allowed in Accept-Language and the one form Range may take. MDN also notes that listing safelisted headers anyway lifts those extra restrictions, so adding Content-Type to the list does no harm.

Why does the same request work in Postman or curl?

Preflights are sent by browsers. Postman and curl send only the request you give them, so the check that fails in the browser never happens there. That is also what makes curl useful for this error: you can send the preflight by hand, with the same Origin and Access-Control-Request-* headers, and read the exact answer.

I added the header and now the error names a different one. Did the fix fail?

No. Chrome stops at the first header it cannot find in the allow-list and reports only that one, so a second missing header shows up only after the first is fixed. Compare the whole Access-Control-Request-Headers list from the preflight with your allow-list to fix them all at once.

Can I use Access-Control-Allow-Headers: * instead of a list?

Only for requests without credentials, and not to cover Authorization: the Fetch Standard excludes Authorization from the wildcard, even though MDN's compatibility data shows Chrome and Safari do not enforce that rule yet. With credentials, * is just a literal header name. An explicit list is the version that matches the standard in every browser.

Sources