Notes

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'when the request's credentials mode is 'include'

CORS Doctor

Your frontend calls an API on another origin with cookies, the server may even answer with a 200, and Chrome's console prints this:

Access to fetch at 'https://api.example.com/me' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Two settings met in one request. Your code asked the browser to send credentials: credentials: 'include' on fetch(), or withCredentials: true on XMLHttpRequest (jQuery sets it through xhrFields, axios has its own withCredentials option). In the Fetch Standard, credentials means cookies, TLS client certificates and HTTP authentication entries. The server answered with Access-Control-Allow-Origin: *. For a request with credentials the browser does not accept the wildcard: the header has to name your page's origin exactly, and Access-Control-Allow-Credentials: true has to be there too. Otherwise the browser keeps the response away from your code. A request that needed no preflight has still reached the server with its cookies; only the answer is withheld, which is why a 200 from the server proves nothing here.

The same rule shows up in a few wordings:

  • When the request comes from XMLHttpRequest, Chrome appends: "The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute."
  • When the request needed a preflight, the part after "blocked by CORS policy:" starts with "Response to preflight request doesn't pass access control check:". Then the OPTIONS answer is the one that failed. The 2024 question linked below shows this variant.
  • Firefox writes "Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'".
  • In 2013 Chrome said "Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true", which is the title of the oldest question linked below.

There are two ways out, and one question picks between them: does this particular call need cookies? If it does, the server has to send your exact origin plus Access-Control-Allow-Credentials: true. If it does not, the client should stop sending credentials, and * is fine as it is.

What the browser checks

The Fetch Standard defines the CORS check in a few lines. For any CORS response, it goes like this:

  1. Read Access-Control-Allow-Origin. If it is missing, fail.
  2. If the credentials mode is not include and the value is *, pass. This is the only step where * means "anyone".
  3. Otherwise the value must be the request's origin, byte for byte. With credentials, * lands here and is compared as a one-character string, so it fails.
  4. With credentials, Access-Control-Allow-Credentials must also be exactly true. A missing header fails, and so does True.

The reason is written into the standard's section on credentials: sharing responses and allowing requests with credentials is "rather unsafe", and the server developer has to decide deliberately whether such responses can be shared. Microsoft's ASP.NET Core documentation says what goes wrong otherwise: a website at another domain can send a signed-in user's credentials to the app on the user's behalf without the user's knowledge. A wildcard would answer yes for every website at once, and the standard does not allow that combination.

The two checks a response to a request with cookies has to pass Your code calls fetch with credentials set to include. Step one: the request goes out with Origin http://localhost:3000 and a Cookie header. Step two, check one, Access-Control-Allow-Origin must equal the Origin byte for byte. An asterisk fails, because with cookies it is only a character. http://localhost:3000 with a trailing slash fails, because a serialized origin has no trailing slash. http://localhost:3000 passes. Step three, check two, Access-Control-Allow-Credentials. A missing header fails. True with a capital T fails, because the value is case-sensitive. Lowercase true passes. Step four: your code gets the response; if Access-Control-Allow-Origin comes from an allow-list, also send Vary: Origin. If the request needs a preflight, the OPTIONS answer must pass both checks as well, and only the real request carries the cookies. fetch(url, { credentials: 'include' }) 1 The request carries cookies Origin: http://localhost:3000 Cookie: session=abc123 The answer then has to pass two checks. 2 Check 1: Allow-Origin EQUAL TO ORIGIN, BYTE FOR BYTE * fails With cookies, * is only a character http://localhost:3000 / +1 fails A serialized origin has no trailing slash http://localhost:3000 passes Same scheme, host and port 3 Check 2: Allow-Credentials MUST BE PRESENT, LOWERCASE (header missing) fails The response is withheld from your code T rue fails Case-sensitive: only lowercase true true passes 4 Your code gets the response If Allow-Origin comes from an allow-list, also send Vary: Origin. THE REQUEST NEEDS A PREFLIGHT? The OPTIONS answer must pass both checks too. Only the real request carries the cookies.
A response to a request with credentials passes two checks, in this order. The first compares Access-Control-Allow-Origin with the Origin the browser sent, as plain text: * does not match, and neither does the right origin with one extra slash. The second wants Access-Control-Allow-Credentials with the exact value true. The rows follow the Fetch Standard's CORS check and the table in its section on CORS and credentials.

