Quick answer: Fix JSON API CORS failures by treating CORS as a browser response-sharing protocol: allow only intended origins, align credentials on both client and server, let unauthenticated
OPTIONSprobes reach CORS handling, declare the requested methods and headers, attach CORS headers to error responses, vary dynamic responses byOrigin, expose needed response headers, and test in a real browser.
A JSON endpoint can return 200 OK to curl and still be unreadable from frontend JavaScript. The body is not necessarily broken. The browser may be enforcing the same-origin policy because the API did not grant the page's exact origin permission to read the response. These seven mistakes turn that broad “CORS error” into a testable checklist.
For the client-side request and parsing path, keep the Fetch JSON in JavaScript guide nearby. For content negotiation, see the guide to JSON Content-Type and MIME types.
Inspect the response body in the JSON Viewer. Browser CORS rules still need to be diagnosed from the request, response headers, and server configuration.
1. Are you treating CORS as API authentication?
CORS is a browser-enforced response-sharing protocol, not caller authentication. It decides whether JavaScript from one origin may read another origin's response. It does not identify a user, replace API keys, or guarantee that a request never reaches the server, so every endpoint still needs ordinary authentication, authorization, and input validation.
The browser's same-origin policy restricts script access across origins. CORS selectively relaxes that restriction through HTTP response headers. An origin is the combination of scheme, host, and port, which means these are different origins:
https://app.example.comhttp://app.example.comhttps://app.example.com:8443https://api.example.com
Allowing https://app.example.com says nothing about who is signed in. The API must still verify a session, bearer token, or other credential and enforce resource-level permissions. Conversely, rejecting a CORS read does not make a public endpoint private: curl, a mobile application, or another server can call it directly.
CORS also is not a complete cross-site request forgery defense. Some cross-origin requests can be sent even when their responses cannot be read. Cookie-authenticated state changes need an explicit CSRF design, suitable SameSite cookies, and origin or token checks appropriate to the application. The JSON security practices guide covers those adjacent controls.
For historical background, Cross-Origin Resource Sharing developed as a controlled way to relax the same-origin policy. The authoritative behavior now lives in the WHATWG Fetch standard, not in a framework's middleware defaults.
2. Do wildcard origins conflict with credentials?
Do not combine credentialed browser requests with Access-Control-Allow-Origin: *. When fetch uses credentials: "include", the API must return the caller's exact allowed origin plus Access-Control-Allow-Credentials: true. Validate the origin against a fixed allowlist; never reflect arbitrary input merely to make an error disappear.
The Fetch standard's credential table marks wildcard origin plus included credentials as a failure. The matching response for an approved application is explicit:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
The client must also opt in when cross-origin cookies or HTTP authentication are required:
const response = await fetch("https://api.example.com/account", {
credentials: "include"
});
Both sides matter. Adding Access-Control-Allow-Credentials: true without an explicit allowed origin does not work. Returning an explicit origin without setting the fetch credentials mode does not make a cross-origin cookie appear. Browser third-party cookie rules may still block the cookie even when the CORS exchange is otherwise valid.
Avoid a regular expression such as \.example\.com unless it is anchored and parses origins safely; https://app.example.com.attacker.test contains that substring. Prefer an exact set of normalized origins. Do not allow the literal null origin by default, because sandboxed documents and some local schemes can serialize that way.
For a public, non-credentialed API, Access-Control-Allow-Origin: * can be a clear policy. The mistake is not the wildcard itself; it is using it for personalized data or assuming it works with browser credentials.
3. Does authentication or routing reject the OPTIONS preflight?
Let CORS handling answer valid OPTIONS preflight requests before authentication middleware demands user credentials. A preflight contains the proposed origin, method, and header names but normally no user credentials. Return a successful empty response with the required access-control headers; otherwise, the browser will not send the actual JSON request.
A cross-origin POST with Content-Type: application/json, an Authorization header, or a non-safelisted method usually triggers a preflight. The browser creates it automatically:
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
The MDN preflight reference explains that this probe uses OPTIONS and a separate browser-managed preflight cache. A suitable response could be:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin
Common failures occur before application routing: a gateway returns 405, authentication middleware returns 401, a redirect sends OPTIONS to a login page, or a serverless platform handles only declared verbs. Inspect the OPTIONS row in the browser Network panel. If the actual POST is absent, solve the probe first.
Do not authorize the business operation during preflight. The probe grants the browser permission to attempt a request; the actual POST must still authenticate and authorize the user. Keep Access-Control-Max-Age conservative while changing policies, because a cached preflight can hide a fix or rollback until its lifetime expires.
4. Do allowed methods and headers match the browser's request?
Read Access-Control-Request-Method and Access-Control-Request-Headers from the preflight, then ensure the response explicitly permits every intended value. A missing PATCH, Authorization, or Content-Type is enough to fail. Configure the policy from the API contract and reject unapproved values rather than echoing every requested method or header.
This failure often appears after a harmless-looking frontend change. Adding Authorization, X-Request-ID, or Content-Type: application/json changes the preflight. Allowing GET, POST does not permit PATCH; allowing Content-Type does not automatically permit Authorization.
The current MDN CORS error index lists distinct diagnostics for a missing requested header, an unrecognized method, multiple origin headers, and invalid tokens. Use the console's exact reason instead of repeatedly broadening the policy.
A compact contract table makes reviews easier:
| Endpoint | Browser methods | Request headers | Credentials |
|---|---|---|---|
/catalog | GET | Accept | No |
/orders | POST | Content-Type, Authorization | Yes |
/orders/:id | PATCH | Content-Type, Authorization, If-Match | Yes |
Do not add Access-Control-Allow-Origin to the request. It is a response header controlled by the API. Adding it in JavaScript does not grant permission and can create another non-safelisted header for the preflight to evaluate.
If a framework generates the headers, test its output rather than trusting a configuration object. Header name matching is ASCII case-insensitive, but middleware, proxies, and configuration parsers can still mishandle lists or overwrite one another.
5. Do redirects and error responses lose CORS headers?
Apply the CORS policy to every response path the browser can receive, including 401, 403, 404, 429, 500, gateway failures, and redirects. If only successful JSON responses carry access-control headers, JavaScript sees a generic network-style failure and cannot read the structured error needed to diagnose authentication, throttling, or routing problems.
Consider an expired session. The application redirects /api/account to an HTML login page on another host. The browser reports a CORS failure, while developers search for malformed JSON. APIs should generally return a direct JSON 401 with the same applicable CORS headers instead of redirecting programmatic clients.
Install CORS handling early enough in the response pipeline that it runs for errors. Then keep error bodies consistent with the JSON API error response format. This lets the frontend distinguish an HTTP problem from a policy problem:
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
{"type":"https://api.example.com/problems/session-expired","status":401,"title":"Session expired"}
A reverse proxy or CDN can generate failures before the application runs. Configure and test those layers too. Never expose stack traces merely because the frontend needs a readable error; CORS permission and safe error content are separate decisions.
Redirect behavior has interoperability history and can be difficult to diagnose across clients. Prefer stable API URLs, avoid redirecting preflight routes, and test the final production hostname rather than assuming a development proxy represents the deployed path.
6. Do dynamic origins and custom response headers handle caches correctly?
When the API chooses an explicit allowed origin dynamically, send Vary: Origin so shared caches keep those representations separate. Also list non-safelisted response headers that JavaScript must read in Access-Control-Expose-Headers. These solve different problems: Vary protects cache selection, while exposure controls script visibility after access is granted.
The MDN Access-Control-Allow-Origin reference recommends Vary: Origin when a server returns different explicit origins. Without it, a CDN might cache a response carrying Access-Control-Allow-Origin: https://app-a.example and serve it to App B, producing a failure or, under a flawed policy, an unintended disclosure.
This is an extension of the cache-key problem described in JSON API caching mistakes. If Origin changes response headers, it changes the representation a shared cache must select.
Header exposure is separate. JavaScript can read safelisted response headers automatically, but not arbitrary operational headers. To let a client inspect a request identifier, rate-limit metadata, or an ETag, name it:
Access-Control-Expose-Headers: X-Request-ID, RateLimit, ETag
Do not confuse “expose” with “allow.” Access-Control-Allow-Headers approves request headers the browser wants to send. Access-Control-Expose-Headers makes selected response headers readable to JavaScript. Neither substitutes for Access-Control-Allow-Origin.
Test cache behavior with two allowed origins and one denied origin through the real CDN. Inspect both the CORS headers and the response body. A cache hit is not evidence of correctness unless it preserved the intended origin and authorization boundaries.
7. Are you testing only with curl, Postman, or no-cors mode?
Use a real browser page from the production-like origin to validate CORS. Curl and Postman can confirm HTTP behavior but do not enforce the browser's same-origin checks. mode: "no-cors" is not a diagnostic bypass: it produces an opaque response that JavaScript cannot inspect, so it cannot deliver usable JSON data.
Start with the browser Network panel and preserve the failing request. Record:
- the page origin, including scheme and port;
- whether an
OPTIONSrequest occurred; - the preflight request and response headers;
- the actual response status and redirect chain, if it was sent;
- the console's specific CORS reason.
Then reproduce the preflight deliberately at the HTTP layer:
curl -i -X OPTIONS 'https://api.example.com/orders' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'
This curl command is useful because it shows raw headers, not because curl will enforce the result. Compare the response with what the browser asked for. Test exact origins: http://localhost:3000 is different from http://127.0.0.1:3000, and a preview deployment has a different host from production.
Automate a matrix covering allowed and denied origins, credentialed and anonymous calls, every browser-used method, error statuses, and exposed headers. Include the deployed gateway and CDN because local framework tests cannot reveal header stripping, redirects, or cache reuse.
Finally, validate the response body only after CORS succeeds. A browser often surfaces CORS as TypeError: Failed to fetch, so there may be no readable body for response.json() to parse. Separate transport permission, HTTP status, content type, and JSON syntax into four checks; collapsing them into “the API is broken” makes each incident slower.
These seven checks provide a bounded diagnosis: define CORS as response sharing, align credential rules, answer preflight, match methods and headers, preserve policy on every response, configure caches and exposed headers, and verify the whole path in a browser. The narrowest correct policy is easier to test and safer to maintain than a wildcard added during an outage.