DevForum’s new frontend launches on app.devforum.com. The backend API stays on api.devforum.com. The developer opens the browser, loads the dashboard, and watches the network tab. Every API request returns 200. The JavaScript receives nothing. The console says:
Access to fetch at 'https://api.devforum.com/v1/posts' from origin
'https://app.devforum.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Most developers respond by adding Access-Control-Allow-Origin: * to the API and moving on. This post is about understanding what you just did — and when that answer is the wrong one.
The Same-Origin Policy, From First Principles
Before CORS can make sense, the restriction it relaxes needs to be clear.
What is an “origin”?
An origin is a triple: scheme + host + port. All three must match for two URLs to share an origin. The path, query string, and fragment are irrelevant.
Base URL: https://devforum.com
https://devforum.com/posts → Same origin (path doesn't count)
http://devforum.com → Different (scheme: https ≠ http)
https://app.devforum.com → Different (host: subdomain ≠ apex)
https://api.devforum.com → Different (host: different subdomain)
https://devforum.com:8080 → Different (port: 443 ≠ 8080)
https://devforum.com:443 → Same origin (443 is the default HTTPS port)
What SOP restricts — and what it doesn’t
The Same-Origin Policy restricts JavaScript reading cross-origin responses. It does not restrict much else:
| Action | Cross-origin allowed? |
|---|---|
Navigate (<a href>, window.location) | Yes |
Form submission (<form action>) | Yes — response blocked, not request |
Embed resources (<img src>, <script src>, <link href>) | Yes |
fetch() or XMLHttpRequest — sending the request | Yes |
fetch() or XMLHttpRequest — reading the response | No — this is what SOP blocks |
The asymmetry is critical. The request goes through. The browser sends the POST to api.devforum.com. The server receives it and processes it. SOP blocks the JavaScript from reading the response. The state change already happened.
This asymmetry is exactly why CSRF is a separate problem, not a CORS problem — and why we cover it in the next post.
The threat SOP prevents: without it, any malicious page could silently fetch('https://yourbank.com/balance') using your session cookie and read your account balance. Any site could read the contents of your authenticated sessions on any other site. SOP prevents cross-origin response reads by default.
What CORS Actually Is
CORS (Cross-Origin Resource Sharing) is not a security restriction. It’s a relaxation of SOP.
The browser’s default is to block cross-origin response reads. CORS is the mechanism by which a server tells the browser: “I permit this specific origin to read my responses.”
The right question when adding CORS is not “how do I make this work?” — it’s “which origins should legitimately be able to read this API’s responses?”
Simple vs Preflighted Requests
The browser splits cross-origin requests into two categories based on their risk profile.
Simple requests are sent directly. The browser attaches the Origin header and checks the response’s Access-Control-Allow-Origin header. If it matches, the response is given to JavaScript. If not, the response is blocked.
A request is simple if it meets all three conditions:
- Method is
GET,HEAD, orPOST - Content-Type is
application/x-www-form-urlencoded,multipart/form-data, ortext/plain - No custom headers beyond the CORS-safelisted headers
Preflighted requests trigger a preliminary OPTIONS request before the actual request is sent. The preflight asks the server: “will you allow this method, from this origin, with these headers?” Only if the server answers yes does the browser send the actual request.
What triggers a preflight:
- Any method other than
GET,HEAD, orPOST Content-Type: application/json(or any non-simple type)- Any custom header (
Authorization,X-Request-ID,X-API-Key)
DevForum’s API call: fetch('https://api.devforum.com/v1/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }) triggers a preflight because of the application/json content type. (credentials: 'include' means the browser will send cookies and Authorization headers with the request — a credentialed request. Credentialed requests are covered in detail in the next section.)
The Preflight Flow in Full Detail
Credentialed Requests and the Wildcard Trap
By default, cross-origin fetch() calls do not include cookies or Authorization headers. To send credentials, you must opt in:
fetch('https://api.devforum.com/v1/me', {
credentials: 'include' // include cookies and auth headers
});
When credentials: 'include' is set, the browser enforces two additional rules on the response:
Access-Control-Allow-Originmust be a specific origin — not*Access-Control-Allow-Credentials: truemust be present
If either is missing, the browser blocks the response from reaching JavaScript — even though the server sent a 200.
Vary: Origin and CDN Caching
CDNs (Content Delivery Networks) cache HTTP responses at edge servers close to your users — they store and replay responses rather than always hitting your origin server. This is what makes them fast, and it’s what makes the next bug subtle.
When Access-Control-Allow-Origin is set dynamically — different values for different requesting origins — the response must include Vary: Origin.
Without it, a CDN may cache the response for one origin and serve it to requests from a different origin. The cached response includes ACAO headers that permit app.devforum.com. A request from otherdomain.com gets the same cached response — with headers permitting the wrong origin.
// Express — correct CORS + Vary header pattern
const ALLOWED_ORIGINS = new Set([
'https://app.devforum.com',
'https://admin.devforum.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// Always set Vary when ACAO is dynamic
res.setHeader('Vary', 'Origin');
next();
});
What CORS Does Not Protect Against
Two things developers routinely assume CORS handles that it does not:
CSRF. A form on evil.com can POST to api.devforum.com. The browser sends the request — including any cookies attached to api.devforum.com. SOP blocks evil.com’s JavaScript from reading the response. The state change (delete account, transfer funds, post comment) has already happened. CORS does not prevent CSRF. That requires CSRF tokens or SameSite cookies — the subject of the next post.
Non-browser clients. curl, Postman, Python’s requests, any server-to-server HTTP call — these ignore CORS headers entirely. CORS is a browser contract. It tells browsers which origins may read responses. It tells nothing to any other HTTP client. If your API contains sensitive data, authentication and authorisation on every endpoint are still required independently of CORS configuration.
Common Misconfigurations
Reflecting the Origin header verbatim. The worst pattern — and surprisingly common:
// Vulnerable — allows any origin to read the response
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
Any site in the world can now read responses from your API as your authenticated users.
Overly broad regex. An attempt to validate the origin that introduces a bypass:
// Vulnerable — devforumevil.com passes this check
if (/devforum/.test(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}
// Fixed — exact set membership check
const ALLOWED = new Set(['https://app.devforum.com', 'https://devforum.com']);
if (ALLOWED.has(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}
Missing Vary: Origin. Covered above — dynamic ACAO headers without Vary creates CDN caching bugs. Always add it.
Wildcard with credentials. Access-Control-Allow-Origin: * combined with credentials: 'include' — browser blocks the response per spec. This is a configuration error, not a browser bug.
| Pattern | What it does | The risk | The fix |
|---|---|---|---|
| Reflect origin verbatim | res.set('ACAO', req.headers.origin) with no check | Any origin reads the API | Validate against an explicit Set of allowed origins |
| Broad regex | /devforum/.test(origin) | devforumevil.com passes | Exact string match against a Set |
| Wildcard + credentials | ACAO: * + Allow-Credentials: true | Browser blocks response — silent auth breakage | Use specific origin when credentials: 'include' |
| Missing Vary: Origin | Dynamic ACAO without Vary header | CDN serves stale CORS headers to wrong origin | Always add Vary: Origin on dynamic ACAO responses |
Request Type Reference
| Type | What triggers it | Preflight? | Credentials? | Required server headers |
|---|---|---|---|---|
| Simple | GET/HEAD/POST + safe content-type + no custom headers | No | Only with credentials: include | Access-Control-Allow-Origin |
| Preflighted | application/json, custom headers, or non-simple method | Yes — OPTIONS first | Only with credentials: include | Access-Control-Allow-Origin + Methods + Headers |
| Credentialed | credentials: 'include' on any request | If non-simple | Yes — cookies and auth headers sent | Specific ACAO (not *) + Allow-Credentials: true |
Maintain a Set of permitted origins and check with .has(). Regex patterns on origin values introduce substring bypass vulnerabilities. Exact string matching does not.
If your ACAO value changes per request (different values for different origins), every CORS response must include Vary: Origin. Without it, CDN or proxy caching will serve the wrong ACAO header to the wrong origin.
Wildcard is appropriate for public read APIs: fonts, public datasets, open image CDNs. Any endpoint that reads session state, user data, or authenticated content needs a specific allowed origin.
SOP blocks the response read, not the request. A cross-site POST to a state-changing endpoint still executes — it just can’t read the response. State-changing endpoints need CSRF tokens or SameSite cookie attributes regardless of CORS configuration.
curl, Postman, and server-to-server requests ignore CORS entirely. Authentication and authorisation on every endpoint are independently required. CORS is a browser contract, not an API access control mechanism.