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.
On this page · 7 min · Advanced
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Advanced
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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>
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#
- Every response carries a strict CSP, nosniff and a referrer policy; frame-ancestors 'none' blocks clickjacking.
- The global antiforgery filter rejects the POST without a token with 400 — no per-action attribute to forget.
- The rate limiter runs before MVC filters, so even the rejected token-less POST used one of the 3 permits.
- Two real login attempts are processed; the third is over the per-IP limit and gets 429 Too Many Requests.
- 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#
AddFixedWindowLimiter without partitioning gives all users one shared bucket — an attacker can block every customer’s login. Partition by IP or user.
A too-strict CSP breaks pages; a too-loose one (unsafe-inline everywhere) protects nothing. Start with Content-Security-Policy-Report-Only and tighten.
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?
Show solution
// 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#
Which protection does AutoValidateAntiforgeryTokenAttribute provide?
Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions
My notes#
Saved in this browser.