ASP.NET Core API Security Checklist: CORS, Rate Limiting, Security Headers & Input Validation

Four Layers Every API Needs

Most API security incidents don't happen because attackers found a sophisticated zero-day. They happen because a developer left AllowAnyOrigin() in production, forgot to add rate limiting to the login endpoint, or trusted user input without validating it. The fixes aren't complex — they're just easy to skip when you're focused on shipping features.

This checklist covers the four layers that every ASP.NET Core 8 API needs before it touches production traffic: CORS to control who can call your API from a browser, rate limiting to stop abuse, security headers to harden HTTP responses, and input validation to reject malformed data at the door. Each section gives you working code you can drop into an existing project today.

None of these replace authentication or authorisation. They work alongside JWT or cookie auth to form a defence-in-depth posture — the kind that catches problems at multiple layers rather than betting everything on one mechanism.

CORS: Lock Down Who Can Call Your API

CORS misconfiguration is one of the most common API security mistakes in .NET projects. Two errors dominate: using AllowAnyOrigin() on an API that handles authentication, and forgetting to configure CORS at all so the browser blocks legitimate frontend calls.

The correct pattern is a named policy per environment. Strict origins in production, localhost for development, applied selectively per route group rather than globally where possible.

Program.cs — CORS Configuration
var builder = WebApplication.CreateBuilder(args);

// Named CORS policies — never use a single AllowAnyOrigin policy for authenticated APIs
builder.Services.AddCors(options =>
{
    // Production policy: locked to known origins only
    options.AddPolicy("ProductionPolicy", policy =>
        policy
            .WithOrigins(
                "https://app.yourcompany.com",
                "https://admin.yourcompany.com")
            .WithMethods("GET", "POST", "PUT", "DELETE")
            .WithHeaders("Authorization", "Content-Type", "X-Tenant-Id")
            .AllowCredentials()              // only safe because origins are explicit
            .SetPreflightMaxAge(TimeSpan.FromMinutes(10)));

    // Development policy: allows localhost ports used by the SPA dev server
    options.AddPolicy("DevelopmentPolicy", policy =>
        policy
            .WithOrigins(
                "http://localhost:3000",
                "http://localhost:5173",
                "https://localhost:7001")
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials());
});

var app = builder.Build();

// Apply the correct policy per environment
var corsPolicy = app.Environment.IsProduction()
    ? "ProductionPolicy"
    : "DevelopmentPolicy";

app.UseCors(corsPolicy);

// Apply a different CORS policy to a specific route group
// (e.g., a public webhook endpoint that legitimately allows any origin)
app.MapGroup("/webhooks")
   .RequireCors(options => options.AllowAnyOrigin().WithMethods("POST"))
   .MapPost("/stripe",  HandleStripeWebhook)
   .MapPost("/github",  HandleGithubWebhook);

The SetPreflightMaxAge call tells browsers to cache the preflight OPTIONS response for 10 minutes, which eliminates the double-request overhead on every authenticated API call from your SPA. Without it, every POST or PUT triggers a preflight first.

What to audit: Search your codebase for AllowAnyOrigin. Every match needs a justification. If the endpoint handles authentication, uses cookies, or returns user data — it's a vulnerability, not a convenience.

Rate Limiting: Stop Abuse Before It Costs You

Without rate limiting, your login endpoint is a free brute-force target, your search endpoint can be scraped at will, and a single misconfigured client can spike your database connections. ASP.NET Core 8's built-in rate limiting middleware handles all of this with zero external dependencies.

Apply tighter limits to sensitive endpoints — authentication, password reset, email sending. Apply looser limits to read-heavy endpoints. Don't apply rate limiting uniformly; a 100 req/min limit is appropriate for a login endpoint but destructive on a high-traffic product listing endpoint.

