ASP.NET Core MVC · Production

Security

Security is not one feature but many small defaults done right. This lesson collects the protections every ShopSmart page needs and shows them working: security headers on every response, antiforgery on every POST, and a login form that slows down password guessing.

Advanced 7 min Lesson 39 of 42Examples target .NET 9.
0/42 in ASP.NET Core MVC
On this page · 7 min · Advanced

In simple words#

Securing a web app is locking every door and window: HTTPS, validated input, encoded output, anti-forgery tokens and least-privilege accounts.

XSS
Injected scripts — Razor encodes output.
CSRF
Forged form posts — use anti-forgery tokens.
HTTPS + HSTS
Encrypt all traffic.

Learning objectives#

  • Enforce HTTPS with redirection and HSTS
  • Require antiforgery tokens on every POST
  • Add security headers including a Content Security Policy
  • Rate-limit sensitive endpoints such as login

Explanation#

HTTPS everywhere: UseHttpsRedirection sends HTTP visitors to HTTPS, and UseHsts (outside Development) tells browsers to never use plain HTTP for your domain again.

CSRF: add AutoValidateAntiforgeryTokenAttribute as a global filter so every POST, PUT and DELETE must carry the token that the form tag helper already puts in your forms.

XSS: Razor encodes output by default — never Html.Raw user content. Add a Content-Security-Policy header so that, even if something slips through, the browser refuses scripts from other origins. Add X-Content-Type-Options: nosniff, a Referrer-Policy and frame-ancestors (clickjacking).

Brute force: the built-in rate limiter (AddRateLimiter + [EnableRateLimiting]) returns 429 Too Many Requests. Partition limits by IP or user so one attacker cannot lock out everybody. Combine with Identity lockout.

Also: parameterised queries (EF Core does it), input models against over-posting, LocalRedirect for return URLs, secrets in User Secrets / Key Vault, keep packages updated (dotnet list package --vulnerable), and least-privilege database accounts.

Syntax#

C# Illustrative
builder.Services.AddControllersWithViews(o => o.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
builder.Services.AddRateLimiter(o => o.AddPolicy("login", ctx =>
    RateLimitPartition.GetFixedWindowLimiter(ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1) })));
app.UseHsts(); app.UseHttpsRedirection(); app.UseRateLimiter();

Code example: Security headers, antiforgery and a rate-limited login#

C#Program.cs Illustrative
// Program.cs
using System.Text.RegularExpressions;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                         // keep the demo output clean
builder.Services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));    // every POST needs a token
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(
        partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",   // per client, not global
        factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromMinutes(1) }));
});


var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.Use(async (context, next) =>                                         // security headers on every response
{
    var headers = context.Response.Headers;
    headers.ContentSecurityPolicy = "default-src 'self'; img-src 'self' https://cdn.shopsmart.example; frame-ancestors 'none'";
    headers.XContentTypeOptions = "nosniff";
    headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
    headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
    await next(context);
});
app.UseRouting();
app.UseRateLimiter();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Urls.Add("http://127.0.0.1:5123");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5123") };
var page = await http.GetAsync("/Account/Login");
foreach (var name in new[] { "Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy" })
    Console.WriteLine(
quot;
{name}: {page.Headers.GetValues(name).First()}");
var noToken = await http.PostAsync("/Account/Login", new FormUrlEncodedContent([new("Email", "a@b.c"), new("Password", "x")])); Console.WriteLine(
quot;POST without antiforgery token:
{(int)noToken.StatusCode}");
var token = Regex.Match(await page.Content.ReadAsStringAsync(), "__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]+)").Groups[1].Value; for (var attempt = 1; attempt <= 4; attempt++) { var response = await http.PostAsync("/Account/Login", new FormUrlEncodedContent( [new("Email", "asha@example.com"), new("Password",
quot;guess
{attempt}"), new("__RequestVerificationToken", token)]));
Console.WriteLine(
quot;Login attempt
{attempt}: {(int)response.StatusCode}");
} await app.StopAsync(); // Controllers/AccountController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; public class AccountController : Controller { [HttpGet] public IActionResult Login() => View(); [HttpPost, EnableRateLimiting("login")] public IActionResult Login(string email, string password) { ViewData["Error"] = "Invalid email or password."; // (real check: see the Identity lesson) return View(); } } @* Views/Account/Login.cshtml *@ <form asp-action="Login" method="post"> <input name="Email" /> <input name="Password" type="password" /> <button>Sign in</button> </form>
Expected output
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.shopsmart.example; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
POST without antiforgery token: 400
Login attempt 1: 200
Login attempt 2: 200
Login attempt 3: 429
Login attempt 4: 429

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Every response carries a strict CSP, nosniff and a referrer policy; frame-ancestors 'none' blocks clickjacking.
  2. The global antiforgery filter rejects the POST without a token with 400 — no per-action attribute to forget.
  3. The rate limiter runs before MVC filters, so even the rejected token-less POST used one of the 3 permits.
  4. Two real login attempts are processed; the third is over the per-IP limit and gets 429 Too Many Requests.
  5. In production, HSTS and HTTPS redirection run too (the demo uses plain HTTP on localhost).

Real-world use case#

Before ShopSmart’s launch, a penetration test flagged missing security headers, a login without rate limiting and an admin form without antiforgery. These few lines in Program.cs closed all three findings.

Common mistakes#

Rate limits shared by everyone

AddFixedWindowLimiter without partitioning gives all users one shared bucket — an attacker can block every customer’s login. Partition by IP or user.

CSP added once and never tested

A too-strict CSP breaks pages; a too-loose one (unsafe-inline everywhere) protects nothing. Start with Content-Security-Policy-Report-Only and tighten.

Relying on client-side checks

Disabled buttons and JavaScript validation do not stop attackers. Every rule must be enforced on the server.

Best practices#

  • HTTPS + HSTS in production
  • Global AutoValidateAntiforgeryToken
  • CSP, nosniff, Referrer-Policy, frame-ancestors
  • Partitioned rate limits on login, signup and password reset

Remember this#

Encode output (XSS), validate tokens (CSRF), parameterise SQL (injection).

Security headers on every response.

Rate-limit login per client → 429.

Practice exercise#

Your task: Configure Content-Security-Policy-Report-Only instead of the enforced header, with a report-uri of /csp-reports, and add an endpoint that logs posted reports.

Need a hint?
headers["Content-Security-Policy-Report-Only"] = "default-src 'self'; report-uri /csp-reports"; app.MapPost("/csp-reports", async (HttpRequest r) => …).
Show solution
C# Illustrative
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();


app.Use(async (context, next) =>
{
    context.Response.Headers["Content-Security-Policy-Report-Only"] = "default-src 'self'; report-uri /csp-reports";
    await next(context);
});


app.MapPost("/csp-reports", async (HttpRequest request, ILogger<Program> logger) =>
{
    using var reader = new StreamReader(request.Body);
    logger.LogWarning("CSP violation: {Report}", await reader.ReadToEndAsync());
    return Results.NoContent();
});


app.MapGet("/", () => "ShopSmart");
app.Run();

Mini quiz#

Security quizMultiple choice Question 1 of 3

Which protection does AutoValidateAntiforgeryTokenAttribute provide?

Score so far: 0/0

Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.