Mert Özen Further With Every Line
Backend .NET Core Web API — #24

CORS and Security in ASP.NET Core Web API: HTTPS, HSTS, Headers and Secrets

Mert Özen Nov 4, 2026 8 min 1 views
CORS and Security in ASP.NET Core Web API: HTTPS, HSTS, Headers and Secrets

We see with curl why CORS is not a firewall, define a policy and read the preflight request. HTTPS redirection, the localhost exception in HSTS, security headers and user secrets are covered too.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

In the last two posts we taught the API who is talking and who is allowed to do what. Now we look at the surface where the API touches the outside world: requests from browsers, HTTPS, response headers and secret keys.

Here is the scenario. An Angular app running at http://localhost:4200 asks the API at https://localhost:7080 for the product list. The API has no CORS configuration. A red CORS error appears in the browser console. The question: did the request reach the server, and did the controller run?

Same-Origin Policy and CORS

An origin is the scheme, host and port of an address. http://localhost:4200 and https://localhost:7080 are different origins; the port and the scheme differ. Through a rule called the same-origin policy, browsers by default stop JavaScript on one page from reading a response that comes from another origin. CORS (Cross-Origin Resource Sharing) is a header protocol that lets the server relax this rule in a controlled way by saying "I trust these origins".

The answer to the guess hides right here: yes, the request reached the server and the controller ran. It is not the server that blocks, it is the browser. The browser received the response, did not find an Access-Control-Allow-Origin header in it, and did not hand the result to JavaScript. To see this with my own eyes I sent a DELETE request to the CORS-configured API from an origin I had not allowed (-k tells curl not to try to validate the local development certificate):

curl -k -i -X DELETE https://localhost:7080/api/products/2 -H "Origin: https://evil.example.com"
HTTP/1.1 204 No Content
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

The product was deleted. There is no CORS header in the response, so if a browser had sent this request, JavaScript would not have been able to see the result, but the deletion had already happened. The lesson is important: CORS is not access control, it is an instruction to the browser. curl, Postman or another server do not care about CORS at all. Protecting the API is the job of authentication and authorization; CORS only makes it harder for third-party pages in the browser to talk on the user's behalf.

Think of a building's doorman. The doorman checks against his list which courier companies may enter the building. But for someone walking around through the back garden, the list means nothing; the apartment doors still need locks.

Defining a CORS Policy

CORS ships inside ASP.NET Core; no extra package is needed. We name the policy and define it in Program.cs:

using ProductCatalog.Security;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    // We trust only the known frontend addresses
    options.AddPolicy("Frontend", policy =>
        policy.WithOrigins("https://shop.example.com", "http://localhost:4200")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Content-Type", "Authorization")
              .WithExposedHeaders("X-Total-Count")
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});

// Remove the "Server: Kestrel" header from responses
builder.WebHost.ConfigureKestrel(options =>
{
    options.AddServerHeader = false;
});

builder.Services.AddControllers();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseSecurityHeaders();
app.UseCors("Frontend");

app.UseAuthorization();

app.MapControllers();

app.Run();

WithOrigins wants the addresses written exactly: scheme and port included, without a trailing /. WithExposedHeaders is a setting people often forget. In the pagination post we returned the total record count in an X-Total-Count header; by default the browser shows only a few basic headers to JavaScript, so if you want it to see this one, you have to expose it explicitly.

The position of UseCors matters too: it must come before the authentication and authorization middleware. The browser's preflight request carries no token. When I added CORS to the previous post's API, the one with JWT and a FallbackPolicy, and placed UseCors after UseAuthorization, the preflight returned 401 and the browser never sent the real request. With UseCors moved to the front, the same preflight returned 204. Since this post's example has no authentication, you only see UseAuthorization; when you combine the two, the order is UseCors, UseAuthentication, UseAuthorization.

For a request from an allowed origin, the response turns into this:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Expose-Headers: X-Total-Count
Transfer-Encoding: chunked
Vary: Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
X-Total-Count: 2

Vary: Origin tells caches in between that "this response varies by the Origin header"; otherwise a response produced for one origin could be served to another.

[EnableCors] and [DisableCors]

If you want to apply the policy per controller or action instead of to the whole app, call UseCors() without a parameter and use the attributes:

[ApiController]
[Route("api/products")]
[EnableCors("Frontend")]
public class ProductsController : ControllerBase

When I put [DisableCors] on an action in the same controller, the preflight for that action returned 204 but carried no Access-Control-* header at all; the browser blocked the request.

Reading the Preflight Request

The browser does not send every request the same way. It sends "simple" requests such as GET directly. Before a DELETE, a PUT, or a request carrying an Authorization header, however, it sends a preflight request with the OPTIONS method and asks for permission. We can imitate it with curl:

curl -k -i -X OPTIONS https://localhost:7080/api/products/2 -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: DELETE" -H "Access-Control-Request-Headers: authorization"
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Max-Age: 600
Vary: Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

Access-Control-Max-Age: 600 lets the browser remember this answer for 10 minutes; a new preflight is not sent before every DELETE. I got two more interesting results. A preflight from a disallowed origin also returned 204, but without a single Access-Control-* header. When I sent Access-Control-Request-Method: PATCH, the server again returned 204 and the list of allowed methods; it was the browser that saw PATCH missing from the list and stopped the request. So in the preflight, too, the server states the decision and the browser enforces it.

AllowAnyOrigin and Credentials Do Not Mix

During development it is tempting to write this so that "everything can come in":

    options.AddPolicy("Everyone", policy =>
        policy.AllowAnyOrigin()
              .AllowAnyHeader()
              .AllowCredentials());