Program.cs — Rate Limiting Policies
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // ── Authentication endpoints: strict fixed window ──────────────────
    // 5 attempts per IP per minute before lockout
    options.AddFixedWindowLimiter("auth", limiter =>
    {
        limiter.Window            = TimeSpan.FromMinutes(1);
        limiter.PermitLimit       = 5;
        limiter.QueueLimit        = 0;           // reject immediately, no queuing
        limiter.QueueProcessingOrder = QueueProcessingOrder.NewestFirst;
    });

    // ── General API: sliding window per IP ────────────────────────────
    // 100 requests per minute, smoothed to prevent window-edge spikes
    options.AddSlidingWindowLimiter("api", limiter =>
    {
        limiter.Window            = TimeSpan.FromMinutes(1);
        limiter.SegmentsPerWindow = 6;           // 10-second segments
        limiter.PermitLimit       = 100;
        limiter.QueueLimit        = 0;
    });

    // ── Expensive operations: token bucket ────────────────────────────
    // Burst of 10 allowed, refills at 2 tokens/second
    options.AddTokenBucketLimiter("expensive", limiter =>
    {
        limiter.TokenLimit          = 10;
        limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
        limiter.TokensPerPeriod     = 2;
        limiter.AutoReplenishment   = true;
        limiter.QueueLimit          = 5;
    });

    // ── 429 response: always return Retry-After header ────────────────
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.Headers.RetryAfter = "60";
        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            type   = "https://tools.ietf.org/html/rfc6585#section-4",
            title  = "Too Many Requests",
            status = 429,
            detail = "Rate limit exceeded. Please wait before retrying."
        }, token);
    };
});

app.UseRateLimiter();

// Apply policies per endpoint or route group
app.MapPost("/auth/login",          HandleLogin)       .RequireRateLimiting("auth");
app.MapPost("/auth/forgot-password", HandleForgotPassword).RequireRateLimiting("auth");
app.MapGet("/products/search",       HandleSearch)     .RequireRateLimiting("api");
app.MapPost("/reports/generate",     HandleReport)     .RequireRateLimiting("expensive");

The Retry-After header on 429 responses is important for API clients — it tells them exactly how long to wait before retrying rather than implementing their own backoff logic. Well-behaved clients will respect it. Badly-behaved clients that ignore it are making a choice you can escalate to an IP block at the infrastructure layer.

Security Headers: Harden Every Response

HTTP response headers are the cheapest security win available. Adding four or five headers to your middleware pipeline takes ten minutes and neutralises entire classes of browser-based attacks: clickjacking, MIME sniffing, protocol downgrade, and information leakage from the Server header.

The headers below are the baseline for any production API. Adjust Content-Security-Policy based on whether your API serves browser content or is a pure JSON API — a pure JSON API can use a restrictive CSP that blocks everything.

Middleware/SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext ctx)
    {
        var headers = ctx.Response.Headers;

        // Prevent MIME type sniffing — browser must honour Content-Type header
        headers["X-Content-Type-Options"] = "nosniff";

        // Block clickjacking — page cannot be rendered in an iframe
        headers["X-Frame-Options"] = "DENY";

        // Force HTTPS for 1 year, include subdomains, allow preload
        // Only add after you're certain your domain never needs HTTP
        headers["Strict-Transport-Security"] =
            "max-age=31536000; includeSubDomains; preload";

        // Control referrer information sent with cross-origin requests
        headers["Referrer-Policy"] = "strict-origin-when-cross-origin";

        // Restrict browser features — disable camera/mic/geolocation by default
        headers["Permissions-Policy"] =
            "camera=(), microphone=(), geolocation=(), payment=()";

        // CSP for a pure JSON API — blocks all content rendering attempts
        // Adjust if your API also serves HTML (Swagger UI, etc.)
        headers["Content-Security-Policy"] =
            "default-src 'none'; frame-ancestors 'none'";

        // Prevent caching of authenticated API responses in shared proxies
        if (ctx.User.Identity?.IsAuthenticated == true)
            headers["Cache-Control"] = "no-store, no-cache, must-revalidate";

        // Remove information-disclosure headers set by Kestrel / IIS
        headers.Remove("Server");
        headers.Remove("X-Powered-By");
        headers.Remove("X-AspNet-Version");
        headers.Remove("X-AspNetMvc-Version");

        await next(ctx);
    }
}

