CORS, Correctly
By the end of this lesson
Restrict cross-origin access to what is actually required.
The orders front end is served from one host and calls the API on another. That is two origins, and browsers treat the boundary between them as significant. Getting the configuration right takes ten minutes; understanding what it is for takes a little longer and is the part worth having.
The same-origin policy is a browser rule. A page from one origin may cause a request to another origin — that has always been allowed, and it is how every image, script and form post to a third party works. What the policy stops is the page reading the response. Script on a page cannot read what came back from an origin that did not agree to it.
That rule is why a page you have open cannot quietly read your inbox from another tab's session. Without it, any page could call any API your browser has cookies for and read the results.
CORS — cross-origin resource sharing — is the mechanism by which a server relaxes that rule on purpose. Your API says, in response headers, which other origins may read its responses. The rest of this lesson is about configuring that narrowly, and about the thing people consistently get wrong: who enforces it.
Five terms, because the headers are hard to reason about without them:
- Origin
- The scheme, host and port of a URL, taken together. https://orders.example-company.com and https://api.example-company.com are different origins. So are https://orders.example-company.com and http://orders.example-company.com, and so are ports 443 and 5001 on the same host. All three parts count, which is the usual reason a policy that looks right does not match.
- Same-origin policy
- The browser rule that script on a page may read responses from its own origin and not from others. It governs reading, not sending. A cross-origin form post or image request still reaches your server; what the page cannot do is see what came back.
- Preflight request
- An OPTIONS request the browser sends by itself, before the real one, when the request is not a plain GET or simple form post — a PUT, a DELETE, a JSON body, or a custom header such as Authorization. It asks your server whether the real request is permitted. Your application never writes this request and rarely needs to handle it.
- Credentialed request
- A cross-origin request carrying cookies or an Authorization header. Browsers require the server to say so explicitly, and they refuse to combine that permission with a wildcard origin. This is the pairing to be most careful with, for reasons in the mistakes callout.
- Access-Control-Allow-Origin
- The response header naming the single origin permitted to read this response, or an asterisk for any origin. It is one value, not a list — when several origins are allowed, the server echoes back whichever matched. Which is why caching in front of your API needs care.
var builder = WebApplication.CreateBuilder(args);
// Origins differ per environment, so they are configuration rather than code.
// The development localhost origin must not be allowed in production.
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
{
options.AddPolicy("OrdersWebApp", policy => policy
.WithOrigins(allowedOrigins) // exact scheme, host and port
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type", "X-CSRF-TOKEN")
.WithExposedHeaders("X-Correlation-Id")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});
var app = builder.Build();
app.UseCors("OrdersWebApp"); // before authentication and before the endpoints
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();
// FLAWED SHAPE, for recognition only.
//
// policy.AllowAnyOrigin().AllowCredentials()
//
// ASP.NET Core throws at runtime for that combination, because the CORS
// protocol forbids it. The workaround people reach for next is worse, since
// it removes the guard instead of the cause:
//
// policy.SetIsOriginAllowed(_ => true).AllowCredentials()
//
// That reflects whatever origin asked back as the allowed origin, and tells
// the browser to send this user's cookies to it. Neither line belongs in a
// deployed application.
app.Run();- WithOrigins compares the full origin, exactly. A trailing slash makes it fail to match, http and https are different entries, and a port must be written when it is not the default. Most of the time a policy that seems ignored is a policy that did not match.
- Origins come from configuration because they are environment-specific. A localhost entry compiled into the policy is an allowed origin in production, and it is the kind of thing that survives for years because nothing visibly breaks.
- Naming methods and headers rather than allowing any is the same closed-by-default idea as the fallback policy in the first lesson. AllowAnyHeader is common and is usually a shortcut past working out which header was missing.
- WithExposedHeaders is needed for your own response headers to be readable by the page. Without it a browser exposes only a small standard set, which is the usual explanation for a correlation id that the front end cannot find.
- AllowCredentials permits cookies and the Authorization header on cross-origin requests. It is required for a cookie-based front end on another origin, and it is the setting that makes a loose origin list serious rather than untidy.
- The framework throwing for AllowAnyOrigin with AllowCredentials is a guard, not a bug to route around. Reflecting the origin back with SetIsOriginAllowed produces exactly what the protocol forbids, with the error message removed. If you find that line in a codebase, the question to ask is which origins genuinely need access.
- UseCors runs before the endpoints, and before anything that short-circuits the pipeline. Put it after, and a preflight gets answered without the headers, which surfaces as a CORS failure in the browser with a perfectly healthy server log.
- RequireAuthorization on the endpoints is the point of the callout above. The CORS policy governs which pages may read responses; the authorisation requirement is what actually decides who may call.
- SetPreflightMaxAge lets the browser cache the preflight answer, saving a round trip per request shape. The trade-off is that a policy change takes up to that long to reach a browser holding a cached answer, so keep it modest while you are still adjusting things.
OPTIONS /orders/4821 HTTP/1.1
Host: api.example-company.com
Origin: https://orders.example-company.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization,content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://orders.example-company.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin- The browser sends this by itself. Your front-end code makes one call; the network tab shows two. Nothing in your application code triggers the OPTIONS request and nothing needs to handle it when the CORS middleware is registered.
- The request states what the real call intends to do. If the method or any header is not permitted, the browser stops there and the real request is never sent — which is why a missing header in the policy looks like a request that never reached the server, because it did not.
- The response names one origin, not a list. Where several origins are allowed, the server echoes back the one that asked, after checking it against the policy.
- Vary: Origin tells caches that the response depends on the request's Origin header. Without it, a shared cache can store the headers generated for one origin and serve them to another, which quietly hands a response to a page that was never allowed to read it. This is the CORS detail most often missed once a CDN is in front of an API.
- Access-Control-Allow-Credentials: true is only valid alongside a specific origin. A browser refuses the combination of credentials and a wildcard, and that refusal is the protocol working as intended.
- Max-Age caches this answer for ten minutes, so subsequent PUTs to that origin skip the preflight.
- Worth repeating in this context: a client that is not a browser sends none of this and reads none of it. It issues the PUT directly. The preflight is a conversation between the browser and your server about what the browser will allow its own page to do.
Two mechanisms that get conflated whenever a request is refused. They answer different questions, and only one of them is a control on your server:
| CORS | Authorization | |
|---|---|---|
| Question answered | May a page on this origin read my response? | May this caller perform this operation on this data? |
| Enforced by | The browser, using headers your server sends | Your server, on every request |
| Effect on a non-browser client | None. It reads no headers and is unaffected | Full. The check runs whatever the client is |
| What it protects | Your users, from other pages acting with their credentials | Your data, from any caller without permission |
| Failure looks like | A message in the browser console, with the request often never sent | A 401 or 403 in the response and in your logs |
| If it is missing | Your own front end on another origin cannot read responses | Anything that can reach the endpoint can use it |
Summary
- The same-origin policy stops a page reading responses from another origin; it never stopped the request being sent
- CORS is how a server relaxes that rule, and it is enforced by the browser rather than by your application
- It protects your users from other pages acting with their credentials; it is not access control for your API
- Anything that is not a browser ignores the policy, so authorisation still has to be enforced on every request
- Name exact origins, methods and headers, keep the list per environment, and never pair a reflected origin with credentials
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
The CORS error that was not about CORS
A PUT from the orders front end fails, and the browser console reports that the response did not pass the access control check. A colleague adds AllowAnyOrigin to the policy. The console message changes but the update still does not happen.
Work out what is most likely going on, and say what you would have checked first.
Show solution
The likely cause is that the request is being refused by the server for an ordinary reason — a missing or expired token, a missing anti-forgery header, or an endpoint that requires a role the employee does not have. An error response generated before the CORS middleware runs, or from a path outside the policy, carries no CORS headers, and the browser reports that absence as an access control failure. The message describes the symptom.
Widening the origin list changed which origins may read responses and changed nothing about the refusal, which is why the update still fails. It also left the API permitting more than it needs, and that part will outlive the debugging session.
What to check first is the server's own record of the request: the status code and the log entry. A 401 tells you the credential did not arrive or did not validate. A 403 tells you it did and permission was refused. A 400 with an anti-forgery message tells you the token was missing from the request. None of those are CORS problems, and all of them are visible from the server side in under a minute.
The second thing to check is whether the preflight itself was answered, and with which headers. If the OPTIONS request returned a 204 with the expected headers, CORS is working and the problem is in the real request. If it returned a 404 or a 500, the middleware ordering or the policy name is worth looking at.
The habit worth forming: when the browser reports a CORS failure, look at the server first. The browser can only tell you what it did not receive.
Challenge
Who can still call your API?
Your API allows exactly one origin, one set of methods and three headers. A colleague concludes the API is now restricted to the orders front end.
List the callers that are unaffected by that policy. Then say, for the orders API specifically, what has to be in place because of them.
Show solution
Unaffected: a command-line HTTP client, a script running on any server, another service in your own estate, the mobile application, a test harness, a scanner, a browser extension that makes requests outside a page's context, and anything at all with a socket and the address. None of them read the CORS headers, and nothing stops them sending the request.
So the policy has not restricted who can call the API. It has restricted which web pages may read its responses inside a browser, using the signed-in user's credentials. That is worth having, and it is a different sentence from the one the colleague said.
What has to be in place because of it: authentication on every endpoint, so an anonymous caller gets nothing; authorisation per operation and per record, since a valid token for one employee should not read another team's orders; validation on every input, because the request shape is not constrained by your front end; and rate limiting, because your front end's usage pattern is not a limit on anyone else's.
One more, easy to overlook: your error responses. A caller outside your front end sees whatever your API returns when something goes wrong, so a detailed exception message is a description of your internals handed to whoever asked. The deployment lesson later in this module deals with that.
The short version, worth carrying into review: CORS decides what browsers permit pages to read. Authorisation decides what your API permits anyone to do. Only the second one is a decision your server makes.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.