REST API Engineering · Lesson 14 of 19
Fix CORS & Add Security Headers in .NET
What CORS Actually Is
CORS (Cross-Origin Resource Sharing) is enforced by the browser, not the server. When JavaScript on https://app.example.com calls https://api.example.com, the browser checks whether the server explicitly permits that origin. If not, the browser blocks the response — the server already processed the request.
This is a browser security feature. Server-to-server calls (curl, HttpClient, Postman) are never blocked by CORS.
Origins are scheme + host + port: https://app.example.com and http://app.example.com are different origins.
AddCors With Named Policies
// Program.cs
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy
.WithOrigins("https://app.example.com", "https://staging.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.WithHeaders("Content-Type", "Authorization", "X-Request-Id")
.AllowCredentials() // needed for cookies or auth headers
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // cache preflight response
});
options.AddPolicy("PublicReadOnly", policy =>
{
policy
.AllowAnyOrigin()
.WithMethods("GET")
.AllowAnyHeader();
// Note: cannot call AllowCredentials() with AllowAnyOrigin()
});
});Apply the pipeline middleware before routing:
app.UseCors("AllowFrontend"); // global default
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();Override per endpoint:
[EnableCors("PublicReadOnly")]
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(_catalog);
[DisableCors]
[HttpGet("internal-status")]
public IActionResult InternalStatus() => Ok("ok");The AllowAnyOrigin + AllowCredentials Trap
This code throws at startup:
// THROWS InvalidOperationException
policy.AllowAnyOrigin().AllowCredentials();Allowing credentials (cookies, Authorization headers) with a wildcard origin is a security hole — the browser would send your users' credentials to any attacker page that makes a cross-origin request. ASP.NET Core refuses to configure it.
Fix: always list explicit origins when credentials are involved.
// Correct
policy.WithOrigins("https://app.example.com").AllowCredentials();Preflight Requests
For non-simple requests (anything with a body, custom headers, or non-GET/POST methods), the browser sends an OPTIONS preflight before the real request:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationThe server must respond with:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600ASP.NET Core handles this automatically when UseCors is in the pipeline. You do not need to handle OPTIONS manually. If you're seeing preflight failures, check:
UseCorsis beforeUseRouting/MapControllers- The requesting origin is in
WithOriginsexactly (no trailing slash) - The
Authorizationheader is listed inWithHeadersorAllowAnyHeaderis used
Security Headers via Middleware
Browsers respect HTTP response headers to restrict what a page or API response can do. None of them are set by default in ASP.NET Core.
Custom Middleware Approach
// SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var headers = context.Response.Headers;
// Prevent the page from being embedded in iframes (clickjacking)
headers["X-Frame-Options"] = "DENY";
// Stop the browser from MIME-sniffing the content type
headers["X-Content-Type-Options"] = "nosniff";
// Don't send the Referer header when navigating away
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
// Allow only HTTPS for 1 year; include subdomains
headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
// Basic CSP — tighten per your app's needs
headers["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none';";
// Opt out of browser features you don't use
headers["Permissions-Policy"] =
"geolocation=(), microphone=(), camera=(), payment=()";
await _next(context);
}
}Register it early in the pipeline:
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseCors("AllowFrontend");
app.UseAuthentication();HSTS via Built-In Middleware
// Only runs in non-development environments
if (!app.Environment.IsDevelopment())
{
app.UseHsts(); // adds Strict-Transport-Security header
}
app.UseHttpsRedirection(); // redirects HTTP -> HTTPSConfigure HSTS options:
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true; // submit to browser preload lists
});Content-Security-Policy for APIs
If your API only serves JSON (not HTML), your CSP can be minimal — but still set it:
Content-Security-Policy: default-src 'none'This tells the browser: nothing on this origin can load any sub-resources. Appropriate for pure APIs.
For apps that serve HTML with scripts:
// Nonce-based CSP — stronger than 'unsafe-inline'
public async Task InvokeAsync(HttpContext context)
{
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
context.Items["csp-nonce"] = nonce;
context.Response.Headers["Content-Security-Policy"] =
$"default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'nonce-{nonce}'";
await _next(context);
}In your Razor view, emit nonce="@Context.Items["csp-nonce"]" on each <script> and <style> tag.
Header Checklist
| Header | Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS for 1 year |
| X-Content-Type-Options | nosniff | Disable MIME sniffing |
| X-Frame-Options | DENY or SAMEORIGIN | Prevent clickjacking |
| Content-Security-Policy | per-app | Control what resources load |
| Referrer-Policy | strict-origin-when-cross-origin | Limit URL leakage |
| Permissions-Policy | geolocation=(),... | Disable unused browser APIs |
Verify your headers at securityheaders.com after deploying.
Key Takeaways
- CORS is a browser mechanism — it does not protect your API from server-to-server calls
- Use named policies and
WithOriginswith explicit URLs; neverAllowAnyOrigin+AllowCredentials - Preflight is automatic when
UseCorsis in the pipeline — don't handleOPTIONSyourself - Security headers are not set by default — add a middleware or use
AddHsts/UseHsts Content-Security-Policyis the hardest but most impactful header; start with'self'and tighten