// Register in Program.cs — must come before UseRouting
app.UseMiddleware<SecurityHeadersMiddleware>();

Verify your headers with securityheaders.com after deploy. A production API should score A or A+. The Strict-Transport-Security preload flag is a commitment — once browsers cache it, there's no way to revert to HTTP, so only add it when you're certain your domain will always serve HTTPS.

Input Validation: Reject Bad Data at the Door

Every byte that enters your API from the outside world is untrusted. Validation isn't just about user experience error messages — it's the first line of defence against injection attacks, buffer overflows, and logic bugs caused by unexpected data shapes. Validate before any business logic runs.

ASP.NET Core 8 Minimal APIs don't automatically validate request bodies — you have to wire validation explicitly. The pattern below uses a generic endpoint filter so validation runs on every handler automatically, without per-handler boilerplate.

Filters/ValidationFilter.cs — Automatic Request Validation
using FluentValidation;

// Generic endpoint filter — runs before any handler that receives a T
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx,
        EndpointFilterDelegate next)
    {
        // Find the argument of type T in the handler's parameter list
        var model = ctx.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (model is null)
            return Results.BadRequest("Request body is required.");

        var result = await validator.ValidateAsync(model);

        if (!result.IsValid)
        {
            // Return RFC 7807 ProblemDetails with per-field errors
            return Results.ValidationProblem(
                result.ToDictionary(),      // field → error[] map
                title:  "Validation failed",
                detail: "One or more fields failed validation. See 'errors' for details.",
                type:   "https://tools.ietf.org/html/rfc7807");
        }

        return await next(ctx);
    }
}

// Validator for a CreateProductRequest
public record CreateProductRequest(
    string  Name,
    decimal Price,
    int     StockQuantity,
    string  Sku);

public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    private static readonly Regex SkuPattern = new(@"^[A-Z]{2}-\d{4}$",
        RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));

    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200)
            .Matches(@"^[\w\s\-\.]+$")  // reject special chars in product names
            .WithMessage("Name contains invalid characters.");

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .LessThanOrEqualTo(999_999.99m)
            .PrecisionScale(8, 2, false)   // max 2 decimal places
            .WithMessage("Price must be a positive value with up to 2 decimal places.");

        RuleFor(x => x.StockQuantity)
            .GreaterThanOrEqualTo(0)
            .LessThanOrEqualTo(100_000);

        RuleFor(x => x.Sku)
            .NotEmpty()
            .Matches(SkuPattern)
            .WithMessage("SKU must be in format AA-0000 (e.g., EL-1234).");
    }
}

// Register validators and wire the filter to an endpoint group
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();

app.MapGroup("/products")
   .RequireAuthorization()
   .MapPost("/", async (CreateProductRequest req, IProductService svc) =>
   {
       var product = await svc.CreateAsync(req);
       return Results.Created($"/products/{product.Id}", product);
   })
   .AddEndpointFilter<ValidationFilter<CreateProductRequest>>()
   .WithTags("Products");

Never trust string length from the client to control your database query patterns. Always enforce MaximumLength. A 10,000-character search query hitting a LIKE clause without a length cap is a cheap way to saturate a database CPU. The Matches(@"^[\w\s\-\.]+$") pattern above rejects anything outside alphanumerics, spaces, hyphens, and dots — adjust the allow-list to match your domain, but err on the side of restrictive.

The Checklist at a Glance

Run through these before every production deploy. They take under an hour to implement from scratch on a new project, and under 30 minutes to audit on an existing one.

Pre-Deploy Security Checklist
// ─── CORS ─────────────────────────────────────────────────────────────────
// [ ] Named policies defined (not AddDefaultPolicy with AllowAnyOrigin)
// [ ] AllowAnyOrigin is absent OR justified for a public, unauthenticated endpoint
// [ ] AllowCredentials() only used with explicit WithOrigins()
// [ ] SetPreflightMaxAge set (reduces preflight round-trips)
// [ ] Separate dev and production policies applied per environment

