Your frontend calls an API and the browser reports that the response was blocked by CORS. The common reaction is to install middleware, allow every origin, allow credentials, and declare the problem solved. The request now works, but nobody has explained which boundary changed.
Cross-Origin Resource Sharing is a browser response-reading policy. It tells a browser whether JavaScript from one origin may read a response from another. It is not login, authorization, input validation, or protection against direct HTTP clients.
start with the browser’s same-origin rule
An origin is the combination of scheme, hostname, and port. These are different origins:
https://app.example.com
https://api.example.com
http://app.example.com
https://app.example.com:8443
The browser applies the same-origin policy to page scripts. A curl command, mobile app, backend service, or attacker-controlled server is not stopped by browser CORS enforcement. If an API returns another user’s record without authorization, a strict CORS policy does not repair that bug.
distinguish simple requests from preflights
Some cross-origin requests can be sent directly, although the browser may block the page from reading the response. Requests using methods or headers outside the simple set trigger an OPTIONS preflight first.
OPTIONS /projects/123 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorization
The API answers which origin, method, and headers it permits:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin
The actual request follows only if the browser accepts that response. A successful preflight does not mean the user is authorized to edit project 123; the PATCH handler must still check that.
use an exact origin allowlist
For a Node.js API serving known web applications, compare the Origin header with configured exact values:
const allowedOrigins = new Set([
"https://gethired.dev",
"https://admin.gethired.dev",
]);
function corsOrigin(origin: string | undefined): string | null {
if (!origin) return null;
return allowedOrigins.has(origin) ? origin : null;
}
Do not use origin.endsWith("gethired.dev"); it also matches evilgethired.dev. Parse URLs when supporting controlled subdomains, verify HTTPS and exact hostname boundaries, and avoid reflecting any supplied origin without checking it.
Requests without Origin can be legitimate server-to-server or same-origin traffic. Decide how the endpoint authenticates them instead of treating a missing origin as automatically safe or malicious.
credentials change the response rules
When a browser sends cookies or HTTP authentication cross-origin, the server may return:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
The wildcard * cannot be combined with credentialed browser access. Return the exact approved origin. Vary: Origin prevents a shared cache from serving a response authorized for one origin with headers intended for another.
The frontend must also opt in, for example with fetch(url, { credentials: "include" }). Cookie attributes such as SameSite, Secure, and domain scope remain separate controls.
CORS is not CSRF protection
A cross-origin form may send a state-changing request even when its script cannot read the response. If authentication uses cookies, protect state changes with an appropriate SameSite policy, CSRF tokens or origin checks where required, and normal authorization.
JSON plus custom headers often causes a preflight, which raises the bar for some attacks, but “it preflights” is not a complete CSRF design. Endpoints change, browser behaviors differ, and simple content types still exist.
solve errors by reading the failed exchange
Open the browser network panel and inspect both the OPTIONS request and response. Common failures are:
| Symptom | Likely cause |
|---|---|
| No preflight response | Proxy or router does not handle OPTIONS |
| Method missing | Access-Control-Allow-Methods excludes it |
| Header missing | Requested header not in allowlist |
| Works without cookies | Credential headers or cookie attributes wrong |
| Works locally, fails through CDN | Cached response lacks Vary: Origin |
| Redirect during preflight | HTTP-to-HTTPS or auth middleware intercepts OPTIONS |
Do not disable browser security or install a permissive extension as the fix. Those tools can confirm a CORS diagnosis, but they do not define a production policy.
keep the configuration close to deployment reality
Maintain separate explicit allowlists for local development, previews, and production. Preview domains are tricky: permitting every deployment URL can let untrusted branches become trusted browser origins. Prefer protected previews, short-lived configuration, or a dedicated staging origin.
Handle CORS at one clear layer. If a CDN, reverse proxy, and application all add headers, duplicate values can produce invalid responses. Document which layer owns preflights and test the public URL, not only the local Node.js process.
verify both browser and API security
Automated tests should cover an allowed origin, rejected origin, credentialed request, required custom header, preflight, missing Origin, and cache variation. Separately test authentication and object-level authorization using direct HTTP calls that ignore CORS entirely.
Log rejected origins in a privacy-conscious aggregated form, and alert on configuration failures after deployments. Do not treat random internet origins as attacks merely because they attempted a request; the important signal is whether authentication or authorization boundaries were also challenged.
CORS has one useful job: controlling which browser pages may read cross-origin responses. Configure that job precisely, then let session security, CSRF defenses, validation, rate limits, and authorization do theirs.
When documenting the policy, list each allowed origin and the product that owns it. Include whether credentials are permitted, which layer emits the headers, and how preview environments are handled. This small inventory prevents an old marketing site, abandoned preview domain, or temporary localhost exception from remaining trusted forever. Remove origins as part of application retirement, and verify the public response after every proxy or CDN change.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.