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 wwwroot and 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

  1. Open DevTools → Network, find the failing request (and the OPTIONS before it).
  2. Check the response headers: is Access-Control-Allow-Origin present and exactly equal to your origin (scheme, host, port; no trailing slash)?
  3. Did the preflight return 2xx? A 404/405 on OPTIONS means the server isn't handling preflight for that route.
  4. Is the real error a 401/500 without CORS headers? Check the server log.
  5. Using credentials? Make sure the origin isn't * and Allow-Credentials is true.
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 any Origin back with credentials. That lets any website make authenticated requests as your users and read the responses.
  • Matching origins with Contains or EndsWith — example.com.attacker.net or evil-example.com pass. 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 null origin — sandboxed iframes and local files send Origin: 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.