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.
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.
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.
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.
// ─── 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.