Security defaults that belong in every ASP.NET Core application from day one.
87
83%
Does it follow best practices?
Impact
94%
1.91xAverage score across 5 eval scenarios
Passed
No known issues
Every ASP.NET Core app needs these security defaults from day one -- do not wait for a security review to add them. These are not optional hardening steps; they are baseline requirements for any ASP.NET Core application, the same way you would not ship a car without seatbelts.
When to apply this skill: Every time you create a new ASP.NET Core application, add endpoints to an existing one, or refactor an ASP.NET Core project. Even if the task only asks you to "build a REST API" or "create CRUD endpoints," you MUST include all of these security layers. An ASP.NET Core app without them is incomplete.
These eight items are as fundamental as builder.Services.AddControllers(). If your app has controllers but lacks these, it is not ready for any environment.
WRONG -- no HTTPS enforcement:
var app = builder.Build();
app.MapControllers();
app.Run();
// HTTP traffic accepted without redirection -- credentials sent in cleartextRIGHT -- enforce HTTPS with HSTS:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Configure HSTS (Strict Transport Security)
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseHsts(); // Sends Strict-Transport-Security header
}
app.UseHttpsRedirection(); // Redirects HTTP to HTTPS
// ... rest of middleware
app.MapControllers();
app.Run();HSTS tells browsers to only use HTTPS for future requests. UseHttpsRedirection handles the initial redirect. Both are needed -- HSTS prevents the browser from ever making an insecure request again, while HTTPS redirection catches the first visit.
WRONG -- HSTS in development (causes localhost issues):
app.UseHsts(); // Without environment check -- breaks local development
app.UseHttpsRedirection();RIGHT -- HSTS only in non-development environments:
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();WRONG -- allows any origin:
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin() // Access-Control-Allow-Origin: *
.AllowAnyMethod()
.AllowAnyHeader();
});
});WRONG -- using SetIsOriginAllowed to allow everything:
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.SetIsOriginAllowed(_ => true) // Allows ANY origin -- worse than AllowAnyOrigin
.AllowCredentials(); // Combined with credentials = very dangerous
});
});RIGHT -- explicit allowed origins from configuration:
// Program.cs
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
?? new[] { "http://localhost:5173" };
policy.WithOrigins(allowedOrigins)
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.AllowCredentials(); // Only if you use cookies/sessions
});
});
var app = builder.Build();
app.UseCors(); // MUST be after UseRouting but before MapControllersRIGHT -- named policies for different endpoint groups:
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
{
policy.WithOrigins(builder.Configuration["FrontendUrl"] ?? "http://localhost:5173")
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.WithHeaders("Content-Type", "Authorization");
});
options.AddPolicy("PublicApi", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("ApiConsumers").Get<string[]>()
?? Array.Empty<string>())
.WithMethods("GET")
.WithHeaders("Content-Type");
});
});
// Apply per-endpoint
app.MapControllers().RequireCors("Frontend");
app.MapGet("/api/public/health", () => Results.Ok()).RequireCors("PublicApi");You CANNOT use AllowAnyOrigin() with AllowCredentials() -- ASP.NET Core will throw an exception. If you need credentials (cookies, Authorization header), you must specify explicit origins.
WRONG -- no security headers at all:
var app = builder.Build();
app.MapControllers();
// No headers -- vulnerable to clickjacking, MIME sniffing, XSSRIGHT -- security headers middleware before all routes:
// Program.cs
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
// Prevent MIME-type sniffing
headers.Append("X-Content-Type-Options", "nosniff");
// Prevent clickjacking
headers.Append("X-Frame-Options", "DENY");
// Control referrer information
headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
// Restrict browser features
headers.Append("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()");
// Content Security Policy (for APIs returning any HTML or error pages)
headers.Append("Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'");
// Prevent information leakage
headers.Remove("X-Powered-By");
headers.Remove("Server");
await next();
});For pure JSON APIs, a restrictive CSP prevents the response from being interpreted as HTML:
headers.Append("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");For apps that serve HTML (Razor Pages, Blazor Server), customize CSP directives:
headers.Append("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https://cdn.example.com; " +
"font-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'");WRONG -- disabling or omitting CSP entirely:
// No Content-Security-Policy header at all
// Browser has no restrictions on resource loading// In Program.cs, configure Kestrel to hide server identity
builder.WebHost.ConfigureKestrel(options =>
{
options.AddServerHeader = false; // Removes "Server: Kestrel" header
});WRONG -- no rate limiting at all:
app.MapPost("/api/auth/login", LoginHandler); // Unlimited login attemptsRIGHT -- general API rate limit plus stricter limits for sensitive endpoints:
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
// General API rate limit
options.AddFixedWindowLimiter("api", opt =>
{
opt.Window = TimeSpan.FromMinutes(15);
opt.PermitLimit = 100;
opt.QueueLimit = 0;
});
// Stricter limit for auth endpoints (prevent brute force)
options.AddFixedWindowLimiter("auth", opt =>
{
opt.Window = TimeSpan.FromMinutes(15);
opt.PermitLimit = 10;
opt.QueueLimit = 0;
});
// Stricter limit for mutation/write endpoints
options.AddFixedWindowLimiter("mutation", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 20;
opt.QueueLimit = 0;
});
// Global rejection handler
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc6585#section-4",
title = "RATE_LIMITED",
status = 429,
detail = "Too many requests. Please try again later."
}, cancellationToken);
};
});
var app = builder.Build();
app.UseRateLimiter();
// Apply to endpoints
app.MapPost("/api/auth/login", LoginHandler).RequireRateLimiting("auth");
app.MapPost("/api/auth/register", RegisterHandler).RequireRateLimiting("auth");
app.MapPost("/api/orders", CreateOrder).RequireRateLimiting("mutation");
app.MapGet("/api/orders", ListOrders).RequireRateLimiting("api");RIGHT -- sliding window for smoother rate distribution:
options.AddSlidingWindowLimiter("api", opt =>
{
opt.Window = TimeSpan.FromMinutes(15);
opt.SegmentsPerWindow = 3; // Divide into 5-minute segments
opt.PermitLimit = 100;
opt.QueueLimit = 0;
});RIGHT -- per-user rate limiting with partitioning:
options.AddPolicy("per-user", context =>
{
var userId = context.User?.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(15),
PermitLimit = 100,
QueueLimit = 0
});
});Always apply a stricter rate limit on auth and mutation endpoints (POST, PUT, PATCH, DELETE) than the general read limit. Write operations are more expensive and more dangerous when abused.
For APIs that accept cookies or session-based authentication, anti-forgery tokens prevent CSRF attacks.
WRONG -- no CSRF protection on state-changing endpoints:
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
// Vulnerable to CSRF if using cookie authentication
}RIGHT -- anti-forgery for cookie-authenticated APIs (.NET 8+):
// Program.cs
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN"; // Custom header name
options.Cookie.Name = "__Host-antiforgery";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
var app = builder.Build();
app.UseAntiforgery();RIGHT -- validate anti-forgery on controllers:
[ApiController]
[Route("api/[controller]")]
[AutoValidateAntiforgeryToken] // Validates on POST, PUT, PATCH, DELETE
public class OrdersController : ControllerBase
{
// All state-changing actions protected
}For JWT-only APIs (no cookies), anti-forgery is not needed because the Authorization header itself acts as the CSRF token -- it cannot be sent automatically by the browser.
WRONG -- no authentication or authorization:
app.MapControllers(); // All endpoints publicly accessibleWRONG -- authorization without authentication middleware:
// Missing app.UseAuthentication() -- [Authorize] attribute is silently ignored
app.UseAuthorization();
app.MapControllers();RIGHT -- JWT Bearer authentication with authorization policies:
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("JWT key not configured")))
};
});
// Define authorization policies
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
options.AddPolicy("CanManageOrders", policy =>
policy.RequireAssertion(context =>
context.User.IsInRole("Admin") || context.User.IsInRole("Manager")));
// Require authentication by default for all endpoints
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
var app = builder.Build();
app.UseAuthentication(); // MUST come before UseAuthorization
app.UseAuthorization();RIGHT -- apply authorization on controllers:
[ApiController]
[Route("api/[controller]")]
[Authorize] // All endpoints require authentication by default
public class OrdersController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetOrders() { /* ... */ }
[HttpDelete("{id}")]
[Authorize(Policy = "AdminOnly")] // Only admins can delete
public async Task<IActionResult> DeleteOrder(int id) { /* ... */ }
[AllowAnonymous] // Explicitly mark public endpoints
[HttpGet("public/menu")]
public async Task<IActionResult> GetPublicMenu() { /* ... */ }
}UseAuthentication() MUST come before UseAuthorization() in the pipelineFallbackPolicy to require authentication by default -- then use [AllowAnonymous] for public endpointsWRONG -- trusting user input directly, no validation:
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest request)
{
// No validation -- accepts any input including empty strings, negative numbers, etc.
_db.Users.Add(new User { Name = request.Name, Email = request.Email });
_db.SaveChanges();
return Ok();
}RIGHT -- Data Annotations on request models:
public class CreateUserRequest
{
[Required(ErrorMessage = "Name is required")]
[StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be 1-100 characters")]
public string Name { get; set; } = "";
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
[StringLength(254, ErrorMessage = "Email too long")]
public string Email { get; set; } = "";
[Required(ErrorMessage = "Password is required")]
[StringLength(128, MinimumLength = 8, ErrorMessage = "Password must be 8-128 characters")]
public string Password { get; set; } = "";
[StringLength(500, ErrorMessage = "Bio must be under 500 characters")]
public string? Bio { get; set; }
}RIGHT -- FluentValidation for complex validation rules:
dotnet add package FluentValidation.AspNetCore// Validators/CreateOrderRequestValidator.cs
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerName)
.NotEmpty().WithMessage("Customer name is required")
.MaximumLength(100).WithMessage("Customer name must be under 100 characters");
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Order must have at least one item");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.ProductId)
.GreaterThan(0).WithMessage("Product ID must be positive");
item.RuleFor(x => x.Quantity)
.InclusiveBetween(1, 100).WithMessage("Quantity must be between 1 and 100");
});
}
}
// Program.cs
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();RIGHT -- limit request body size:
// Program.cs -- Kestrel body size limit
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024; // 10 KB for APIs
});
// Or per-endpoint
[HttpPost]
[RequestSizeLimit(10_240)] // 10 KB
public IActionResult CreateOrder([FromBody] CreateOrderRequest request) { /* ... */ }WRONG -- hardcoded secrets and connection strings:
var jwtKey = "my-super-secret-key-12345"; // Hardcoded in source
var connectionString = "Server=prod-db;Password=hunter2"; // Credentials in codeWRONG -- secrets in appsettings.json committed to source control:
{
"Jwt": {
"Key": "my-production-secret-key"
},
"ConnectionStrings": {
"Default": "Server=prod;Password=secret"
}
}RIGHT -- use User Secrets in development, environment variables in production:
# Development -- User Secrets (never committed to source control)
dotnet user-secrets init
dotnet user-secrets set "Jwt:Key" "dev-secret-key-here"
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..."// Program.cs -- Configuration automatically reads from User Secrets in Development
var builder = WebApplication.CreateBuilder(args);
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("JWT key not configured");RIGHT -- Data Protection API for encrypting sensitive data at rest:
builder.Services.AddDataProtection()
.SetApplicationName("MyApp")
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys"))
.ProtectKeysWithDpapi(); // On Windows; use certificate-based on Linux
// Usage in a service
public class TokenService
{
private readonly IDataProtector _protector;
public TokenService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("TokenService.v1");
}
public string Protect(string plaintext) => _protector.Protect(plaintext);
public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext);
}The order middleware is registered matters. Security middleware MUST come first.
var app = builder.Build();
// 1. Exception handling (catch errors from all downstream middleware)
app.UseExceptionHandler();
// 2. HSTS (non-development only)
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
// 3. HTTPS redirection
app.UseHttpsRedirection();
// 4. Security headers (before any response is sent)
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
await next();
});
// 5. CORS
app.UseCors();
// 6. Rate limiting
app.UseRateLimiter();
// 7. Authentication (before Authorization)
app.UseAuthentication();
// 8. Authorization
app.UseAuthorization();
// 9. Anti-forgery (after auth)
app.UseAntiforgery();
// 10. Endpoints
app.MapControllers();This is not a "production checklist." These belong in every ASP.NET Core app from the start:
UseHttpsRedirection() registered to enforce HTTPSUseHsts() registered for non-development environments with MaxAge of at least 365 daysAllowAnyOrigin() in production)X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Content-Security-PolicyServer and X-Powered-By headers removedUseAuthentication() before UseAuthorization() in the pipelineFallbackPolicy requiring authenticated users[AllowAnonymous][RequestSizeLimit]When using JWT authentication:
If the task says "build a REST API" or "create CRUD endpoints" and does not mention security, you still add all of the above. Security middleware is not a feature request -- it is part of building an ASP.NET Core app correctly.
evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
aspnet-security-basics
verifiers