Skip to content

CORS with credentials: cookies, credentials: 'include' and the wildcard

Why cross-origin cookies need credentials: 'include', Access-Control-Allow-Credentials and an exact origin, how SameSite affects them, and why Vary matters.

The ProxifyEdge team 5 min read

CORS works fine until you need cookies. You add credentials: 'include' so your session cookie reaches the API, and the request that worked a minute ago fails with a message about the wildcard *. Or the request succeeds, but the cookie is never sent. CORS with credentials has its own rules on both the browser side and the server side, and a separate set of cookie rules on top. This guide covers all three.

What counts as credentials

In the Fetch standard, “credentials” means three things:

  • Cookies, the common case.
  • HTTP authentication entries the browser remembers (Basic or Digest auth).
  • TLS client certificates.

An Authorization: Bearer … header you set yourself is not a credential in this sense. It’s an ordinary request header: it triggers a preflight and must be listed in Access-Control-Allow-Headers, but it doesn’t require Access-Control-Allow-Credentials. A lot of confusion disappears once you separate “my token” from “the browser’s credentials.”

The client side: credentials: ‘include’

By default, fetch uses credentials: 'same-origin': cookies are sent to your own origin and withheld from every other one. To send them cross-origin, opt in:

const response = await fetch('https://api.example.com/v1/me', {
  credentials: 'include',
});

With XMLHttpRequest, the equivalent is xhr.withCredentials = true; with Axios, withCredentials: true.

Opting in on the client only asks for permission. The browser sends the cookies, but it only lets your script read the response if the server agrees, as follows.

The server side: three requirements

For a credentialed cross-origin request, the response must include:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

1. An exact origin, never *

The wildcard is forbidden with credentials. If the server sends Access-Control-Allow-Origin: *, Chrome reports:

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'.

The same rule applies to the other wildcard positions. With credentials, * in Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers is treated as a literal header or method named *, not as “everything.” You must list values explicitly.

The rule exists for a good reason. * says “any site may read this.” Combined with cookies, that would let any site read data belonging to whoever is logged in.

2. Access-Control-Allow-Credentials: true

Without it, Chrome reports that the header’s value “is ” which must be ‘true’ when the request’s credentials mode is ‘include’.” The header must also be on the preflight response if there is one.

3. Vary: Origin

When the server reflects the requesting origin from an allow-list, the response differs per origin. Without Vary: Origin, a shared cache or CDN can serve the response generated for https://app.example.com to a request from https://admin.example.com, carrying the wrong Access-Control-Allow-Origin value and breaking that site at random.

Here is a minimal, correct server implementation:

const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);

function cors(req, res, next) {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin');
  if (origin && allowed.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    res.statusCode = 204;
    return res.end();
  }
  next();
}

Never replace the allow-list with “reflect whatever Origin arrives.” With credentials enabled, that’s equivalent to letting every website on the internet act as your logged-in users.

Even with perfect CORS headers, the browser may decline to send a cookie, or decline to store one the server sets. That’s governed by cookie attributes and browser privacy settings, not by CORS.

SameSite

The SameSite attribute decides whether a cookie is sent on cross-site requests:

SameSite valueSent on cross-site fetch?
StrictNo
Lax (the default in Chromium when unset)No, except top-level navigations
None (requires Secure)Yes, subject to third-party cookie settings

Notice the word site, not origin. app.example.com and api.example.com are different origins but the same site, because they share the registrable domain example.com. Requests between them are same-site, so SameSite=Lax cookies are sent. Most “my cookie isn’t sent” problems disappear if you host the API on a subdomain of your app’s domain.

If your frontend and API truly live on different sites, the cookie needs SameSite=None; Secure:

Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=None

A cookie sent to a different site from the page is a third-party cookie, and browsers increasingly restrict those. Safari blocks them by default, and Firefox partitions them per top-level site. Even SameSite=None doesn’t guarantee delivery. For a cross-site API, token-based authentication in a header is far more reliable than cookies.

Storing cookies from the response

The same applies in reverse. For the browser to store a Set-Cookie from a cross-origin response, the request must use credentials: 'include', the response must pass the credentialed CORS checks, and the cookie must satisfy SameSite and third-party rules. If any of these fails, the cookie is silently dropped.

Credentials and the preflight

A credentialed request that also needs a preflight, such as a JSON POST, involves two responses, and both must pass. The preflight itself never carries cookies, so the server can’t authenticate it and must answer it before any session check runs. It still has to say yes to credentials:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Vary: Origin

A common bug is a CORS middleware configured for credentials on normal responses but answering OPTIONS from a different code path that omits Access-Control-Allow-Credentials. The browser then rejects the preflight and never sends the real request, even though the real handler is configured correctly.

Debugging checklist

When a credentialed request misbehaves, check these in order:

  1. Does the request use credentials: 'include' (or withCredentials)?
  2. Does the response, and the preflight response, include the exact origin, not *?
  3. Is Access-Control-Allow-Credentials: true present on both?
  4. Is the cookie Secure and SameSite=None if the request is cross-site?
  5. Is the browser blocking third-party cookies? Test in a fresh profile.
  6. In DevTools, open Application → Cookies and check whether the cookie was stored at all, and the request’s Cookies tab to see whether it was sent.

The Network and console details are covered in debugging CORS errors in DevTools.

When credentials are the wrong tool

Cookies make sense for your own API on your own site. They’re the wrong choice for calling a third-party API from the browser. You don’t control its cookie attributes, and you can’t log your users into someone else’s service anyway.

That case usually needs a token, and the token usually shouldn’t be in the browser. ProxifyEdge deliberately doesn’t carry cookies: it never forwards Cookie or its own credentials upstream and doesn’t relay upstream Set-Cookie, since those cookies would be scoped to the proxy’s domain. For upstream authentication, store the token in the secrets vault and reference it as {{secret.NAME}} in the X-Proxify-Upstream-Authorization header. ProxifyEdge substitutes it on the server, only for the hosts you allow. The headers reference lists exactly what is and isn’t forwarded.

Key takeaways

  • credentials: 'include' asks to send cookies cross-origin. The server must answer with an exact origin and Access-Control-Allow-Credentials: true, on the preflight too.
  • With credentials, * stops being a wildcard everywhere. List origins, methods and headers explicitly.
  • Send Vary: Origin whenever the allowed origin is reflected from a list.
  • SameSite and third-party cookie blocking decide whether a cookie is sent at all. Subdomains of the same site avoid most of the trouble.
  • A bearer token in a header isn’t a CORS “credential”; for third-party APIs it’s usually the better choice, kept server-side.

For the basics, see What is CORS?; for the request the browser sends first, see CORS preflight requests explained.

Are you sure?