Back to blog
Backend Systemsintermediate

Fix CORS Errors Forever — Plus Add Security Headers

Understand why CORS exists, configure it correctly in ASP.NET Core without the AllowAnyOrigin trap, handle preflight requests, and harden your API with Content-Security-Policy, HSTS, and friends.

LearnixoApril 14, 20265 min read
.NETC#CORSSecurity HeadersCSPHSTSASP.NET Core
Share:𝕏

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

C#
// 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:

C#
app.UseCors("AllowFrontend"); // global default
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Override per endpoint:

C#
[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:

C#
// 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.

C#
// 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, Authorization

The 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: 600

ASP.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:

  1. UseCors is before UseRouting/MapControllers
  2. The requesting origin is in WithOrigins exactly (no trailing slash)
  3. The Authorization header is listed in WithHeaders or AllowAnyHeader is 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

C#
// 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:

C#
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseCors("AllowFrontend");
app.UseAuthentication();

HSTS via Built-In Middleware

C#
// Only runs in non-development environments
if (!app.Environment.IsDevelopment())
{
    app.UseHsts(); // adds Strict-Transport-Security header
}

app.UseHttpsRedirection(); // redirects HTTP -> HTTPS

Configure HSTS options:

C#
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:

C#
// 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 WithOrigins with explicit URLs; never AllowAnyOrigin + AllowCredentials
  • Preflight is automatic when UseCors is in the pipeline — don't handle OPTIONS yourself
  • Security headers are not set by default — add a middleware or use AddHsts/UseHsts
  • Content-Security-Policy is the hardest but most impactful header; start with 'self' and tighten

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.