Articles › Security
CORS Explained: Why Your API Call Fails and How to Fix It Correctly
Understand CORS from the ground up: same-origin policy, preflight requests, cookies and credentials, correct ASP.NET Core setup and common security mistakes.
"Access to fetch at '...' from origin '...' has been blocked by CORS policy." Almost every web developer has seen this message the first time a React app calls an API on a different port or domain. The usual reaction is to search for a snippet that makes it go away — often AllowAnyOrigin() — without understanding what CORS protects. This article explains how CORS really works and how to configure it correctly.
The same-origin policy
Browsers enforce the same-origin policy: JavaScript on one origin can't read responses from another origin. An origin is the combination of scheme + host + port:
| URL | Same origin as https://example.com? |
|---|---|
https://example.com/about |
✅ yes (path doesn't matter) |
http://example.com |
❌ different scheme |
https://api.example.com |
❌ different host |
https://example.com:8443 |
❌ different port |
Why? Imagine you're logged in to your bank. Without this policy, any website you visit could fetch("https://bank.com/api/balance") — the browser would attach your bank cookies — and read your data.
CORS (Cross-Origin Resource Sharing) is the controlled way for a server to say: "it's fine for these other origins to read my responses."
Tip: CORS is enforced by the browser. Postman, curl and server-to-server calls ignore it. That's why "it works in Postman but not in the browser" is the classic CORS symptom.
Simple requests
For "simple" requests — GET, HEAD or POST with only basic headers and a content type of text/plain, multipart/form-data or application/x-www-form-urlencoded — the browser sends the request directly with an Origin header:
GET /api/articles HTTP/1.1
Host: api.example.com
Origin: https://example.com
If the response includes a matching header, JavaScript can read it:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://example.com
If not, the request still reached the server, but the browser hides the response from your code.
Preflight requests
Anything else — PUT, DELETE, PATCH, Content-Type: application/json, an Authorization header — triggers a preflight: the browser first sends an OPTIONS request asking for permission:
OPTIONS /api/articles/42 HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
The server answers which methods and headers are allowed:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Only if the preflight succeeds does the browser send the real PUT. Access-Control-Max-Age lets the browser cache the answer so not every request needs a preflight.
Credentials: cookies and auth
By default, cross-origin fetch doesn't send cookies. To include them:
fetch("https://api.example.com/api/me", { credentials: "include" });
The server must then respond with:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true
With credentials, the browser rejects Access-Control-Allow-Origin: *; the server must echo a specific origin. Cookies also need SameSite=None; Secure to be sent cross-site.
Tokens sent in the Authorization header (JWT) don't need credentials: "include" — but the header must be allowed in the preflight.
Configuring CORS in ASP.NET Core
var allowedOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy => policy
.WithOrigins(allowedOrigins) // e.g. https://example.com, https://admin.example.com
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});
var app = builder.Build();
app.UseRouting();
app.UseCors("frontend"); // after UseRouting, before UseAuthentication/UseAuthorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
{
"Cors": {
"Origins": [ "https://example.com", "https://admin.example.com" ]
}
}
Keep origins in configuration so development (http://localhost:5173) and production can differ without code changes.
Per-endpoint control is possible too:
[EnableCors("frontend")]
public class ArticlesController : ControllerBase { }
app.MapGet("/api/public/stats", GetStats).RequireCors("public");
Middleware order matters
If UseCors runs after authorization, a failing auth check returns 401 without CORS headers — and the browser reports a confusing CORS error instead of the real 401. Put UseCors early. Similarly, if an unhandled exception produces a 500 without CORS headers, the browser shows a CORS error; check the server logs.
Avoid CORS entirely in production
If you serve the frontend and API from the same origin, there's no CORS at all:
- Host the React build in the API's
wwwrootand call/api/...with relative URLs. - Or put both behind one reverse proxy:
example.com/→ frontend,example.com/api→ backend.
In development, the Vite dev server can proxy API calls so the browser sees one origin:
// vite.config.js
export default defineConfig({
server: {
proxy: { "/api": "http://localhost:5179" },
},
});
Debugging CORS errors
- Open DevTools → Network, find the failing request (and the
OPTIONSbefore it). - Check the response headers: is
Access-Control-Allow-Originpresent and exactly equal to your origin (scheme, host, port; no trailing slash)? - Did the preflight return 2xx? A 404/405 on
OPTIONSmeans the server isn't handling preflight for that route. - Is the real error a 401/500 without CORS headers? Check the server log.
- Using credentials? Make sure the origin isn't
*andAllow-Credentialsistrue.
| Error message mentions | Likely cause |
|---|---|
| "No 'Access-Control-Allow-Origin' header" | origin not allowed, middleware order, or a server error |
| "does not pass access control check: wildcard" | * used together with credentials |
| "Method PUT is not allowed" | method missing from the policy |
| "Request header field authorization is not allowed" | header missing from WithHeaders |
Security mistakes
AllowAnyOrigin()on an API that uses cookies — or, worse, reflecting anyOriginback with credentials. That lets any website make authenticated requests as your users and read the responses.- Matching origins with
ContainsorEndsWith—example.com.attacker.netorevil-example.compass. Compare exact values. - Treating CORS as authentication. CORS only controls which browsers' JavaScript can read responses. Attackers call your API directly with curl. Always authenticate and authorize on the server.
- Allowing
nullorigin — sandboxed iframes and local files sendOrigin: null; don't whitelist it.
AllowAnyOrigin() is acceptable for truly public, read-only APIs without cookies (a public weather API, for example).
Summary
CORS is the browser's controlled exception to the same-origin policy. Simple requests are sent directly; others are preceded by an OPTIONS preflight; credentials require a specific origin and Allow-Credentials. In ASP.NET Core, define an explicit policy with configured origins, place UseCors early, and prefer same-origin hosting or a dev proxy when possible. And remember: CORS protects users' browsers — it doesn't replace server-side security.
FAQ
Why does my request work in Postman but not in the browser?
Postman isn't a browser and doesn't enforce the same-origin policy. Only browsers apply CORS.
Can I fix CORS in the frontend?
No. Only the server (or a proxy in front of it) can add the headers that grant access. Frontend "fixes" like mode: "no-cors" just make the response unreadable.
Does CORS protect against CSRF?
Partially at best. Use SameSite cookies, anti-forgery tokens or bearer tokens for CSRF protection; CORS policy misconfiguration can actually make CSRF worse.