Understanding CORS (Cross-Origin Resource Sharing): The Complete Guide
Introduction
If you've worked with web APIs for any length of time, you've almost certainly run into this dreaded message in your browser console:
Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
This is CORS — Cross-Origin Resource Sharing — and it's one of the most misunderstood mechanisms in web development. Developers often treat it as an annoying obstacle to "turn off," when in reality it's a critical security feature protecting users every single day. In this guide, we'll break down what CORS is, why it exists, how it works under the hood at the protocol level, how to implement it correctly across different stacks, how to debug it like a professional, and the security pitfalls that trip up even experienced developers.
By the end, you won't just know how to "fix" a CORS error — you'll understand exactly why it happened and how to prevent it in production systems.
Part 1: A Brief History — Why the Web Needed CORS
Before CORS existed (formalized as a W3C standard around 2014), the web operated almost entirely under the Same-Origin Policy with no legitimate way to relax it. If you wanted your frontend on app.com to talk to an API on api.com, you were stuck with hacks like:
- JSONP (JSON with Padding): Abusing the fact that
<script>tags aren't subject to SOP, by having the server wrap JSON data in a callback function. This only worked for GET requests and had serious security implications, since it required trusting the remote server completely. - Server-side proxies: Having your own backend fetch data from the third-party API and pass it along, since server-to-server calls aren't restricted by browser SOP.
- Flash/Silverlight cross-domain policy files: Legacy, plugin-based hacks that don't exist anymore.
CORS was introduced specifically to give browsers a safe, standardized way to allow cross-origin requests, replacing these fragile workarounds with an explicit permission system controlled entirely by HTTP headers.
Part 2: Why CORS Exists — The Same-Origin Policy
To understand CORS, you first need to understand the Same-Origin Policy (SOP), a security rule built into every web browser since the mid-1990s.
Two URLs share the same "origin" only if they match on all three of these:
- Protocol (http vs https)
- Domain (example.com)
- Port (80, 443, 3000, etc.)
| URL (relative to https://example.com:443) | Same origin? | Why |
|---|---|---|
| https://example.com/page2 | Yes | Path doesn't matter |
| http://example.com | No | Different protocol |
| https://api.example.com | No | Different subdomain |
| https://example.com:8080 | No | Different port |
| https://EXAMPLE.com | Yes | Domain matching is case-insensitive |
By default, browsers block JavaScript running on one origin from reading responses from a different origin. This is what stops a malicious script on evil.com from silently reading your bank balance from yourbank.com while you're logged in with an active session cookie.
Without SOP, any website you visited could make authenticated requests to any other site using your browser's stored cookies, and freely read the response. That would be catastrophic for web security.
CORS is the controlled, opt-in relaxation of this same-origin policy — a way for a server to explicitly say, "It's okay, I trust requests coming from this other origin, and here's exactly what I'll allow."
Part 3: What CORS Actually Is (and Isn't)
CORS is an HTTP-header-based mechanism that allows a server to specify who (which origins) is allowed to access its resources, and how (which methods, headers, credentials).
Here's the part that trips up almost everyone: CORS is enforced by the browser, not the server. The server just sends headers indicating what's allowed; it's the browser's job to inspect those headers and decide whether to expose the response to the calling JavaScript.
This leads to a very common point of confusion: a cross-origin request often does reach the server, and the server does process it and send back a full response — but the browser silently blocks the frontend JavaScript from reading that response if the headers don't match up. This is why:
- You can see the request succeed (status 200) in the Network tab, yet your
fetch()promise still rejects with a CORS error. - A POST request to create a database record can actually go through and create the record, even though your frontend shows an error — because the browser blocked reading the response, not sending the request.
This matters a lot for non-idempotent operations (payments, form submissions) — a "failed" CORS request on the frontend might have still succeeded on the backend.
It's also worth stating clearly: CORS provides zero protection for server-to-server communication. Tools like Postman, curl, or one backend service calling another aren't subject to SOP because there's no browser and no JavaScript execution context involved.
Part 4: Types of CORS Requests
1. Simple Requests
A request qualifies as "simple" if it meets all of these conditions:
- Method is GET, POST, or HEAD
- Only "CORS-safelisted" headers are used (Accept, Accept-Language, Content-Language, Content-Type)
- Content-Type is limited to application/x-www-form-urlencoded, multipart/form-data, or text/plain
- No ReadableStream used in the request
For simple requests, the browser sends the request directly — no preflight — and just checks the response headers afterward to decide whether to expose the returned data to JavaScript.
2. Preflighted Requests
Anything that doesn't qualify as "simple" — like a PUT request, a DELETE request, or a POST with Content-Type: application/json — triggers a preflight request.
Here's what happens step by step:
- Before sending the actual request, the browser automatically sends an OPTIONS request to the server.
- This OPTIONS request asks: "If I send this real request with these headers and this method, will you allow it?"
- The server responds with headers describing exactly what's permitted.
- Only if approved does the browser proceed to send the actual request.
- The browser may cache this approval for a duration set by Access-Control-Max-Age.
A preflight request looks like this:
OPTIONS /api/data HTTP/1.1 Origin: https://myapp.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: Content-Type, Authorization
And a valid server response:
HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://myapp.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 86400
A subtlety many developers miss: your server-side route handler still needs to explicitly respond to OPTIONS requests. If your framework only defines handlers for GET/POST/PUT and has no logic for OPTIONS, the preflight fails — even if your actual handler has correct CORS headers.
3. Requests with Credentials
If a request needs to include cookies, HTTP authentication, or client-side TLS certificates, it's a credentialed request. These come with stricter rules, covered below.
Part 5: The Key CORS Headers Explained
| Header | Direction | Purpose |
|---|---|---|
| Origin | Request | Sent automatically by the browser, indicates where the request originates |
| Access-Control-Allow-Origin | Response | Specifies which origin(s) can access the resource |
| Access-Control-Allow-Methods | Response | Lists HTTP methods allowed |
| Access-Control-Allow-Headers | Response | Lists which request headers are permitted |
| Access-Control-Allow-Credentials | Response | Set to true to allow cookies/auth headers cross-origin |
| Access-Control-Expose-Headers | Response | Lists which response headers JavaScript can read |
| Access-Control-Max-Age | Response | How long the browser can cache the preflight response |
| Access-Control-Request-Method | Request (preflight) | Tells the server which method the real request will use |
| Access-Control-Request-Headers | Request (preflight) | Tells the server which custom headers the real request will include |
A commonly overlooked header is Access-Control-Expose-Headers. By default, JavaScript can only read a small whitelist of response headers. If your API returns a custom header like X-Total-Count for pagination and your frontend can't read it, this is almost always the missing header.
Part 6: The Wildcard Trap and Credentials
A very common mistake:
Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true
This combination is invalid and will be actively rejected by browsers. You cannot use a wildcard origin alongside credentials — it would completely defeat the purpose of the security model. If you need credentials, specify an exact, single origin, validated against a whitelist if you support multiple frontends:
Access-Control-Allow-Origin: https://myapp.com Access-Control-Allow-Credentials: true Vary: Origin
The Vary: Origin header tells caches like CDNs not to serve a cached response meant for one origin to a different origin.
On the client side, credentialed requests also require explicit opt-in:
fetch('https://api.example.com/data', {
credentials: 'include'
});
Part 7: Implementing CORS on the Server
Node.js / Express
const cors = require('cors');
app.use(cors({
origin: 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
Nginx
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
Django (Python)
INSTALLED_APPS = [
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
CORS_ALLOWED_ORIGINS = [
"https://myapp.com",
]
CORS_ALLOW_CREDENTIALS = True
Spring Boot (Java)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://myapp.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
}
}
ASP.NET Core (C#)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowMyApp", policy =>
{
policy.WithOrigins("https://myapp.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
app.UseCors("AllowMyApp");
Part 8: Common CORS Errors and How to Fix Them
"No 'Access-Control-Allow-Origin' header is present"
The server isn't sending CORS headers at all, or not for this specific origin/route. Fix: configure the server's CORS middleware and confirm it applies to the exact route.
"...must not be the wildcard '*' when the request's credentials mode is 'include'"
You're using * while also sending credentials. Fix: specify the exact origin dynamically.
"Method PUT is not allowed by Access-Control-Allow-Methods"
The preflight response doesn't list the method you're using. Fix: add it explicitly.
"Request header field Authorization is not allowed"
A custom header isn't whitelisted. Fix: add it to Access-Control-Allow-Headers.
It works in Postman but not the browser
Expected behavior — CORS is a browser-only enforcement.
Preflight request returns 404 or 405
Your framework/router doesn't have a handler for OPTIONS on that route.
CORS works on GET but fails on POST with JSON body
Content-Type: application/json disqualifies the request from being "simple," triggering a preflight your server may not handle correctly.
Part 9: Debugging CORS Like a Pro
- Open browser DevTools → Network tab.
- Look for the failed request; check whether there's a separate OPTIONS request immediately before it.
- Click into that OPTIONS request and inspect its response headers carefully — this is where most CORS issues live.
- Confirm the Origin header sent by the browser matches exactly what the server allows.
- Remember: a 200 or 204 status on the OPTIONS request doesn't automatically mean success — verify the actual header values.
- If using a CDN or reverse proxy, check whether it's stripping or overriding CORS headers set by your application server.
- Test with
curl -i -X OPTIONS <url> -H "Origin: https://myapp.com" -H "Access-Control-Request-Method: POST"to see the raw preflight response.
Part 10: Security Best Practices
- Never use Access-Control-Allow-Origin: * for authenticated or sensitive endpoints.
- Avoid blindly reflecting the request's Origin header — validate against an explicit whitelist first.
- Use Access-Control-Max-Age wisely to reduce preflight overhead without caching stale policies too long.
- Limit Access-Control-Allow-Methods and -Headers to only what's actually needed.
- CORS is not authentication or authorization — it doesn't replace proper auth tokens or rate limiting.
- Be cautious with subdomain wildcards like *.example.com — a compromised subdomain inherits trust.
Part 11: CORS vs. Other Security Mechanisms
CORS vs CSRF: CSRF protection stops malicious sites from making unwanted state-changing requests using a victim's active session. CORS controls whether a response can be read by JavaScript on another origin. They're often used together.
CORS vs CSP: Content Security Policy restricts what resources a page itself can load. CORS governs cross-origin data access initiated via JavaScript.
CORS vs SOP: SOP is the default restrictive browser behavior; CORS is the mechanism to selectively relax it.
Part 12: Frequently Asked Questions
Does CORS apply to mobile apps?
No — CORS is browser-specific. Native mobile apps aren't subject to SOP or CORS.
Can I fix CORS purely from the frontend?
No, not in production. CORS must be configured on the server returning the response.
Why does my request work the first time but fail on refresh?
Usually a cached preflight response being reused with stale permissions.
Is CORS a replacement for API authentication?
No. CORS only controls whether browser JavaScript can read a response.
Conclusion
CORS often feels like a frustrating hurdle, but it exists for a genuinely important reason: protecting users from malicious cross-origin data theft. Once you internalize that it's a browser-enforced policy, that servers only advertise what they permit via headers, and that preflight requests exist to check permissions before "risky" verbs run — most CORS errors become straightforward to diagnose rather than mysterious.
Resist the urge to reach for Access-Control-Allow-Origin: * as a universal shortcut. Take the time to configure origins, methods, and headers deliberately — your API's security posture depends on it just as much as your authentication layer does.

Comments
Post a Comment