CORS: Let the School Website Call the API – Web API | Extra 7
Introduction
The SMS API works. Postman calls it, curl calls it, and 189 tests call it. Then the front-end team builds the office website, opens it in a browser, and every request fails with "Failed to fetch".
Nothing is wrong with the API's code. The browser itself refused to let the page read the answer, because the page and the API live at different addresses. The rule that decides this is called CORS.
By the end of this lesson, you will be able to:
- Explain what an origin is and why the browser cares about it.
- Read a CORS error in the browser console and say what it means.
- Describe the preflight request a browser sends before the real one.
- Add one safe CORS policy to the SMS, with the allowed websites in configuration.
- Prove with tests that the office page gets in and an unknown website does not.
Why this matters
Almost every API you build will have a web front end. In most projects that front end runs on a different address from the API, at least on your own machine: the page on one port, the API on another.
So every web developer meets a CORS error sooner or later, usually on the first day. Developers who do not understand it often "fix" it by allowing every website in the world, which hides the error and opens a hole.
This lesson shows you the right fix: allow exactly the websites you trust, and nothing more.
The concept
Think of the gate guard at a school. A visitor walks up and says "I am from the bus company." The guard checks the list the principal gave them, and only then lets the visitor in.
In the browser, the browser is the guard. A web page wants to read an answer from an API at another address. The browser asks the API, "Is this website on your list?", and lets the page read the answer only if the API says yes.
Example: what is an origin
An origin is three parts of a web address: the scheme (http or https), the host name, and the port. Two
addresses have the same origin only when all three match.
| Page address | API address | Same origin? | Why |
|---|---|---|---|
http://localhost:5080/index.html | http://localhost:5080/api/students | Yes | All three parts match |
http://localhost:5173 | http://localhost:5080 | No | Different port |
https://office.example.org | https://api.example.org | No | Different host |
http://office.example.org | https://office.example.org | No | Different scheme |
The browser's same-origin policy says: a page may freely read answers from its own origin. For any other origin, the page may read the answer only when the server allows it. CORS (Cross-Origin Resource Sharing) is how the server says "allowed".
Example: the permission header
The server gives permission with one response header. When the office page at http://localhost:5173 calls the
API, the API's answer must carry:
Access-Control-Allow-Origin: http://localhost:5173
If the header is missing, or names a different website, the browser hides the answer from the page. The page's code only learns "Failed to fetch", with no status code and no message.
Example: the preflight request
Some requests are risky enough that the browser asks first. A request with an Authorization header, a JSON
body, or a method like PUT or DELETE gets a preflight: an OPTIONS request that only asks a question.
The preflight carries no token and no body. If the answer does not give permission, the browser never sends the real request at all.
Example: what CORS does not do
CORS is enforced by the browser, for the people using it. curl, Postman, a mobile app, and another server do not check CORS headers at all.
So CORS is not a lock on your API. It stops other websites from reading your API's answers inside a user's browser. The lock is still the token check from Lesson 85, and it must stay on every endpoint.
Apply it to the SMS
Example: the office page
To see CORS for real, we need a page on a different origin. This small single-file app serves one HTML page
from http://localhost:5173, while the API stays on http://localhost:5080.
// Extra 7: a stand-in for the school office's website. It serves one page, wwwroot/index.html, from
// http://localhost:5173: a different origin from the API on http://localhost:5080, so the browser applies CORS.
// Run with: dotnet run extras/e7-cors/office-page.cs then open http://localhost:5173
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
WebApplication app = WebApplication.CreateBuilder(args).Build();
app.UseDefaultFiles(); // "/" serves index.html
app.UseStaticFiles(); // files from wwwroot, next to this file
app.Run("http://localhost:5173");
The page signs in, then asks for class 10-A's student list with fetch(). Any error ends up in the catch
block, which prints it on the page.
Show extras/e7-cors/wwwroot/index.html
<!doctype html>
<!-- Extra 7: the office page. It signs in, then reads 10-A's class list from the SMS API with fetch().
The demo signs in as the sample principal so the page can run by itself; a real page asks the user. -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>SMS office page</title>
</head>
<body>
<h1>Class 10-A</h1>
<pre id="result">Loading...</pre>
<script>
const api = new URLSearchParams(location.search).get("api") ?? "http://localhost:5080";
const result = document.getElementById("result");
async function loadClassList() {
try {
const signIn = await fetch(`${api}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userName: "principal", password: "Demo@2026" })
});
const token = (await signIn.json()).data.accessToken;
const response = await fetch(`${api}/api/classes/10/sections/A/students`, {
headers: { Authorization: `Bearer ${token}` }
});
const body = await response.json();
result.textContent = `${response.status} ${body.message}\n`
+ body.data.map(s => `${s.rollNumber}. ${s.fullName}`).join("\n");
} catch (error) {
// A request refused by CORS ends here: the page only learns "Failed to fetch".
result.textContent = `Failed: ${error.message}`;
}
}
loadClassList();
</script>
</body>
</html>
Example: the error, before CORS
We started the API with only another website allowed, and opened the page in Microsoft Edge. The page showed one line, and the browser's console (F12) explained it:
Class 10-A
Failed: Failed to fetch
Access to fetch at 'http://localhost:5080/api/auth/login' from origin 'http://localhost:5173' 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.
Read the message slowly; it says everything. The very first call, sign-in, already failed. Its JSON body made the browser send a preflight, and the preflight's answer had no permission header.
The page's own code could not see any of this. That is on purpose: a page should not learn anything from a server that did not agree to talk to it.
Example: the allowed websites, as a setting
Which websites may call the API changes from place to place. On your machine it is http://localhost:5173; on
the live server it is the school's real office website. So the list is configuration, like the rate limits in
Lesson 92.
"Cors": {
"AllowedOrigins": [ "http://localhost:5173" ]
},
appsettings.json has an empty list, so a server with no setting allows no websites at all. Each real server
then adds its own website, for example with the setting Cors__AllowedOrigins__0.
A setting can have a typo, so the settings class checks every entry. An origin never has a path or a slash at
the end, because the browser's Origin header never has one.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Cors.Infrastructure;
namespace SchoolApp.Api.Security;
// Extra 7: the "Cors" section of configuration: which websites may call the API from a browser.
// An origin is scheme + host + port, exactly as the browser sends it: "https://office.example.org",
// never with a path or a slash at the end, because the browser's Origin header never has one.
public class CorsSettings : IValidatableObject
{
public const string SectionName = "Cors";
public string[] AllowedOrigins { get; set; } = [];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
foreach (string origin in AllowedOrigins)
{
bool isOrigin = Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
&& origin == uri.GetLeftPart(UriPartial.Authority);
if (!isOrigin)
{
yield return new ValidationResult(
$"'{origin}' is not an origin. Write it like https://office.example.org: no path, no slash at the end.",
[nameof(AllowedOrigins)]);
}
}
}
}
// Extra 7: the one CORS policy of the SMS API.
public static class Cors
{
public const string PolicyName = "SchoolWebsites";
public static void AddPolicy(CorsOptions cors, CorsSettings settings) =>
cors.AddPolicy(PolicyName, policy => policy
.WithOrigins(settings.AllowedOrigins) // only these websites, never "any"
.WithMethods("GET", "POST", "PUT", "DELETE") // the methods the API uses
.WithHeaders("Authorization", "Content-Type") // the token and the JSON body
.WithExposedHeaders("Location", "Retry-After", "api-supported-versions") // headers page code may read
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))); // the browser may reuse a preflight answer for 10 minutes
}
The policy names everything the office page needs, and nothing more. Each line answers one question:
| Line | The question it answers |
|---|---|
WithOrigins | Which websites? Only the ones in the setting. |
WithMethods | Which methods? The four the API uses. |
WithHeaders | Which request headers? The token and the JSON content type. |
WithExposedHeaders | Which response headers may page code read? Location (Lesson 74), Retry-After (Lesson 92), and the version list (Lesson 93). |
SetPreflightMaxAge | How long may the browser remember a "yes"? Ten minutes, so it does not ask before every call. |
Why expose headers? By default, page code can read only a few simple response headers, such as Content-Type.
Without this line, the office page could not read the new student's address from Location after a 201.
Example: wiring it into Program.cs
Two parts: register the policy with the other services, and switch it on in the pipeline.
// Extra 7: CORS. A browser lets a page on another address read the API's answers only when the API says
// so. The websites are a setting: none in appsettings.json, the local office page in Development.
builder.Services.AddOptions<CorsSettings>()
.BindConfiguration(CorsSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
CorsSettings corsSettings = builder.Configuration.GetSection(CorsSettings.SectionName).Get<CorsSettings>()
?? new CorsSettings();
builder.Services.AddCors(cors => Cors.AddPolicy(cors, corsSettings));
// ...
app.UseHttpsRedirection();
app.UseCors(Cors.PolicyName); // Extra 7: before authentication, so a browser's preflight is answered without a token
app.UseAuthentication(); // Lesson 85: who is calling? (reads and checks the token)
ValidateOnStart runs the check when the API starts, not on the first request. We tried a slash at the end, and
the API refused to start with a clear message:
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed
for 'CorsSettings' members: 'AllowedOrigins' with the error: ''http://localhost:5173/' is not an origin. Write it
like https://office.example.org: no path, no slash at the end.'.
Is the check worth it? We tested a tiny app without it. ASP.NET Core accepted http://localhost:5173/ without
complaint and then never matched it: the page simply got no permission.
A typo that stops the start is far easier to find than one that fails quietly.
Example: the page works
With the API restarted in Development, we refreshed the page in Edge:
Class 10-A
200 4 students in 10-A.
1. Aarav Sharma
2. Diya Patel
3. Kabir Singh
4. Meera Iyer
Same page, same code, same API endpoints. The only change is that the API now gives permission to this one website.
Example: a preflight by hand
You can play the browser's part with curl. Send OPTIONS with the same three headers the browser sends, and look
at the answer. (In these outputs, only the headers that matter are shown.)
curl -i -X OPTIONS http://localhost:5080/api/classes/10/sections/A/students \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization"
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Authorization,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Max-Age: 600
Now the same question from a website that is not on the list:
HTTP/1.1 204 No Content
Still 204, which surprises many people. The refusal is the missing Access-Control-Allow-Origin header; the
browser sees no permission and stops there.
Errors carry the header too. A request with no token gets 401, with permission to read it, so the office page can show "Sign in first" instead of "Failed to fetch":
HTTP/1.1 401 Unauthorized
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Expose-Headers: Location,Retry-After,api-supported-versions
WWW-Authenticate: Bearer
{"status":"error","message":"Sign in first: send a valid access token.","traceId":"00-ccbe84425dd082a8aff7ee1e4085d36e-32b9f793023518de-00"}
Example: why the order matters
The preflight never carries a token. So UseCors must answer it before authentication asks for one. To prove
it, we moved UseCors below UseAuthorization and sent the same preflight:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
No permission headers at all, so every browser request would fail before it started. We moved the line back.
Example: tests that act like a browser
The tests send the same requests as the browser and check the headers. They run in Development, where the office page is allowed.
Show tests/SchoolApp.Tests/Api/CorsTests.cs
using System.ComponentModel.DataAnnotations;
using System.Net;
using SchoolApp.Api.Security;
namespace SchoolApp.Tests.Api;
// Extra 7: what a browser sees. The tests run in Development, where appsettings.Development.json allows the
// office page at http://localhost:5173.
[Collection("Api")]
public class CorsTests
{
private const string OfficePage = "http://localhost:5173";
private const string ClassList = "/api/classes/10/sections/A/students";
private readonly ApiFactory _factory;
public CorsTests(ApiFactory factory)
{
_factory = factory;
}
// The browser's preflight: OPTIONS, with the origin and what the real request will use. It carries no token.
private static HttpRequestMessage Preflight(string origin) =>
new HttpRequestMessage(HttpMethod.Options, ClassList)
{
Headers =
{
{ "Origin", origin },
{ "Access-Control-Request-Method", "GET" },
{ "Access-Control-Request-Headers", "authorization" }
}
};
private static string? Header(HttpResponseMessage response, string name) =>
response.Headers.TryGetValues(name, out IEnumerable<string>? values) ? string.Join(",", values) : null;
[Fact]
public async Task Preflight_FromTheOfficePage_IsAllowed_WithoutAToken()
{
HttpResponseMessage response = await _factory.CreateClient().SendAsync(Preflight(OfficePage));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); // not 401: UseCors runs before authentication
Assert.Equal(OfficePage, Header(response, "Access-Control-Allow-Origin"));
Assert.Equal("GET,POST,PUT,DELETE", Header(response, "Access-Control-Allow-Methods"));
Assert.Equal("Authorization,Content-Type", Header(response, "Access-Control-Allow-Headers"));
}
[Fact]
public async Task Preflight_FromAnUnknownWebsite_GetsNoPermission()
{
HttpResponseMessage response = await _factory.CreateClient().SendAsync(Preflight("http://unknown.example"));
// The answer is still 204. What protects the API is the missing header: without it, the browser refuses.
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Null(Header(response, "Access-Control-Allow-Origin"));
}
[Fact]
public async Task RealRequest_FromTheOfficePage_CanBeRead_AndErrorsToo()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);
client.DefaultRequestHeaders.Add("Origin", OfficePage);
HttpResponseMessage list = await client.GetAsync(ClassList);
HttpResponseMessage missing = await client.GetAsync("/api/students/ADM-1999-0001");
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
Assert.Equal(OfficePage, Header(list, "Access-Control-Allow-Origin"));
Assert.Equal("Location,Retry-After,api-supported-versions", Header(list, "Access-Control-Expose-Headers"));
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
Assert.Equal(OfficePage, Header(missing, "Access-Control-Allow-Origin")); // the page can show the message
}
[Theory]
[InlineData("http://localhost:5173", true)]
[InlineData("https://office.example.org", true)]
[InlineData("http://localhost:5173/", false)] // a slash at the end
[InlineData("https://office.example.org/students", false)] // a path
[InlineData("office.example.org", false)] // no scheme
[InlineData("*", false)] // "any website" is not allowed here
public void Settings_AcceptOnlyRealOrigins(string origin, bool valid)
{
CorsSettings settings = new CorsSettings { AllowedOrigins = [origin] };
bool isValid = Validator.TryValidateObject(settings, new ValidationContext(settings), [], validateAllProperties: true);
Assert.Equal(valid, isValid);
}
}
Passed! - Failed: 0, Passed: 189, Skipped: 0, Total: 189, Duration: 11 s - SchoolApp.Tests.dll (net10.0)
The suite grew from 180 to 189 tests: three about requests, and six origins for the settings check.
Common mistakes
- Allowing every website to make the error go away.
AllowAnyOrigin()lets any page on the internet read the API's answers in your users' browsers. Fix: list the exact websites, from configuration. - A slash or path in the origin.
http://localhost:5173/never matches, and nothing tells you. Fix: scheme, host and port only, checked at startup. UseCorsafter authentication. The preflight has no token, so it gets 401 and every browser call fails. Fix:UseCorsbeforeUseAuthentication.- Thinking CORS protects the API. curl and other servers ignore it. Fix: keep the token check and permissions on every endpoint; CORS only protects users inside browsers.
- Debugging from the page's error message. "Failed to fetch" says nothing. Fix: open the browser console and the Network tab, and read the preflight's answer.
In real projects
A team launched their new parent website on a Friday evening. On every developer's machine it had worked for weeks. In production, every page showed a spinner that never stopped.
The production setting listed the website as https://parents.example.org/, with a slash at the end. The
Development setting had allowed every origin, so the mistake never showed up during testing.
The fix was one character, but finding it took most of the night. Afterwards the team made two changes: the same strict policy in every environment, and a check that refuses to start the API when an origin is not written exactly right.
Best practices
- Allow exact origins from configuration; keep the default list empty.
- Validate the origins when the API starts.
- Put
UseCorsbefore authentication, rate limiting, and every endpoint. - Allow only the methods and headers your front end uses; expose only the response headers it must read.
- Use the same policy shape in Development and production, with different origins.
- Keep the real security on the server: tokens, roles, and record-level rules.
Recap
- An origin is scheme, host, and port; the office page on port 5173 and the API on 5080 are different origins.
- The browser lets a page read another origin's answer only when it carries
Access-Control-Allow-Originfor that page. - Requests with a token or a JSON body get a preflight first; with no permission, the real request is never sent.
- The SMS has one policy,
SchoolWebsites, with its origins in configuration and checked at startup. UseCorsruns before authentication, so preflights work without a token.- Nine new tests check the preflight, the refusal, error answers, and the settings: 189 tests in all.
Homework
Library module task: The library wants two kinds of callers. Any website, such as a school blog, may show
the book list. Only the library's kiosk page at http://localhost:5174 may issue loans.
- Write a single-file Library API on
http://localhost:5107withGET /api/books,POST /api/loans, andGET /api/reports/overdue. - Add two named policies:
catalogue(any origin, GET only) anddesk(only the kiosk, POST withContent-Type). - Call
app.UseCors()with no name, and choose the policy on each controller with[EnableCors("...")]. The reports controller gets no policy. - Check with curl: a blog reads the books, the kiosk's preflight for a loan is allowed, the blog's is not, and the report carries no permission header.
A correct result looks like: (headers trimmed)
GET /api/books, Origin: https://blog.example.org
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
OPTIONS /api/loans, Origin: http://localhost:5174
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: http://localhost:5174
OPTIONS /api/loans, Origin: https://blog.example.org
HTTP/1.1 204 No Content
GET /api/reports/overdue, Origin: http://localhost:5174
HTTP/1.1 200 OK
Show the solution
// Library homework, Extra 7: two CORS policies, chosen per endpoint.
// "catalogue": any website may read the book list (a school blog can show new arrivals). GET only, no token.
// "desk": only the library kiosk page at http://localhost:5174 may issue loans.
// UseCors() has no policy name here, so an endpoint without [EnableCors] gets no CORS headers at all.
// Run with: dotnet run library-assignment/e7-library-cors.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddCors(cors =>
{
// AllowAnyOrigin cannot be combined with AllowCredentials: the browser would refuse such an answer anyway.
cors.AddPolicy("catalogue", policy => policy.AllowAnyOrigin().WithMethods("GET"));
cors.AddPolicy("desk", policy => policy.WithOrigins("http://localhost:5174")
.WithMethods("POST")
.WithHeaders("Content-Type"));
});
WebApplication app = builder.Build();
app.UseCors();
app.MapControllers();
app.Run("http://localhost:5107");
public record LoanRequest(string Isbn, int StudentId);
[ApiController]
[Route("api/books")]
[EnableCors("catalogue")]
public class BooksController : ControllerBase
{
[HttpGet]
public IActionResult List() => Ok(new[]
{
new { isbn = "978-0-13-468599-1", title = "The Pragmatic Programmer" },
new { isbn = "978-0-59-651798-1", title = "Head First C#" }
});
}
[ApiController]
[Route("api/loans")]
[EnableCors("desk")]
public class LoansController : ControllerBase
{
[HttpPost]
public IActionResult Issue(LoanRequest request) =>
Ok(new { message = $"{request.Isbn} issued to student {request.StudentId}." });
}
[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
// No [EnableCors]: only same-origin pages and tools like curl can read this.
[HttpGet("overdue")]
public IActionResult Overdue() => Ok(new { overdue = 3 });
}
Is the catalogue safe to open to everyone? Yes: it is public data, read with GET and no token. The loan desk changes data, so it stays with the one website the library controls.
One more thing we noticed: a preflight asking for DELETE on /api/books got 404, because no endpoint answers
DELETE there. The browser treats that as "no", which is what we want.
Quiz
Which pair of addresses has the same origin?
Show answer
Answer: B. https://office.example.org/a and https://office.example.org/b Scheme, host and port all match; the path is not part of the origin.
The API answers a preflight from an unknown website with 204. Why is the website still blocked?
Show answer
Answer: B. The answer has no Access-Control-Allow-Origin header, so the browser refuses The browser looks for the permission header, not the status code.
Why must UseCors come before UseAuthentication?
Show answer
Answer: B. The preflight carries no token, so it would get 401 without CORS headers We tried it: the preflight got 401 and no permission headers.
Does CORS stop curl from calling the API?
Show answer
Answer: C. No; only browsers enforce CORS, so the token check must stay CORS protects users in browsers; the server's own checks protect the API.
Why does the SMS policy expose the Location header?
Show answer
Answer: B. So page code can read the new record's address after a 201 By default, page code can read only a few simple response headers.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is an origin, and when do two addresses have the same origin? concept
An origin is the scheme, host, and port of an address, such as http://localhost:5173. Two addresses have
the same origin only when all three match; the path does not count. A page on port 5173 calling an API on
port 5080 is a cross-origin call.
F What is CORS, and who enforces it? concept
Cross-Origin Resource Sharing. Browsers stop a page from reading answers from another origin unless the
server allows it with the Access-Control-Allow-Origin header. The browser enforces it; curl, Postman, and
other servers ignore it, so the API must still check tokens and permissions itself.
F A page shows 'Failed to fetch' but the same request works in Postman. How do you find out why? scenario
Open the browser's console and Network tab. A CORS refusal is explained in the console, for example "No 'Access-Control-Allow-Origin' header is present". The page's code only gets "Failed to fetch", on purpose, so the console is where the reason is.
Mid (3-5 yrs)
M What is a preflight request, and when does the browser send one? concept
An OPTIONS request the browser sends before the real one, carrying Origin,
Access-Control-Request-Method, and Access-Control-Request-Headers. It is sent for requests that are not
simple: an Authorization header, a JSON body, or methods like PUT and DELETE. Without permission in the
answer, the real request is never sent.
M Where does UseCors go in the ASP.NET Core pipeline, and what breaks if it is too late? coding
Before UseAuthentication and UseAuthorization. The preflight carries no token, so if authorization runs
first, the preflight gets 401 with no CORS headers and every browser call fails. In the SMS, moving it after
UseAuthorization turned the preflight's 204 into a 401.
M An unknown website's preflight gets 204 from your API. Is the API open to it? scenario
No. ASP.NET Core answers the preflight with 204 either way. What matters is the missing
Access-Control-Allow-Origin header: without it the browser refuses, and the real request is never sent.
Test for the header, not the status code.
Senior (6-9 yrs)
S How would you configure CORS for an API used by several websites across environments? design
One named policy with exact origins read from configuration: empty by default, the local front end in Development, the real websites per server. Validate the origins at startup (no path, no trailing slash), allow only the methods and headers the front ends use, expose only the response headers they must read, set a preflight max age, and cover it with tests that send preflights from allowed and unknown origins.
How to answer: Cover exact origins from configuration, startup validation, least privilege for methods and headers, exposed headers, caching, and tests.
S Why is AllowAnyOrigin risky, and when is it acceptable? concept
It lets any website read the API's answers inside your users' browsers, which matters most when the browser sends credentials automatically, such as cookies; ASP.NET Core refuses to combine it with AllowCredentials. It is acceptable for truly public, read-only data with no credentials, such as a public book catalogue, in a separate policy on those endpoints only.
How to answer: Explain who is protected, the credentials rule, and a narrow per-endpoint policy for public data.
Architect / Lead (10+ yrs)
A Many teams' front ends call your organisation's APIs. How do you keep CORS correct and safe at scale? design
Avoid cross-origin calls where you can: serve the front end and API under one origin through a gateway or reverse proxy, or use a backend-for-frontend. Where CORS is needed, manage the allowed origins centrally per environment, validate them in CI and at startup, keep policies identical across environments except for origins, and test preflights in the pipeline. Never treat CORS as authentication; tokens and authorization stay on every API.
How to answer: Cover same-origin options, central origin management, validation, environment parity, tests, and the limits of CORS as a security control.
Resources
- Code for this lesson: git tag
extra-7-cors(code repository coming soon) - Files:
src/SchoolApp.Api/Security/Cors.cs,src/SchoolApp.Api/Program.cs,src/SchoolApp.Api/appsettings.Development.json,extras/e7-cors/office-page.cs,extras/e7-cors/wwwroot/index.html,tests/SchoolApp.Tests/Api/CorsTests.cs,library-assignment/e7-library-cors.cs - Video: coming soon
What's next
The office website can now talk to the SMS API from the browser. The next extra turns the direction around: the
SMS API calling other services with HttpClient, and retrying safely when they fail. Until it is published,
continue with the capstone: Lesson 97: From one school to 1,000
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.