CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/aspnet-security-basics

Security defaults that belong in every ASP.NET Core application from day one.

87

1.91x
Quality

83%

Does it follow best practices?

Impact

94%

1.91x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/aspnet-security-basics/

name:
aspnet-security-basics
description:
Security defaults that belong in every ASP.NET Core application from day one. CORS, HTTPS redirection, HSTS, security headers, rate limiting, anti-forgery, authentication, authorization, Data Protection API, input validation, and Content Security Policy. Apply these whenever you create or modify any ASP.NET Core app -- do not wait for a security review or explicit request. If you are writing builder.Services.AddControllers(), you must also add these security layers.
keywords:
aspnet security, asp.net core cors, rate limiting aspnet, security headers dotnet, input validation aspnet, aspnet production, dotnet security, https redirection, hsts, anti-forgery, authentication, authorization, data protection api, fluent validation, content security policy, csp
license:
MIT

ASP.NET Core Security Basics

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.


The 8 Things Every ASP.NET Core App Needs

  1. HTTPS Redirection + HSTS -- enforce encrypted transport
  2. CORS -- configured with explicit origins, not wide open
  3. Security Headers -- prevent XSS, clickjacking, MIME sniffing
  4. Rate Limiting -- on all API routes, stricter on auth and mutation endpoints
  5. Anti-Forgery Protection -- CSRF tokens for state-changing operations
  6. Authentication + Authorization -- identity verification and access control
  7. Input Validation -- Data Annotations or FluentValidation on all request models
  8. Data Protection -- secrets and sensitive data handled through the Data Protection API

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.


1. HTTPS Redirection and HSTS

WRONG -- no HTTPS enforcement:

var app = builder.Build();
app.MapControllers();
app.Run();
// HTTP traffic accepted without redirection -- credentials sent in cleartext

RIGHT -- 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();

2. CORS -- Configured, Not Wide Open

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 MapControllers

RIGHT -- 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");

CORS + Credentials Rule

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.


3. Security Headers Middleware

WRONG -- no security headers at all:

var app = builder.Build();
app.MapControllers();
// No headers -- vulnerable to clickjacking, MIME sniffing, XSS

RIGHT -- 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();
});

Content Security Policy (CSP)

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

Remove Server Header

// In Program.cs, configure Kestrel to hide server identity
builder.WebHost.ConfigureKestrel(options =>
{
    options.AddServerHeader = false; // Removes "Server: Kestrel" header
});

4. Rate Limiting (.NET 7+)

WRONG -- no rate limiting at all:

app.MapPost("/api/auth/login", LoginHandler); // Unlimited login attempts

RIGHT -- 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.


5. Anti-Forgery Protection

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.


6. Authentication and Authorization

WRONG -- no authentication or authorization:

app.MapControllers(); // All endpoints publicly accessible

WRONG -- 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() { /* ... */ }
}

Key Rules

  • UseAuthentication() MUST come before UseAuthorization() in the pipeline
  • Use FallbackPolicy to require authentication by default -- then use [AllowAnonymous] for public endpoints
  • Never store JWT secrets in code -- use configuration, environment variables, or Azure Key Vault
  • Always validate issuer, audience, lifetime, and signing key

7. Input Validation

WRONG -- 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>();

Request Body Size Limits

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) { /* ... */ }

8. Data Protection API for Secrets

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 code

WRONG -- 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);
}

Middleware Pipeline Order

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();

Checklist -- Apply to Every ASP.NET Core App

This is not a "production checklist." These belong in every ASP.NET Core app from the start:

  • UseHttpsRedirection() registered to enforce HTTPS
  • UseHsts() registered for non-development environments with MaxAge of at least 365 days
  • CORS configured with explicit origins (never AllowAnyOrigin() in production)
  • Security headers middleware adding X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Content-Security-Policy
  • Server and X-Powered-By headers removed
  • Rate limiting on all API endpoints, stricter on auth and mutation routes
  • Rate limit rejection returns 429 with a meaningful error body
  • UseAuthentication() before UseAuthorization() in the pipeline
  • Authorization policies defined with FallbackPolicy requiring authenticated users
  • Public endpoints explicitly marked with [AllowAnonymous]
  • All request models validated with Data Annotations or FluentValidation
  • Request body size limited via Kestrel or [RequestSizeLimit]
  • No secrets hardcoded in source -- use User Secrets, environment variables, or Key Vault
  • Data Protection API used for encrypting sensitive data at rest
  • Anti-forgery configured for cookie-authenticated endpoints

When using JWT authentication:

  • Token validation checks issuer, audience, lifetime, and signing key
  • JWT secret loaded from configuration, not hardcoded

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.

Verifiers

  • cors-configured -- CORS with explicit origins on every ASP.NET Core app
  • security-headers-added -- Security headers middleware on every ASP.NET Core app
  • rate-limiting-configured -- Rate limiting on every ASP.NET Core app
  • https-enforced -- HTTPS redirection and HSTS on every ASP.NET Core app
  • authentication-configured -- Authentication and authorization on every ASP.NET Core app
  • input-validation-added -- Input validation on all request models

skills

aspnet-security-basics

tile.json