CORS
By the end of this lesson
Configure cross-origin access deliberately rather than permitting everything.
An origin is the combination of scheme, host and port: https://hr.example is one origin, and http://hr.example, https://hr.example:8080 and https://other.example are three different ones. A difference in any of the three parts makes it a separate origin.
Browsers enforce a rule called the same-origin policy. Script running on one origin cannot read the response from a request to a different origin unless that other origin says it may. This is what stops a page you happened to open from reading your data out of an API you are signed in to on another tab.
Cross-Origin Resource Sharing, universally shortened to CORS, is the mechanism by which a server grants that permission. It is a set of response headers saying which origins are allowed, which methods and headers they may use, and whether credentials may be included.
Configuring it is therefore an act of naming who you trust. That framing is more useful than treating CORS as an error to make go away.
For anything beyond a simple request, the browser asks first. That extra request is called a preflight:
Script makes a cross-origin call
A page on https://hr.example calls https://api.internal.example with a JSON body and an Authorization header.
The browser sends OPTIONS instead
Because the request uses a method or headers beyond the simple set, the browser first sends an OPTIONS request carrying Origin, Access-Control-Request-Method and Access-Control-Request-Headers. Your application code does not run for this.
The CORS middleware answers
It compares the request against your policy and responds with the matching Access-Control-Allow headers, or without them if the origin is not permitted.
The browser decides
If the response permits what was asked, the browser sends the real request. If not, it never sends it, and the script receives a network error with no detail about why.
The real response is filtered
Even on the actual request, script can only read headers you listed as exposed. Everything else is hidden from it, which is why a custom header appears missing until you add it to the policy.
const string HrPortalPolicy = "hr-portal";
builder.Services.AddCors(options =>
{
options.AddPolicy(HrPortalPolicy, policy =>
policy
// Exact origins. No trailing slash — the value must match
// scheme, host and port exactly.
.WithOrigins(
"https://hr.example",
"https://hr-staging.example")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
// Without this, script cannot read the header at all.
.WithExposedHeaders("X-Correlation-Id")
.AllowCredentials()
// Cache the preflight answer, so OPTIONS is not sent every time.
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});
WebApplication app = builder.Build();
app.UseRouting();
app.UseCors(HrPortalPolicy); // before authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();- Everything is named explicitly: which origins, which methods, which request headers, which response headers. Each line is a decision you can defend in a review.
- Origins must match exactly and must not end with a slash. A trailing slash is the single most common reason a correctly configured policy appears not to work.
- WithExposedHeaders is easy to forget. By default script can read only a small set of response headers, so your correlation id is invisible to the client until you list it here.
- SetPreflightMaxAge lets the browser cache the preflight result. It removes an OPTIONS round trip per request, at the cost of a delay before a policy change takes effect for clients that already cached it.
- UseCors goes after UseRouting and before UseAuthorization. Register it too late and a rejected cross-origin request can fail before the headers that explain the rejection are ever added.
OPTIONS /api/employees HTTP/1.1
Host: api.internal.example
Origin: https://hr.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://hr.example
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: content-type,authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin- The browser sends this by itself. There is no body, no controller involved, and no authentication — which is why a preflight must be allowed through even on endpoints that require a token.
- Access-Control-Allow-Origin echoes one specific origin rather than a wildcard, because the policy named exact origins.
- Access-Control-Allow-Credentials: true is what permits cookies and the Authorization header on the real request. It only appears because the policy called AllowCredentials.
- Vary: Origin tells caches that the response depends on the Origin header. Without it, a shared cache can serve one origin's allow-headers to a different origin.
- If any of these headers were missing or did not cover what was asked, the browser would stop here and the real POST would never be sent.
Before a cross-origin setup goes to production:
- Origins are listed explicitly, with no trailing slashes, and come from configuration so each environment differs without a code change
- AllowAnyOrigin appears nowhere alongside AllowCredentials, and no permissive origin predicate has been used to work around it
- Only the methods and request headers the client actually uses are allowed
- Response headers the client needs to read are listed in WithExposedHeaders
- UseCors is registered after UseRouting and before UseAuthorization
- Every endpoint the policy exposes has its own authentication and authorization, because CORS is not protecting it
Summary
- An origin is scheme, host and port together; the same-origin policy stops script reading another origin's responses
- CORS is the server's way of granting that permission, expressed entirely in response headers
- Non-simple requests trigger a preflight OPTIONS the browser sends by itself, before any of your code runs
- Name exact origins, methods and headers, and list any response header the client needs to read. AllowAnyOrigin with AllowCredentials is invalid and the framework refuses it
- CORS is enforced by browsers, so it protects your users from other sites and does nothing to protect your API from other callers
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Who is CORS protecting?
Your API allows only https://hr.example. Someone calls the same endpoint from a terminal on their laptop and gets a full response including employee data.
Is the CORS policy broken? What was it ever protecting, and what would have stopped this call?
Show solution
The policy is working as designed. CORS lives in the browser, so a terminal has nothing to enforce and never sees a reason to stop.
What the policy protects is your users. If someone signed in to the HR portal opens an unrelated page, script on that page cannot read responses from your API, because your API does not list its origin.
What would have stopped the terminal call is authorization. Requiring a validated token and checking the caller's permissions is the control that applies to every caller, browser or not.
The habit worth forming: when reasoning about an access question, ask whether the check runs on a machine you control. CORS does not.
Try it yourself
Make a preflight fail, then read it
Configure a policy allowing one origin and the header Content-Type only. From a page on that origin, send a request that also sets an Authorization header.
Look at the browser network tab, find the OPTIONS request, and compare what was asked for against what the response allowed.
Show solution
The OPTIONS request lists authorization in Access-Control-Request-Headers, and the response either omits it from Access-Control-Allow-Headers or omits the allow headers entirely. The real request is never sent.
This is worth seeing because the console error is deliberately vague and reading the preflight is how you diagnose CORS problems quickly: compare what the browser asked for, header by header, with what the response permitted.
It also shows that the allowed-headers list is a real constraint, not documentation. Adding a header on the client is a policy change on the server.
Saved in this browser only.