CORS
The API runs on port 5080, and the frontend page runs on port 5178. By default, a browser does not let the page read the API's response. This chapter configures Cross-Origin Resource Sharing (CORS) so a local frontend can call the API and read its results.
Add a CORS policy to the previous chapter's code. Here are the complete files:
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanWriteTodos", policy => policy.RequireAuthenticatedUser().RequireRole("editor"));
});
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.WithExposedHeaders("Location")));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
app.MapGet("/me", (ClaimsPrincipal user) => new { Name = user.Identity?.Name }).RequireAuthorization();
var todos = app.MapGroup("/todos").WithTags("Todos").RequireAuthorization();
todos.MapGet("/", async (TodoDbContext db) =>
await db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());
todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is null ? TypedResults.NotFound()
: TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});
todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}",
new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).RequireAuthorization("CanWriteTodos");
app.Run();using System.ComponentModel.DataAnnotations;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}The allowed frontend address comes from configuration:
{
"ConnectionStrings": {
"Todos": "Data Source=todos-20.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"Cors": {
"Origins": [
"http://localhost:5178"
]
}
}First, understand origins
An origin consists of a protocol, host, and port:
| Address | Same origin as http://localhost:5080? |
|---|---|
http://localhost:5080/todos | Yes; a different path does not affect the origin |
http://localhost:5178 | No; the port differs |
http://127.0.0.1:5080 | No; the host differs |
https://localhost:5080 | No; the protocol differs |
The browser's same-origin policy restricts scripts from reading responses from another origin. With CORS configured, the server can allow pages from specified origins to read a response.
Start the API
Stop the previous chapter's service, then run these commands from the repository root:
cd samples/20-cors
dotnet user-jwts create --name alice --role editor --valid-for 1h --output token
dotnet runCopy the complete token printed by the tool for later. Generate the token in this chapter's project. The database is a new todos-20.db, whose Todo list starts empty; the categories remain Work (1) and Life (2).
Verify using a real browser page
The sample includes an HTML page served locally by Node.js; it does not require installing npm packages:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Todo CORS Demo</title>
</head>
<body>
<h1>Todo CORS Demo</h1>
<p>Page origin: localhost:5178; API origin: localhost:5080.</p>
<label for="token">Local test token (kept only in this page memory)</label>
<input id="token" type="password" autocomplete="off" spellcheck="false" size="48">
<button id="read" type="button">Read Todos</button>
<button id="create" type="button">Create Todo</button>
<pre id="output" role="status" aria-live="polite">Paste the editor token generated in this chapter.</pre>
<script type="module">
const token = document.querySelector('#token');
const output = document.querySelector('#output');
async function request(method) {
if (!token.value.trim()) {
output.textContent = 'Please enter a local test token.';
return;
}
const headers = { Authorization: `Bearer ${token.value.trim()}` };
const options = { method, headers, credentials: 'omit' };
if (method === 'POST') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify({ title: 'Browser todo', categoryId: 1 });
}
try {
const response = await fetch('http://localhost:5080/todos', options);
const body = await response.text();
output.textContent = `HTTP ${response.status}\nLocation: ${response.headers.get('Location') ?? '(none)'}\n${body}`;
} catch {
output.textContent = 'Request failed: Check that the API is running and inspect the Network panel for errors.';
}
}
document.querySelector('#read').addEventListener('click', () => request('GET'));
document.querySelector('#create').addEventListener('click', () => request('POST'));
</script>
</body>
</html>import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const page = await readFile(new URL('./index.html', import.meta.url));
createServer((request, response) => {
if (request.url !== '/') {
response.writeHead(404).end();
return;
}
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
}).end(page);
}).listen(5178, '127.0.0.1', () => {
console.log('Open http://localhost:5178/');
});Open another terminal, go to samples/20-cors, and run:
node browser/serve.mjsExpected output:
Open http://localhost:5178/Open the page through this HTTP address; do not double-click the HTML file to open it with file://. Paste the token into the page's input and click “Read Todos.” On the first run, the result is:
HTTP 200
Location: (none)
[]Then click “Create Todo.” The result is:
HTTP 201
Location: /todos/1
{"id":1,"title":"Browser todo","done":false,"categoryId":1}This ID assumes a new database; clicking again creates another Todo. The token stays in the current page's memory and is not written to localStorage, a Cookie, or a server file.
Preflight: ask if the request is allowed before sending it
Open the browser developer tools and select the Network panel. You can see an OPTIONS preflight request. This example manually sends Authorization, and POST also uses application/json; these conditions trigger a preflight. POST is not the only method that triggers one. The browser may cache preflight results, so an OPTIONS request may not appear for every click.
Use curl to inspect the preflight response. This command does not include a JWT or create a Todo:
curl -i -X OPTIONS http://localhost:5080/todos -H "Origin: http://localhost:5178" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: authorization,content-type"The relevant response headers are:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5178
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Authorization,Content-TypeThe preflight does not carry the actual request's Bearer token, so it must be handled before authentication and authorization. This example calls UseRouting() first to identify the endpoint, then UseCors() to handle preflight. Actual GET and POST requests still require a valid token, and writes still require the editor role.
The policy's four settings
| Setting | Purpose |
|---|---|
WithOrigins(...) | Allow the exact origin http://localhost:5178; it does not include a path or trailing slash |
WithMethods(...) | Allow the frontend to use GET, POST, PUT, and DELETE |
WithHeaders(...) | Allow the actual request to include Authorization and Content-Type |
WithExposedHeaders("Location") | Allow frontend JavaScript to read the Location response header |
Why configure request and response headers separately? Allowing a request to send Authorization does not allow it to read any response header. Location is not exposed to cross-origin scripts by default, so it must be declared separately. Otherwise, the browser's Network panel may show it, but response.headers.get('Location') returns null.
This example manually sends a Bearer header and uses credentials: 'omit' to prevent the browser from attaching a Cookie, so AllowCredentials() is not needed. If you later use Cookie-based sign-in, configure credentials separately and defend against cross-site request forgery (CSRF). ASP.NET Core CORS documentation
A disallowed origin does not necessarily get a 403
Change the Origin in the preflight command to http://localhost:5179 and run it again. This example still returns 204, but does not include Access-Control-Allow-Origin, so the browser will not approve the follow-up cross-origin request.
curl does not enforce the browser's same-origin policy. With a valid token, a direct curl request may still reach a handler even if it includes a disallowed Origin; the response simply lacks the CORS permission header. Some browser requests that do not require preflight may also reach the server, but the script cannot read their responses.
CORS does not replace authentication and authorization. JWT and CanWriteTodos prevent writes by callers without permission in this chapter; CORS determines whether a browser page may read a response. How CORS works
Tip
When you see a “CORS error” in the browser, first inspect the preflight and actual request in Network: check that the API is running, the Origin matches exactly, the method and request headers are allowed, and whether the actual request received 401 or 403. Do not change business permissions just because the browser reported an error.
FastAPI comparison
This corresponds to FastAPI / Starlette's CORSMiddleware: configure allowed origins, methods, request headers, and readable response headers separately. Browser preflight and same-origin rules are the same regardless of the server framework.
Summary
- An origin is defined by protocol, host, and port. CORS allows browser scripts to read cross-origin responses from specified origins.
- Configure origins, methods, and request headers precisely. To read response headers such as Location, expose them explicitly as well.
- Preflight checks cross-origin permission; actual requests still need authentication and authorization. Place CORS middleware before authentication and authorization.
- A disallowed origin may still receive an HTTP response, but without a permission header. A successful curl request does not prove that browser CORS is configured correctly.
- CORS does not provide user permissions or data isolation, and it does not replace CSRF protection.
This chapter completes the “Security” stage. Next: Testing—use automated checks to protect these behaviors. Previous: Authorization.
