Notes
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response:fixing the preflight allow-list
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:
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,apikeyandx-client-infoon every request andcontent-typewhen there is a body. Supabase's documentation lists more that only appear in specific cases:x-retry-counton automatic retries, andtraceparent,tracestateandbaggageonly when trace propagation is turned on. That is why a list copied from an old example can fall behind. From@supabase/supabase-js2.95.0, Supabase recommends importingcorsHeadersfrom the SDK, so the allow-list follows the client. - Sentry adds
sentry-traceandbaggageto requests that matchtracePropagationTargets. The default matches any URL containinglocalhostand any URL starting with/, so a frontend onlocalhost:5173calling an API onlocalhost:8000sends them as soon asbrowserTracingIntegrationis on, without touchingtracePropagationTargets. Sentry's docs say to allow both on the API, plustraceparentif you turn onpropagateTraceparent. - Your own interceptors: an axios or fetch wrapper that adds
X-Request-Id,X-CSRF-TokenorCache-Controlto 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_HEADERSdefaults toaccept,authorization,content-type,user-agent,x-csrftokenandx-requested-with. Anything else, such asx-api-key, has to be added. - Express with the
corspackage: once you setallowedHeaders, only those names are sent. If you leave the option out, the package reflects whatever the browser asked for inAccess-Control-Request-Headers. - AWS API Gateway (REST API), Enable CORS in the console:
Access-Control-Allow-Headersis a static string, prefilled withContent-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
- Get the full list the browser asked for. In DevTools, open the Network tab, find the
OPTIONSrequest to the same URL and copyAccess-Control-Request-Headersfrom its request headers. If you cannot find it, write the list yourself from the headers your code, your interceptors and your SDKs set. - 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" - Sort the names into two groups. Some should not be sent at all:
Access-Control-*headers, a strayCache-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. - Add the names to
Access-Control-Allow-Headerson theOPTIONSanswer. 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-WithExpress, 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 }) } - 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-headerscontains every name you passed inAccess-Control-Request-Headers, in any letter case.access-control-allow-originis 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'orwithCredentials),*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 coversAuthorization. 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. ListAuthorizationby name. The cookie side of the wildcard rules is in the note on credentials mode 'include'.- Adding
OPTIONStoAccess-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-urlencodeddoes make that header safelisted again. It only helps if no other unsafe header is left, becauseAuthorizationalone 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
- MDN, "Reason: missing token 'xyz' in CORS header 'Access-Control-Allow-Headers' from CORS preflight channel" (Firefox wording, the header is not in the list, fix on the server or stop sending the header): developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSMissingAllowHeaderFromPreflight
- MDN, "Access-Control-Allow-Headers header" (required when the preflight has Access-Control-Request-Headers; * only for requests without credentials; Authorization always listed explicitly; listing safelisted headers lifts the extra restrictions): developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers
- MDN, "CORS-safelisted request header" (Accept, Accept-Language, Content-Language, Content-Type with three values, Range; value length up to 128; allowed characters in Accept-Language and Content-Language, forbidden bytes in Accept and Content-Type, a single byte range for Range): developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
- MDN, "Access-Control-Request-Headers header" (sorted, unique, comma-separated, lowercase names): developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Request-Headers
- MDN, "Preflight request" (sent automatically by the browser): developer.mozilla.org/en-US/docs/Glossary/Preflight_request
- MDN, "CORS errors", client-side considerations (avoid the preflight, no-cors for analytics beacons or loading resources into a cache via a service worker, opaque response with status 0, use a proxy server you control): developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors
- MDN, "RequestInit", mode no-cors (only CORS-safelisted request headers, opaque response): developer.mozilla.org/en-US/docs/Web/API/RequestInit
- MDN browser-compat-data, Access-Control-Allow-Headers, "Authorization header is not covered by wildcard" (Chrome and Safari: no; Firefox 115 behind the preference network.cors_preflight.authorization_covered_by_wildcard): github.com/mdn/browser-compat-data/blob/main/http/headers/Access-Control-Allow-Headers.json
- WHATWG Fetch Standard, "CORS-preflight fetch" (the response needs an ok status, 200 to 299; Access-Control-Request-Headers from the CORS-unsafe request-header names; byte-case-insensitive match against Access-Control-Allow-Headers; * only without credentials; Authorization never covered by *; cache entries created only after a successful preflight): fetch.spec.whatwg.org/#cors-preflight-fetch
- WHATWG Fetch Standard, "CORS-safelisted request-header", "CORS-unsafe request-header names" (sorted-lowercase set) and "CORS non-wildcard request-header name" (Authorization): fetch.spec.whatwg.org/#cors-safelisted-request-header, fetch.spec.whatwg.org/#cors-unsafe-request-header-names, fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name
- Chromium source,
preflight_controller.cc(origin check and ok status first, then the method, then the headers): source.chromium.org/chromium/chromium/src/+/main:services/network/cors/preflight_controller.cc - Chromium source,
preflight_result.cc(allow-list parsed in lowercase, a missing Access-Control-Allow-Headers parsed as an empty list, case-insensitive lookup, returns on the first missing header name): source.chromium.org/chromium/chromium/src/+/main:services/network/cors/preflight_result.cc - Chromium source,
cors_error_string.cc(the "Access to ... at" prefix and "Request header field ... is not allowed by Access-Control-Allow-Headers in preflight response."): source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/loader/cors/cors_error_string.cc - RFC 9110, section 5.1 "Field Names" (case-insensitive): rfc-editor.org/rfc/rfc9110.html#section-5.1
- axios README ("Make XMLHttpRequests from the browser"): github.com/axios/axios
- expressjs/cors README (allowedHeaders; if not specified, reflects Access-Control-Request-Headers): github.com/expressjs/cors
- django-cors-headers README (CORS_ALLOW_HEADERS default and extending default_headers): github.com/adamchainz/django-cors-headers
- Supabase, "CORS support for invoking from the browser" (headers the client libraries send and when, corsHeaders import from 2.95.0, OPTIONS handling): supabase.com/docs/guides/functions/cors
- Sentry, "Dealing with CORS Issues" (sentry-trace and baggage on URLs matching tracePropagationTargets, default localhost and /, allow both on the backend, traceparent with propagateTraceparent): docs.sentry.io/platforms/javascript/tracing/distributed-tracing/dealing-with-cors-issues/
- Sentry, "Distributed Tracing" (works out of the box once the BrowserTracing integration is enabled): docs.sentry.io/platforms/javascript/tracing/distributed-tracing/
- AWS, "Enable CORS on a resource using the API Gateway console" (REST API resources; static Access-Control-Allow-Headers string, console-provided list, deploy or redeploy the API): docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors-console.html
- Next.js, "Environment Variables", bundling for the browser (NEXT_PUBLIC_ values inlined into the client bundle at build time): nextjs.org/docs/app/guides/environment-variables
- curl, "curl.1 the man page" (-i, -X, -H): curl.se/docs/manpage.html
- Stack Overflow, "Request header field Access-Control-Allow-Headers is not allowed by Access-Control-Allow-Headers" (Content-Type first, then Access-Control-Allow-* headers sent from the client): stackoverflow.com/questions/25727306
- Stack Overflow, "Request header field Authorization is not allowed by Access-Control-Allow-Headers in preflight response" (AngularJS and Node, Authorization already in the server code): stackoverflow.com/questions/42061727
- Stack Overflow, Authorization not allowed when calling the Slack API from the browser (Slack's own allow-list, answer from Slack support in 2022): stackoverflow.com/questions/71293668