01

A CORS library on its default, with credentials switched on

A CORS setup that allows every origin sends *, and in some libraries switching on credentials does not change that value; it only adds the second header.

Express with the cors package. Its default configuration has "origin": "*", and the credentials option only adds Access-Control-Allow-Credentials. So the line below sends exactly the pair the browser rejects. Half of the answers on the 2013 question are about this package.

Express: sends * together with credentials

app.use(cors({ credentials: true }));

Express: an explicit list

app.use(cors({
  origin: ['http://localhost:3000', 'https://app.example.com'],
  credentials: true,
}));

With an array, the package sends back the request's origin only when it is in the list, and it adds Vary: Origin on its own.

ASP.NET Core. A policy with AllowAnyOrigin sends *, so it produces this message as soon as the client sends cookies. Adding AllowCredentials to it does not get you further. Microsoft's documentation calls that combination insecure, and in the current source CorsPolicyBuilder.Build() throws an InvalidOperationException ("The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time"), and CorsService rejects such a policy with an ArgumentException. That version ends in an exception on the server, not in this message. Name the origins instead. The URL in WithOrigins must not end with a slash, or the comparison fails and no header is returned. A JSON POST also needs a preflight, and unless you call AllowAnyHeader, ASP.NET Core answers it only with the headers you list, hence WithHeaders below.

ASP.NET Core, based on Microsoft's CORS documentation

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy =>
        policy.WithOrigins("http://localhost:3000")
              .WithMethods("GET", "POST")
              .WithHeaders("Content-Type")
              .AllowCredentials());
});
// ...
app.UseRouting();
app.UseCors("Frontend");   // after UseRouting, before UseAuthorization
app.UseAuthorization();

FastAPI. Its documentation says that none of allow_origins, allow_methods and allow_headers can be ['*'] when allow_credentials=True; all of them must be listed. The trap section below explains why following that rule matters even when the error does not show up.

FastAPI

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type"],
)

02

Laravel's default CORS config meets Sanctum's cookies

Laravel's default config/cors.php has 'allowed_origins' => ['*'] and 'supports_credentials' => false. The CORS layer underneath, fruitcake/php-cors, then answers with * and no credentials header. Sanctum's SPA authentication, on the other hand, tells you to set axios.defaults.withCredentials = true, because it runs on the session cookie. Put the two together, with the SPA on another origin, and its calls to the paths listed in 'paths' end in this error.

Sanctum's documentation gives the fix: publish the file with php artisan config:publish cors, because it is not published by default, and set supports_credentials to true. List your SPA's origin while you are there:

config/cors.php

'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['http://localhost:3000'],
'supports_credentials' => true,

With supports_credentials set to true, php-cors never sends *: its code sets the wildcard only when all origins are allowed and credentials are off. That is what makes the 2024 question below useful. Its config already had 'supports_credentials' => true and a list of origins, and the response still said *. Nobody answered it, but two commenters point elsewhere: a cached config (php artisan config:clear) and the web server overriding the header Laravel set. Which leads to the next cause.

03

Another layer writes its own *

