Tutorials / Build a REST API with C# and .NET · Lesson 42 of 44
Backend
On this page
CORS
Picture a team of two. Priya builds the API. Tom builds the web pages. Tom does not want to restart the API every time he changes a button, so he runs his pages from a small web server of his own, on port 5500. Priya's API runs on port 5101.
Tom opens his page. It looks right. He presses Log in and the page says Failed to fetch. He tells Priya, "your API is down". Priya tries it with curl: the API answers perfectly. Tom checks the API's log: nothing is wrong, no error, no crash, and the login request is there, answered 200. Both of them are right, and the page still does not work.
The culprit is not in the API or in the page. It is a rule of the browser, and the API has to say one sentence to switch it off. That sentence is CORS. This lesson is about why the rule exists and what the sentence is.
This lesson is optional. Nothing later in the course needs it, because our own pages are served by the API itself, from wwwroot, so page and API share one address and the rule never applies. But most real teams do not work that way. Pages and API often live in different places: the pages on a file host, the API on a server, or a dev server on one port and the API on another, like Tom's. The first time you meet that, the error looks like a broken API. Knowing it saves an afternoon.
Our goal: make a page served from another address (port 5500) work against the API, by telling the browser which address may read the API's answers.
Here is the route:
- See the problem for real: serve a small page from port 5500 and watch it fail.
- Learn the rule behind it: what an origin is, and why the browser protects answers.
- Fix it in the API with a named list of allowed origins.
- Run the page again, and see what CORS is not.
Left out: how to publish pages and the API to a real server, and policies for cookies (our token travels in a header, not in a cookie). A small unit test would test only configuration, so there is none; the browser is the test.
Where we are
This is the solution from the end of the trace id. One file changes in the API. One new folder is made next to src/, to play Tom's part:
BookShop/
src/
BookShop.Api/
Program.cs <- changes
frontend-test/ <- NEW (Tom's page, outside the solution)
index.html <- NEW
Run every command from the solution folder, BookShop/.
The problem, for real
We play Tom. Create a folder frontend-test next to src, and in it index.html. The whole file is:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Books from another address</title></head>
<body>
<h1>Books</h1>
<p id="message">Loading...</p>
<ul id="list"></ul>
<script>
const api = 'http://localhost:5101';
async function load() {
const login = await fetch(api + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName: 'bob', password: 'bob-pass-1' })
});
const { token } = await login.json();
const response = await fetch(api + '/books', { headers: { Authorization: 'Bearer ' + token } });
const books = await response.json();
document.getElementById('message').textContent = '';
for (const book of books) {
const item = document.createElement('li');
item.textContent = book.title;
document.getElementById('list').append(item);
}
}
load().catch(error => {
document.getElementById('message').textContent = 'Failed: ' + error.message;
});
</script>
</body>
</html>
It is the same idea as the pages of the last few lessons: log in as bob, then fetch the books with the token. The one difference is the first line of the script: const api = 'http://localhost:5101'. Our own pages call /books, a short address that means "on the same site as this page". Tom's page is on another site, so it must say the full address of the API.
Now serve the folder from port 5500. Any small web server will do. Python has one built in (you may have it already), and there are others:
python3 -m http.server 5500 --directory frontend-test
Other ways to the same result: npx serve -l 5500 frontend-test if you have Node.js, or the Live Server extension of an editor (Visual Studio Code and Visual Studio each have one; mind that its default port may differ from 5500). We use Python only because it is small. Start the API in another terminal (dotnet run --project src/BookShop.Api), and open http://localhost:5500 in the browser.
The page shows:
Failed: Failed to fetch
Open the browser's developer tools (F12) and look at the Console tab, which the first web page introduced. The browser has written the real reason:
Access to fetch at 'http://localhost:5101/auth/login' from origin 'http://localhost:5500' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
That sentence is the whole lesson. Take it apart in the next section.
The rule behind it
First, the word origin. An origin is the first part of an address: the scheme (http or https), the host (localhost), and the port (5500). Two addresses are the same origin only if all three match.
| Page at | Calls | Same origin? |
|---|---|---|
http://localhost:5101/index.html |
http://localhost:5101/books |
Yes. This is how our own pages work. |
http://localhost:5500/index.html |
http://localhost:5101/books |
No: the port differs. |
http://localhost:5101/index.html |
https://localhost:5101/books |
No: the scheme differs. |
Now the question: why would a browser refuse to give a page an answer that the API was happy to send? Because of what else the browser is doing. You are logged in to your bank in one tab. In another tab you open some unknown website. If that website's page could call your bank and read the answer, it could read your balance with your login. So the browser has a rule: a page may read answers only from its own origin. The browser enforces it for you, on every page, all the time. The name is the same-origin policy.
The rule is too strict for the case of Tom and Priya, who trust each other. So the server is allowed to loosen it for chosen origins. It does that by sending a permission in its answer: this origin may read my answers. The agreement of asking and permitting is CORS, short for Cross-Origin Resource Sharing. The word sharing is the clue: it is the server that shares, the browser that checks.
One more word from the message: preflight. For most requests a page makes, the browser does not just send them. It first sends a small question, a request with the verb OPTIONS, which asks "I am the page from http://localhost:5500; may I send a POST with these headers?" Only if the answer says yes does it send the real request. The browser does this for every request that is not a plain one. A request is not plain when it carries a header such as Authorization, or when its body is JSON (Content-Type: application/json). Our login does the second, and the books call does the first. So almost every call of our pages needs a preflight.
What the API saw
The API sent no wrong answer. We can look at both sides with curl, which is not a browser and so has no such rule. First the preflight question, as the browser sends it:
curl -i -X OPTIONS http://localhost:5101/auth/login -H "Origin: http://localhost:5500" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 405 Method Not Allowed
Content-Type: application/problem+json
Date: Sat, 31 Oct 2026 10:24:58 GMT
Server: Kestrel
Allow: POST
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.6","title":"Method Not Allowed","status":405,"traceId":"00-bc0ad600d54a5ed77c91ca4817af84f7-c2ecde85e698350b-00"}
(Your date and trace id differ.) The login endpoint answers POST only, so it says "OPTIONS is not allowed", and gives no permission. The browser stops there. Now the same login as a plain POST, saying it comes from the other origin:
curl -i -X POST http://localhost:5101/auth/login -H "Origin: http://localhost:5500" -H "Content-Type: application/json" -d '{"userName":"bob","password":"bob-pass-1"}'
HTTP/1.1 200 OK
(We cut the rest.) The API would have logged bob in. Nothing in the answer says the origin may read it, so a browser would hide it from the page.
⚠️ Mind what this shows: CORS does not stop requests. It stops pages from reading the answers. The API ran the login for an origin it never heard of. CORS is not a lock and is not authentication. The lock on our books is still the token from protect endpoints and roles.
The fix: name the origins
The permission is a header in the answer, Access-Control-Allow-Origin, with the origin that may read. ASP.NET sends it for us once we describe a policy: who may ask, with which headers, with which verbs. Open src/BookShop.Api/Program.cs. The whole file is:
using System.Text;
using BookShop.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("BookShop");
builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IStockReportService, StockReportService>();
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
builder.Services.AddSingleton(jwtSettings);
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<IUserService, DemoUserService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key))
};
});
builder.Services.AddAuthorization();
builder.Services.AddCors(options => // new
{ // new
options.AddPolicy("Frontend", policy => policy // new
.WithOrigins("http://localhost:5500") // new
.WithHeaders("Content-Type", "Authorization") // new
.WithMethods("GET", "POST", "PUT", "DELETE")); // new
}); // new
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseCors("Frontend"); // new
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
AddCorsandAddPolicy("Frontend", ...)register a policy with a name we choose. A policy is a list of what is allowed.WithOrigins("http://localhost:5500")names the one origin that may read answers. This is the line that matters. Write it exactly as the browser shows the origin: no slash at the end.WithHeaders(...)lists the headers the page may send, the two we use:Content-Type(for the JSON) andAuthorization(for the token).WithMethods(...)lists the verbs the page may use. These are the four verbs the books endpoints use.UseCors("Frontend")switches the policy on for every request. It stands after the static files and beforeUseAuthentication(). The order matters for a good reason: a refusal made by the authorization step (a401for a missing token) ends the request there. If CORS stood after it, that answer would leave without the permission header, and the page could not read even the401. It would see only "Failed to fetch", and could not send the user to the login page. We tried both orders: withUseCorsafter the authorization, a request without a token gets401and noAccess-Control-Allow-Originheader. With it before, the header is there.
Two shortcuts exist, and are worth knowing so that you recognise them: AllowAnyOrigin(), AllowAnyHeader() and AllowAnyMethod(). They switch the checking off. They are fine for a public API with no logins that anyone may read, and wrong for anything else. A named list is the habit to start with, and the list differs between machines: in a real project the origins come from appsettings.json, so the live server lists the real address of the pages and not localhost. Here we write it in the code to keep the lesson short.
Restart the API. Ask the preflight question again:
curl -i -X OPTIONS http://localhost:5101/auth/login -H "Origin: http://localhost:5500" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 204 No Content
Date: Sat, 31 Oct 2026 10:25:19 GMT
Server: Kestrel
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:5500
Now the answer is "yes": 204, no body, and the permission written out. The login itself carries the header too:
curl -i -X POST http://localhost:5101/auth/login -H "Origin: http://localhost:5500" -H "Content-Type: application/json" -d '{"userName":"bob","password":"bob-pass-1"}'
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5500
(Again we cut the rest.) Reload http://localhost:5500 in the browser. The page now lists the books: Clean Code, Refactoring, Salt Fat Acid Heat, The Joy of Cooking, The Pragmatic Programmer. Tom's page works, and Priya never touched the page.
What CORS is not
Three things get mixed up, so let us keep them apart.
- It is the browser that enforces it. curl, a phone app, a script and another server ignore CORS completely. We saw it: curl sent the login from the "wrong" origin and got a
200. So CORS cannot protect the API from other programs. The token does that. - It is not authentication. CORS says which pages may read answers. It never asks who the user is. A request from an allowed origin with no token still gets
401. - It does not stop the request from running. A request from an origin that is not on the list still runs on the server. Only the browser hides the answer. Try the login with
-H "Origin: http://evil.example": the answer is still200, and it comes without the permission header, so a browser would keep it from the page.
Finally, check what the lesson did not change: our own pages. They are on the same origin as the API, so the browser does not ask for permission at all. Open http://localhost:5101 and log in, as in the last lessons. It works as before.
Stop the API and the small web server (Ctrl+C in each terminal). If you like, delete the frontend-test folder. The CORS lines can stay in Program.cs: they only matter when a page from port 5500 asks.
Where we ended
Compare with your solution. One file changed, one folder was added outside the solution:
BookShop/
src/
BookShop.Api/
Program.cs adds the "Frontend" CORS policy and UseCors
frontend-test/
index.html a page on another origin, for trying it
Recap
- An origin is the scheme, the host and the port. Pages and API on different origins are "cross-origin".
- The browser's same-origin policy stops a page reading answers from another origin, so that an unknown website cannot read your logged-in data.
- CORS is how the server lets chosen origins through: it sends
Access-Control-Allow-Originand a few headers beside it. - For requests with an
Authorizationheader or a JSON body, the browser first sends anOPTIONSpreflight question, and only sends the real request if the answer says yes. - In ASP.NET:
AddCorswith a named policy listing the origins, headers and methods, andUseCorsplaced before authentication. - CORS is enforced by the browser only. It is not a lock, it is not authentication, and it does not stop the request from running.
- Our own pages never need it, because the API serves them from the same origin.
Questions to think about
Try to answer each one before you open the answer.
- Why does a browser refuse to give a page the answer from another origin?
Show answer
If it did not, any website you open could call the sites you are logged in to and read their answers with your login. The rule protects the person using the browser.
- The API logged the request as a success, yet the page says "Failed to fetch". Where was the answer stopped?
Show answer
In the browser. The API did its work and answered, but without the permission header the browser would not hand the answer to the page.
- Does CORS protect the API from someone who is not using a browser?
Show answer
No. Only browsers enforce it. curl, scripts and other servers ignore it, so the API's real protection is still the token and the roles.
- Why is the policy a named list of origins, and not "allow everything"?
Show answer
Allowing everything lets any page on any website read the answers of a logged-in user. Naming the origins lets only the pages we own do that.
Next lesson: one integration test, a test that sends real requests to the whole API at once, including the locks that unit tests cannot see.