CORS Credentials Error: Why Cookies and Auth Headers Fail Across Origins
Quick answer
💡CORS credentials errors occur when the browser blocks a cross-origin request that includes cookies or an Authorization header. You must set both credentials: 'include' on the client and Access-Control-Allow-Credentials: true on the server. Critically, the server cannot use Access-Control-Allow-Origin: * when credentials are enabled; it must echo the exact requesting origin. Without all three pieces, the browser will block the response.
Error symptoms
- ✕
Access to XMLHttpRequest from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' - ✕
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' - ✕
Cookies are not being sent to the API server even though the browser shows them in Application > Cookies - ✕
Authorization header is missing from the actual request despite being set in fetch() options - ✕
CORS preflight OPTIONS request returns 200 but the actual request fails with a CORS error - ✕
Login flow works on localhost but fails in production where the app and API are on different subdomains
Common causes
- •Server returns Access-Control-Allow-Origin: * which is incompatible with credentialed requests
- •Client omits credentials: 'include' in fetch() or withCredentials: true in XMLHttpRequest or Axios
- •Server sets Access-Control-Allow-Credentials: true but does not echo the exact Origin header value
- •Cookies have SameSite=Strict or SameSite=Lax which prevents them from being sent on cross-site requests
- •The preflight OPTIONS request is not handled on the server, so CORS headers are only present on GET and POST responses
- •The API is hosted on a different subdomain (api.example.com vs app.example.com) and the CORS configuration does not treat them as separate origins
When it happens
- •When the frontend SPA is deployed to a CDN domain and the API is on a separate backend domain
- •During local development when the React dev server runs on port 3000 and the API runs on port 8080
- •After enabling cookie-based session authentication on an API that previously used only Bearer tokens
- •When adding a mobile app that calls the same API from a different origin than the web app
Examples and fixes
The most common mistake: returning Access-Control-Allow-Origin: * while also setting credentials: include on the client.
Wildcard origin incompatible with credentials
❌ Wrong
// Express server - BROKEN
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); // blocks credentials
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
// Client fetch - BROKEN
fetch('https://api.example.com/me', {
credentials: 'include' // conflict with wildcard origin
});✅ Fixed
// Express server - FIXED
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://localhost:3000'
];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // echo exact origin
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Vary', 'Origin'); // required for CDN caching
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});The browser specification explicitly prohibits combining Access-Control-Allow-Origin: * with credentialed requests. When credentials are included, the server must echo the exact Origin header value and explicitly set Access-Control-Allow-Credentials: true. The Vary: Origin header is critical for CDN correctness: without it, a CDN may cache a response with one origin header and serve it to a different origin, causing subsequent requests to fail. The fixed version also handles OPTIONS preflight requests explicitly with a 204 response.
Even with correct CORS headers, cookies set without the right SameSite attribute will not be sent on cross-origin requests.
SameSite cookie attribute blocks cross-site cookie sending
❌ Wrong
// Server sets session cookie without SameSite attribute
res.cookie('session', token, {
httpOnly: true,
secure: true
// SameSite defaults to Lax in modern browsers
// Cross-site POST requests will not include this cookie
});
// Client attempts to send credentials
fetch('https://api.example.com/data', {
method: 'POST',
credentials: 'include' // cookie not sent due to SameSite=Lax on POST✅ Fixed
// Server sets session cookie with SameSite=None for cross-site use
res.cookie('session', token, {
httpOnly: true,
secure: true, // required when SameSite=None
sameSite: 'None' // allows cross-site requests to include the cookie
});
// Client sends credentials
fetch('https://api.example.com/data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'example' })
});Modern browsers default to SameSite=Lax when no SameSite attribute is specified. Lax allows the cookie on cross-site GET navigations but blocks it on cross-site POST, PUT, and DELETE requests. For a cross-origin API that needs to receive cookies, you must set SameSite=None, which requires Secure=true (HTTPS only). This combination is a deliberate security trade-off: the browser will send the cookie cross-site, but only over HTTPS, never plain HTTP. On localhost development, use a proxy to avoid cross-origin requests entirely, or use a self-signed certificate.
Why CORS blocks credentialed cross-origin requests
The Same-Origin Policy (SOP) is a browser security mechanism that prevents scripts on one origin from reading responses from a different origin. CORS (Cross-Origin Resource Sharing) is the controlled relaxation of SOP: a server can tell the browser which origins are allowed to read its responses. When credentials are involved, the relaxation is more restrictive because cookies and Authorization headers can contain authentication state that should never be exposed to untrusted origins.
The browser enforces three simultaneous requirements for credentialed cross-origin requests. First, the client must explicitly opt in by setting credentials: 'include' in fetch() or withCredentials: true in XMLHttpRequest. Second, the server must respond with Access-Control-Allow-Credentials: true. Third, the server must specify an exact origin in Access-Control-Allow-Origin rather than the wildcard *. If any one of these three conditions is missing, the browser blocks the response and the JavaScript code cannot read the response body or status code.
The wildcard restriction exists for a clear security reason. If Access-Control-Allow-Origin: * were permitted with credentials, any website on the internet could make authenticated requests to your API using the visiting user's cookies. For example, a malicious website could embed a fetch() call to your banking API and read the authenticated response if the user is logged in. By requiring an explicit origin, you whitelist specific trusted origins rather than allowing any origin.
Subdomains are separate origins in the browser's Same-Origin Policy. https://app.example.com and https://api.example.com are different origins even though they share the example.com domain. You cannot use document.domain tricks to bridge them for CORS purposes. Each origin must be explicitly listed in your server's CORS whitelist.
Diagnosing CORS credentials failures in the browser
Open the browser DevTools Network panel and find the failing request. Look at two things: the request headers for an Origin header, and the response headers for Access-Control-Allow-Origin, Access-Control-Allow-Credentials, and any error message in the Console panel. The Console will show the exact CORS error message which identifies which specific header is missing or incorrect.
If you see a preflight OPTIONS request before the main request, inspect both. The preflight is the browser's way of asking the server which origins, methods, and headers are allowed before sending the actual request. If the preflight returns a 404 or 405 (Method Not Allowed), the server is not handling OPTIONS routes, and the actual request will never proceed. Ensure your server handles OPTIONS for all CORS-enabled routes.
If the Network panel shows no OPTIONS preflight for a POST request, the request is being treated as a simple request (application/x-www-form-urlencoded or multipart/form-data). If you expect a preflight, check that Content-Type is set to application/json, which triggers a preflight. Simple requests still follow CORS rules for reading the response, but they do not send a preflight first.
For cookie-based authentication, open Application > Cookies and verify the cookie exists in the browser for the correct domain. Then check its SameSite attribute. If it shows Lax and you are making a cross-site POST, the browser will not send the cookie regardless of CORS configuration. If it shows None but Secure is false (HTTP), modern browsers (Chrome 80+, Firefox 79+) will reject the cookie entirely. Use the DevTools Issues panel which often identifies SameSite problems automatically with a clear explanation and remediation suggestion.
How to fix CORS credentials errors correctly
The minimal correct CORS configuration for credentialed requests requires three server-side headers: Access-Control-Allow-Origin set to the exact requesting origin (not wildcard), Access-Control-Allow-Credentials set to true, and Vary: Origin to ensure CDN caches do not serve a response with one origin header to requests from a different origin. If you skip Vary: Origin, a CDN may cache a response for https://app.example.com and serve it to https://staging.example.com, causing that request to fail with a CORS mismatch.
Maintain a whitelist of allowed origins and echo the incoming Origin header only if it is in the whitelist. Do not reflect the Origin header unconditionally, as this effectively makes any origin allowed, which is equivalent to the wildcard. For example: if (ALLOWED_ORIGINS.includes(req.headers.origin)) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin); }.
For preflight handling, respond to OPTIONS requests with a 204 No Content status and include the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers. The Access-Control-Allow-Headers value must include every custom header the client sends, including Authorization and Content-Type. Missing a single header from this list will cause the preflight to fail and the actual request to be blocked. Set Access-Control-Max-Age to a reasonable value (e.g., 86400 seconds) to allow the browser to cache the preflight result and avoid an OPTIONS round trip on every request.
Security consideration per OWASP: never add request origins to your whitelist dynamically based on patterns or substring matching without careful validation. An attacker could register a domain like eviltargetdomain.example.com that matches a substring pattern meant to allow api.example.com. Always match the full origin string against a static list, and log any requests with an unrecognized Origin header for monitoring purposes.
Edge cases in credentialed CORS requests
Localhost during development is a persistent source of confusion. https://localhost:3000 and http://localhost:3000 are different origins. http://localhost:3000 and http://127.0.0.1:3000 are also different origins. Add all variants you use during development to your allowed origins list. Alternatively, use a reverse proxy (nginx, Vite proxy, Create React App proxy) to serve the API from the same origin as the frontend, eliminating CORS entirely for development.
The Fetch API distinguishes between credentials: 'same-origin' (the default), credentials: 'include', and credentials: 'omit'. The default same-origin only sends cookies when the request is to the same origin as the page. A common bug is expecting cookies to be sent on a cross-origin request without explicitly setting credentials: 'include'. Axios uses withCredentials: false by default; you must set withCredentials: true either per-request or globally via axios.defaults.withCredentials = true.
HTTPS is required for SameSite=None cookies and is a practical requirement for any production credentialed CORS setup. When deploying to production, ensure your API uses HTTPS and that the Set-Cookie header includes the Secure attribute. Without Secure, Chrome 80+ and Firefox 79+ will reject SameSite=None cookies. This means a server that previously worked over HTTP in testing will fail in production over HTTPS unless the cookie configuration is updated.
For mobile app clients (React Native, Flutter) making requests to a web API, CORS is not enforced because CORS is a browser mechanism. Native HTTP clients are not bound by the Same-Origin Policy. If you see CORS errors from a React Native app, it is likely running in a web browser debug session (Expo Web), not as a native app. Native app requests still need proper server-side authentication and authorization, but not CORS headers.
Most common CORS credential configuration mistakes
The most common mistake is setting Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true simultaneously, then wondering why the browser still blocks the request. The browser specification explicitly forbids this combination. The fix is always to replace the wildcard with the exact origin. There is no workaround or browser flag that allows both.
A second common mistake is configuring CORS only on the application routes but not on the authentication routes (/login, /logout, /token/refresh). This causes the login request itself to fail, which is immediately visible, but the token refresh endpoint failure is often not noticed until a user's session expires and the background refresh silently fails, causing sporadic 401 errors that are hard to trace.
Forget to add the Vary: Origin header is a mistake that causes subtle intermittent failures in production. Without Vary: Origin, a CDN such as CloudFront or Fastly may cache a CORS response and serve the cached version (with the wrong Access-Control-Allow-Origin value) to a different origin. Requests from the second origin will then fail with a CORS mismatch even though the server configuration is correct. Add Vary: Origin whenever you echo the Origin header in CORS responses.
Another mistake is setting Access-Control-Allow-Headers: * (wildcard). Unlike Access-Control-Allow-Origin, the wildcard for Allow-Headers is supported by modern browsers. However, older browsers (Chrome < 63) do not support it. If you need to support older browsers, enumerate the allowed headers explicitly. Also note that the Authorization header is never sent automatically in preflight by some older proxies; enumerate it explicitly in Access-Control-Allow-Headers.
Security best practices for CORS with credentials
Treat your CORS allowed origins list as a security boundary equivalent to a firewall rule. Maintain it in application configuration or environment variables, not hardcoded in middleware. Review it during each deployment to ensure no stale development or staging origins remain in the production list. OWASP lists permissive CORS configuration as a common web application vulnerability (A05:2021 Security Misconfiguration).
For APIs that serve both a browser SPA and a mobile app or server-to-server clients, use different authentication paths. The browser SPA uses cookie-based sessions with CORS; mobile and server clients use Bearer tokens in the Authorization header without cookies. This avoids applying the CORS credentials mechanism to traffic that does not need it. Server-to-server requests from your own backend do not need CORS at all.
For the SameSite cookie attribute, use SameSite=Lax for cookies that do not need to be sent on cross-site requests (most session cookies for single-domain apps). Use SameSite=None; Secure only for cookies that genuinely need to be sent cross-site. Avoid SameSite=Strict for authentication cookies on apps with multiple subdomains, because Strict blocks cookies even on top-level navigations from external links.
Monitor for CORS policy violations in production. Your application logs should record any request with an Origin that is not in the whitelist. A spike in these log entries may indicate a cross-site scripting probe or an attacker attempting to enumerate your CORS policy. Consider rate-limiting preflight OPTIONS requests from unknown origins and returning 403 rather than 200 for origins that are not whitelisted. This makes your CORS policy harder to probe programmatically.
Quick fix checklist
- ✓Confirm credentials: 'include' is set in fetch() or withCredentials: true in Axios for every credentialed request
- ✓Replace Access-Control-Allow-Origin: * with the exact requesting origin echoed from a whitelist
- ✓Set Access-Control-Allow-Credentials: true on all credentialed response paths including preflight
- ✓Add Vary: Origin to all CORS responses to prevent CDN caching the wrong origin value
- ✓Handle OPTIONS preflight requests explicitly with a 204 response on all CORS-enabled routes
- ✓Set session cookies with SameSite=None; Secure for cross-site use, or SameSite=Lax for same-site
- ✓Test from the exact origin domain used in production, not just localhost
- ✓Review the allowed origins list before each production deployment for stale development entries
Related guides
Frequently asked questions
Why can I not use Access-Control-Allow-Origin: * with credentials?
The browser specification explicitly prohibits this combination because it would allow any website to make authenticated requests using the visiting user's cookies. If a malicious site could read credentialed responses from your API, it could steal session data or perform actions as the logged-in user. The server must echo the exact trusted origin to prove it has explicitly authorized that specific origin.
What is the difference between credentials: 'include' and credentials: 'same-origin'?
The default fetch() credential mode is 'same-origin', which sends cookies only when the request is to the same origin as the current page. The 'include' mode sends cookies on cross-origin requests as well. Use 'include' only when your API is on a different origin from your frontend. Use 'omit' to explicitly suppress cookies for all requests.
Do I need CORS headers for server-to-server API calls?
No. CORS is a browser security mechanism. Server-to-server HTTP calls (Node.js fetch, curl, Python requests) are not bound by the Same-Origin Policy and will not be blocked by a missing CORS header. CORS headers are only enforced by browser JavaScript. Server-to-server calls need proper authorization (API keys, bearer tokens) but not CORS configuration.
Why does my CORS configuration work in development but fail in production?
The most common causes are: the production allowed origins list does not include the production frontend domain, SameSite=None cookies require HTTPS which is not active in some dev setups, or a CDN is caching a CORS response without Vary: Origin and serving it to different origins. Check all three before deploying. Also ensure preflight OPTIONS responses are not being cached by an intermediate proxy with a long TTL.
What should SameSite be set to for cross-site cookies?
Set SameSite=None and Secure=true for cookies that must be sent on cross-site requests. SameSite=None requires HTTPS. Without Secure, Chrome 80+ and Firefox 79+ reject the cookie entirely. For most session cookies that do not need cross-site access, SameSite=Lax is the safe default and prevents CSRF on form submissions while allowing normal same-site navigation.
How do I handle CORS in an Express.js application?
Use the cors npm package with an origin option that accepts a function or array. Pass a function that checks the incoming Origin header against your whitelist and calls callback(null, true) for allowed origins and callback(new Error('Not allowed')) for denied ones. Set credentials: true in the cors options. Always add a Vary: Origin header via the cors package's preflightContinue option or manually.
Why does the browser send an OPTIONS request before my POST?
The browser sends an OPTIONS preflight before cross-origin requests that use non-simple methods (anything other than GET, HEAD, POST with simple content types) or include custom headers like Authorization or Content-Type: application/json. The preflight checks whether the server permits the actual request. If the server does not handle OPTIONS or returns the wrong headers, the actual request is never sent.
Can I use CORS with HTTP (not HTTPS) in production?
You can technically configure CORS over HTTP, but SameSite=None cookies require HTTPS, and browsers increasingly mark HTTP origins as insecure. In practice, any production application using cookies or session authentication across origins must use HTTPS. Use a reverse proxy like nginx or a platform like Heroku that terminates TLS and forwards requests to your HTTP application if you cannot run HTTPS natively.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.