CORS headers can come from a CORS middleware, a hand-written middleware, the web server, a reverse proxy or a CDN. If one of them sends *, fixing another one changes nothing in the browser. In the 2013 question, the author set a specific origin in the Node.js server that served the frontend (the backend's own origin, with a trailing slash), while the response that mattered came from the Django API, which still answered with *. The fix the author added to the question was in the Django API's own CORS settings.

Fix: find out which layer writes the header. Send the same request with curl -i (see below) once to the public URL and once to the app directly, for example on its local port, and compare the Access-Control-Allow-Origin lines. Then keep CORS in one place. If two layers both send the header, Chrome reports a different error, "The 'Access-Control-Allow-Origin' header contains multiple values", which the note on the missing header covers.

04

Credentials on a call that does not need them

The opposite case: the server is a public API or a CDN that answers with * on purpose, and your client sends credentials to it anyway. It usually comes from a global setting meant for your own API, such as axios.defaults.withCredentials = true, which Sanctum's documentation recommends, or a fetch wrapper that adds credentials: 'include' to every call. You cannot change their server, and you do not need to. MDN's client-side fix for this error is to not send credentials:

Client: no credentials for a public API

// fetch: 'omit' sends no cookies and ignores any Set-Cookie
fetch('https://public-api.example.org/data', { credentials: 'omit' });

// axios: a separate instance for third-party calls
const publicApi = axios.create({ withCredentials: false });

Leaving the option out works too: fetch() defaults to same-origin, which sends no cookies to another origin, and XMLHttpRequest.withCredentials defaults to false.

Bearer tokens belong here as well. A token your code puts into an Authorization header is not a credential in the Fetch Standard's sense, it is an ordinary request header, so it does not need credentials: 'include'. It does need to be listed in Access-Control-Allow-Headers by name: MDN notes that the wildcard never covers Authorization.

The fix, step by step

  1. Read what follows "blocked by CORS policy:". If it starts with "Response to preflight request doesn't pass access control check", the OPTIONS answer failed; without that prefix, the actual response did. With credentials, both need the same two headers.
  2. Decide whether this call needs cookies (or HTTP authentication, or a client certificate). If it does not, remove credentials: 'include' or withCredentials for this call, as in cause 04, and you are done.
  3. If it does, send the exact origin. Compare the request's Origin header with a list of your frontends and send that same value back: scheme, host and port, no path and no trailing slash.
  4. Add Access-Control-Allow-Credentials: true, in lowercase, to the preflight answer and to the actual response.
  5. Send Vary: Origin whenever the value depends on the request, so caches keep the answers for different origins apart.
  6. Replace * in the other CORS headers. With credentials, Access-Control-Allow-Methods, Access-Control-Allow-Headers and Access-Control-Expose-Headers need explicit lists too.
  7. Keep one layer in charge of these headers, as in cause 03.

The response your browser needs then looks like this:

A response that passes both checks

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Vary: Origin

How to tell it is fixed

Ask the server directly, with the Origin header your page sends:

Terminal: the actual request

curl -i https://api.example.com/me \
  -H "Origin: http://localhost:3000"

If your request needs a preflight, send that one by hand as well, with the method and header names your code uses:

Terminal: the preflight

curl -i -X OPTIONS https://api.example.com/me \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

On Windows, type curl.exe instead of curl, because in Windows PowerShell 5.1 curl is an alias for Invoke-WebRequest. Put each command on one line, without the backslashes.

  • access-control-allow-origin is exactly http://localhost:3000. Header names are case-insensitive, so lowercase names in curl's output are fine. The values are compared exactly: the origin byte for byte, and true only in lowercase.
  • access-control-allow-credentials: true is on both answers.
  • If the value comes from a list, vary includes Origin.

Then run the first command once more with -H "Origin: https://not-my-site.example". That origin must not come back in Access-Control-Allow-Origin. If it does, the server copies any origin it receives, which is the first trap below.

In the browser, reload with DevTools open on the Network tab. The request headers should show your Cookie, the response headers the two values above, and the console should stay quiet. If Chrome now says "The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'", the origin is fixed and step 4 is still missing.

Traps

  • Echoing back any origin. Several answers on the 2013 question suggest origin: true or an origin callback that always says yes; in the cors package, true reflects the request's origin. With credentials, that lets any website send requests with your users' cookies and read the answers. The accepted answer on the 2017 question warns about exactly this, and Microsoft calls SetIsOriginAllowed(_ => true) with AllowCredentials insecure. Some libraries do it quietly when you combine "allow all" with credentials: django-cors-headers (CORS_ALLOW_ALL_ORIGINS with CORS_ALLOW_CREDENTIALS), php-cors under Laravel (['*'] with supports_credentials), Starlette, whose CORSMiddleware FastAPI uses (allow_origins=['*'] with allow_credentials=True), and the older ASP.NET Web API 2 (new EnableCorsAttribute("*", "*", "*") { SupportsCredentials = true }, or Katana's CorsOptions.AllowAll, which sets both flags) all send back the request's origin in that case. The error goes away and the hole stays. Use a list. The 2017 question used both Web API 2 settings, and neither of them writes * with credentials on; the question does not show where its * came from.
  • A trailing slash in the configured origin. An origin is scheme, host and port, and the Fetch Standard's table marks https://rabbit.invalid/ as a failure even without credentials. The author of the 2013 question fixed it in the end by removing exactly that slash from CORS_ORIGIN_WHITELIST.
  • True or false in Allow-Credentials. The only valid value is lowercase true. MDN advises leaving the header out rather than sending false.
  • * in the other headers. With credentials, * in Access-Control-Allow-Headers is the literal header name *, not a wildcard, and Authorization is never covered by it. The same goes for Allow-Methods and Expose-Headers.
  • CORS headers on the request. In the 2013 question the frontend sent Access-Control-Allow-Credentials, -Headers and -Methods as request headers. They are response headers. On a request they do nothing for CORS, and since they are not CORS-safelisted request headers, each of them forces a preflight.
  • CORS passes, but the cookie still does not travel. Cookie rules are separate from CORS. localhost:3000 calling api.example.com is cross-site, so the cookie needs SameSite=None; Secure, and MDN notes that third-party cookie policies can still block it regardless of the server's CORS setup. app.example.com calling api.example.com is same-site, because subdomains of one registrable domain count as one site, so a Lax cookie goes along. Cookie Doctor checks these attributes for your case.
  • A browser extension or --disable-web-security. The author of the 2013 question had been using the flag, and one answer recommends an extension. Both change only your own browser, and every user still gets the error. An extension can also cause it: one answerer on the 2017 question found that a CORS extension in Chrome had rewritten the header to *.

Check your case

Paste the console message into CORS Doctor and fill in your request and the response headers. Open CORS Doctor with this note's example filled in: a GET with credentials: 'include' from http://localhost:3000, and a response with Access-Control-Allow-Origin: *. It flags the wildcard with credentials, gives the three header values from the fix above, and prints an Express snippet with your origin; pick your own stack in the form for the matching snippet. Because the page and the API are on different hosts in this example, it also points out the SameSite=None; Secure requirement for the cookie. For the cookie attributes themselves, Cookie Doctor goes further. Neither tool can see your server: they check the values you enter, in your browser, and do not call your API.

Questions

Why does the same request work in Postman or curl?

Because CORS is enforced by the browser. Postman and curl do not run the CORS check at all, so whatever headers come back, nothing is withheld. The 2017 question describes exactly that: no error in Postman, a 200 in the browser, and the CORS error anyway.

Why is the session cookie not saved after a login that returned 200?

Most often because the login call itself was sent without credentials. A cross-origin response can set cookies only when the request asked for them: the Fetch Standard says credentials: 'include' makes Set-Cookie fully functional and that it is ignored otherwise, and MDN says the same about withCredentials for XMLHttpRequest. So the login call needs credentials too, and then it needs the exact origin and Access-Control-Allow-Credentials: true like every other call, or your code never sees its result. If the cookie is still missing after that, its SameSite and Secure attributes are the next suspects.

Can one API send * on some routes and an exact origin on others?

Yes. The * is fine for routes that are called without credentials. The documentation of Express's cors package shows this with a per-request options function: an exact origin with credentials for one path, * for the rest.

Do I need credentials: 'include' for a Bearer token?

No. In the Fetch Standard, credentials are cookies, TLS client certificates and HTTP authentication entries. A token you set in the Authorization header yourself is sent like any other request header. The server has to list Authorization in Access-Control-Allow-Headers by name, because the wildcard never covers it.

Is it safe to copy the Origin header into the response?

Only after comparing it with a list of origins you trust. Copying it unchecked, together with Access-Control-Allow-Credentials: true, lets any website make requests with your users' cookies and read the answers. When the value depends on the request, send Vary: Origin as well.

Sources