// ─── RATE LIMITING ────────────────────────────────────────────────────────
// [ ] Auth endpoints (login, register, forgot-password) have a strict policy
// [ ] Retry-After header returned on every 429 response
// [ ] RejectionStatusCode set to 429 (not the default 503)
// [ ] General API endpoints have a per-IP sliding or fixed window policy
// [ ] Expensive operations (reports, exports, AI calls) have token bucket limits
// [ ] Rate limiting middleware registered BEFORE UseRouting

// ─── SECURITY HEADERS ─────────────────────────────────────────────────────
// [ ] X-Content-Type-Options: nosniff
// [ ] X-Frame-Options: DENY (or SAMEORIGIN if you embed your own pages)
// [ ] Strict-Transport-Security with max-age >= 31536000
// [ ] Referrer-Policy: strict-origin-when-cross-origin
// [ ] Content-Security-Policy: default-src 'none' for pure JSON APIs
// [ ] Server header removed
// [ ] X-Powered-By header removed
// [ ] Cache-Control: no-store on authenticated responses
// [ ] Verified with securityheaders.com (target: A+)

// ─── INPUT VALIDATION ─────────────────────────────────────────────────────
// [ ] All request bodies validated before handler logic runs
// [ ] MaximumLength enforced on every string field
// [ ] Regex allow-lists used for structured fields (SKU, codes, identifiers)
// [ ] Numeric ranges validated (no negative prices, no absurd quantities)
// [ ] FluentValidation registered from assembly (AddValidatorsFromAssemblyContaining)
// [ ] Validation errors returned as RFC 7807 ProblemDetails (not raw strings)
// [ ] No validation logic in the database layer (validate before the query, not in it)

The most important single item on this list: rate limit your authentication endpoints. Everything else can be added incrementally. An unprotected login endpoint is an open invitation to credential stuffing attacks, and it happens silently — no exceptions, no errors in your logs until the account takeovers start appearing in support tickets.

Common Questions

Should I use AllowAnyOrigin in CORS for development?

Never combine AllowAnyOrigin with AllowCredentials — ASP.NET Core throws a runtime exception because the combination is insecure by design. For local development, use a named policy with explicit localhost origins so your dev setup mirrors what production enforces. AllowAnyOrigin is only acceptable for fully public, unauthenticated endpoints such as open data APIs where credentials are never sent.

Which rate limiting algorithm should I use in ASP.NET Core 8?

Use Fixed Window for simple endpoint protection (login, password reset). Use Sliding Window when you want smoother enforcement without traffic spikes at window resets. Use Token Bucket for APIs where occasional bursts are legitimate but sustained high volume is not. Use Concurrency Limiter to cap parallel in-flight requests rather than requests per unit of time — ideal for expensive database or AI calls.

Which security headers are mandatory for every API response?

At minimum: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security, and remove Server and X-Powered-By. For APIs serving browser clients, also add Content-Security-Policy and Referrer-Policy. Add Cache-Control: no-store on authenticated endpoints to prevent sensitive data being cached in intermediary proxies.

Is DataAnnotations validation enough for production APIs?

DataAnnotations covers basic property-level constraints and runs automatically in Minimal APIs when you call Results.ValidationProblem. It's sufficient for simple inputs. For cross-field rules, async database checks, or conditional requirements, use FluentValidation alongside it. The two complement each other well — DataAnnotations for structural constraints, FluentValidation for business rules. Never rely on client-side validation alone.

Does rate limiting protect against DDoS attacks?

Application-layer rate limiting protects against accidental abuse and low-volume API misuse. It is not a substitute for network-layer DDoS protection. Against a volumetric attack, requests reach your server before the middleware processes them, exhausting connection pools. Use a CDN or WAF (Cloudflare, Azure Front Door, AWS Shield) in front of your API to absorb traffic at the network edge before it reaches your application server.

Back to Articles