Even though I did not use this policy anywhere, the app crashed on startup:

Unhandled exception. System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.

The CORS specification forbids answering requests that come with credentials, such as cookies, with Access-Control-Allow-Origin: *. ASP.NET Core catches this right at the start. In some projects you will see SetIsOriginAllowed(_ => true) written to get around this error; that means sidestepping the ban and opening the door to every origin with credentials. Read the origins from configuration and list them explicitly.

HTTPS Redirection and HSTS

UseHttpsRedirection redirects a request that arrives over HTTP to the HTTPS address:

HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Location: https://localhost:7080/api/products

But for an API, the redirect alone is not enough. If the client sent the first request over HTTP, the token has already traveled unencrypted. This is where HSTS comes in: with the Strict-Transport-Security header the server tells the browser "from now on, come to me only over HTTPS", and for the given period the browser does not even try HTTP.

As in the template, we turned on UseHsts only outside Development. Running the app in the Production environment brought a surprise: the response from https://localhost:7080 had no HSTS header. The reason is HstsOptions.ExcludedHosts: the default list contains localhost, 127.0.0.1 and [::1], so that you do not accidentally lock HSTS onto your own machine. When I sent the same request with the name api.test, the header came:

curl -k -i --resolve api.test:7080:127.0.0.1 https://api.test:7080/api/products
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains

The 307 response to the request over HTTP had no HSTS header; per the specification, this header only means something on HTTPS responses. Turn on IncludeSubDomains and a long MaxAge only once you are sure all your subdomains are ready for HTTPS; the browser remembers this decision for a year.

Security Headers

Even though the API returns JSON, a few response headers add a cheap layer of protection. We add them to every response with a small middleware:

namespace ProductCatalog.Security;

public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public SecurityHeadersMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public Task InvokeAsync(HttpContext context)
    {
        var headers = context.Response.Headers;
        // The browser should not try to guess the Content-Type
        headers["X-Content-Type-Options"] = "nosniff";
        // Responses must not be embedded in another site's iframe
        headers["X-Frame-Options"] = "DENY";
        headers["Referrer-Policy"] = "no-referrer";
        // An API that returns JSON has no need to load scripts, styles or images
        headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";

        return _next(context);
    }
}

public static class SecurityHeadersExtensions
{
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
        app.UseMiddleware<SecurityHeadersMiddleware>();
}

Because we write the headers before calling _next, we do not fall into the error of trying to add headers after the response has started. nosniff stops the browser from mistaking a JSON response for HTML or a script; frame-ancestors 'none' and X-Frame-Options: DENY are against clickjacking. Notice also that the Server: Kestrel line is missing from the outputs above: the AddServerHeader = false setting removes this piece of information, which tells an attacker which server you run. On its own it hardly counts as a security measure, but it is free.

Secret Management

The JWT signing key, the database password and third-party API keys must not go into the repository. For the development environment there are user secrets:

dotnet user-secrets init
dotnet user-secrets set "Jwt:SigningKey" "secret-from-user-secrets-0123456789abcdef"

The first command adds a UserSecretsId to the csproj, the second writes the value outside the project, into a JSON file in the user profile. WebApplication.CreateBuilder reads this source only in the Development environment: when I ran the same app in Development, the Jwt:SigningKey value came through, in Production it returned null. User secrets are not an encryption tool, only a "keep it outside the source code" tool.

In production you provide the values as environment variables. The : separator in configuration is written as a double underscore: Jwt__SigningKey. In the JWT post we tried the short key error in exactly this way, by changing the Jwt__SigningKey variable. Larger systems use a secret store such as Azure Key Vault or AWS Secrets Manager together with its configuration provider. Whichever you choose, the rule is the same: the code does not know where the secret comes from; it only reads it through IOptions<JwtOptions>.

Common Pitfalls

Thinking of CORS as a firewall. As in the DELETE example at the start, even a request that trips over CORS may already have run on the server.

Placing UseCors after authentication and authorization. Preflight requests carry no token and come back with a 401; I saw this the same way on .NET 6 and .NET 10.

Writing the origin incompletely. http://localhost:4200/ (with a trailing /) or localhost:4200 (without a scheme) does not match the Origin value the browser sends.

Turning on HSTS with a one-year MaxAge on the first try. The browser remembers this decision for that whole period; if something goes wrong on the HTTPS side, users have no way back to the site over HTTP. Start with a short period and extend it if all is well. Localhost is already protected by the default ExcludedHosts list.

Leaving the login endpoint unlimited. You need to limit requests against brute-force attempts. The built-in rate limiting middleware arrived with .NET 7; on .NET 6 you solve this at the reverse proxy or API gateway layer. We will see the built-in middleware in the upcoming version posts.

A Small Experiment

★☆☆ Read the WithOrigins list from a Cors:AllowedOrigins array in appsettings.json. Provide different lists for Production and Development.

★★☆ Remove the X-Total-Count header from WithExposedHeaders. Send a request with fetch from a simple HTML page and see in the browser that response.headers.get("X-Total-Count") returns null.

★★★ Make SecurityHeadersMiddleware configurable with the options pattern: let the headers to add come from configuration, and allow a different Content-Security-Policy for endpoints that return HTML, such as Swagger UI.

The API's outer surface is sturdier now. But as the API grows, another question will come up: when you need to change the shape of ProductDto, how do you avoid breaking the mobile app that depends on the old one? In the next post we move on to API versioning and set up versioning by URL, query string and header with the Asp.Versioning.Mvc package.