CORS Explained In Depth
Understand Cross-Origin Resource Sharing: simple vs preflight requests, credentials, and the headers that decide whether your browser will let your fetch succeed.
What you'll learn
- ✓What the Same-Origin Policy enforces
- ✓Simple vs preflighted requests
- ✓The handshake of Origin and Access-Control headers
- ✓How credentials change the rules
- ✓Common CORS mistakes and how to debug them
Prerequisites
- •Familiar with HTTP
What and Why
CORS, Cross-Origin Resource Sharing, is the browser mechanism that lets a page at https://app.example.com call an API at https://api.example.com without trampling on the Same-Origin Policy.
The Same-Origin Policy is the browser’s foundational security boundary. Without it, any page you visited could quietly call your bank’s API using your logged-in cookies. CORS is the controlled escape hatch: the server tells the browser, in HTTP headers, which origins are allowed to read its responses.
Crucially, CORS is enforced by the browser, not the server. curl does not care.
Mental Model
Two origins match if scheme, host, and port are identical. https://a.com and https://a.com:8443 are different origins.
When JavaScript on origin A calls origin B, the browser asks: is this a simple request or a complex one?
- Simple: GET, HEAD, or POST with a safe content type (
text/plain,application/x-www-form-urlencoded, ormultipart/form-data) and no custom headers. - Otherwise: complex. The browser sends a preflight
OPTIONSfirst.
Browser (app.example.com) Server (api.example.com)
| |
| OPTIONS /users |
| Origin: https://app.example.com |
| Access-Control-Request-Method: PUT |
| Access-Control-Request-Headers: ... |
|------------------------------------>|
| |
| 204 No Content |
| Access-Control-Allow-Origin: ... |
| Access-Control-Allow-Methods: PUT |
| Access-Control-Allow-Headers: ... |
|<------------------------------------|
| |
| PUT /users (actual request) |
|------------------------------------>| If the preflight response does not authorize the method, headers, or origin, the browser cancels the real request and your fetch rejects.
Hands-on Example
You have a frontend at https://app.example.com calling https://api.example.com/profile with a JSON body and a PUT. This triggers a preflight.
The server must respond to OPTIONS /profile with:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Then, on the actual PUT:
Access-Control-Allow-Origin: https://app.example.com
If you need to send cookies or Authorization with cross-origin XHR/fetch:
fetch('https://api.example.com/profile', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
The server must add:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com
With credentials, you cannot use Access-Control-Allow-Origin: *. You must echo the specific origin. Also add Vary: Origin so caches do not serve the wrong header to another origin.
Common Pitfalls
Using * with credentials. The browser silently rejects this. Echo the request’s Origin instead.
Forgetting Vary: Origin. CDNs and proxies will cache the response for the first origin they saw and return it to others. Cue mysterious failures.
Treating CORS as authentication. CORS does not authorize anything. It only tells the browser whether the response is readable by JS. Your API still needs auth.
Allowing every origin to read sensitive data. A wide-open API can be read by any logged-in user from any malicious site if cookies are scoped wrong.
Confusing CORS errors with network errors. The fetch promise rejects with a generic TypeError. The real reason is in the browser console.
Stripping headers behind a proxy. Nginx or a CDN sometimes drops Access-Control-* headers. Verify in the browser network panel, not in your application logs.
Practical Tips
- Whitelist origins explicitly. A regex or a config list beats
*once you ship to production. - Set
Access-Control-Max-Age(e.g., 600) so the browser caches the preflight and you do not double every request. - Keep your CORS layer at the edge (gateway or reverse proxy) so every service behaves consistently.
- Test with the actual browser.
curl -H "Origin: ..."shows the response headers, but only the browser enforces them. - For public APIs, allow
*for read-only, anonymous endpoints. Lock down the rest. - Remember subdomains are separate origins.
app.example.comandapi.example.comare different. Usedocument.domainor, better, structure your cookies and CORS rules deliberately.
Wrap-up
CORS feels arcane until you internalize the mental model: browser asks, server answers, headers govern. Most CORS bugs come from one of three places: missing headers, mismatched origins, or credentials misconfigured.
Configure it once at the edge, log preflights during rollout, and you will rarely think about it again.
Related articles
- Web Content Security Policy Explained
A practical tour of CSP: what each directive does, how nonces and hashes work, how to roll out a policy safely with report-only mode, and the mistakes that quietly weaken it.
- Web Cookies vs localStorage vs sessionStorage
Compare the three main browser storage mechanisms by lifecycle, capacity, security, and use case so you pick the right tool for tokens, preferences, and state.
- Web JWT vs Session Authentication
Compare JSON Web Tokens with classic server-side sessions across storage, revocation, scaling, and security so you can choose the right model for your app.
- Web Web Performance: Core Web Vitals Guide
A practical guide to Core Web Vitals: LCP, INP, and CLS. How they are measured, what hurts them, and concrete fixes that move the numbers.