By the end of this session, you will be able to:
AddRateLimiter middlewareUseHttpsRedirection and explain what HSTS adds on top| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Security threats this session addresses | Theory | 10 min |
| 0:10 | Rate limiting | Theory + Demo | 30 min |
| 0:40 | CORS configuration | Theory + Demo | 20 min |
| 1:00 | HTTPS enforcement & HSTS | Theory + Demo | 15 min |
| 1:15 | Hiding sensitive fields | Demo | 15 min |
| 1:30 | Lab — harden the Task API | Lab | 25 min |
| 1:55 | Wrap-up | Discussion | 5 min |
/api/auth/login.PasswordHash, RefreshToken, and internal fields to every caller.
Middleware runs top to bottom. Highlighted stages are added in this session.
UseCors() after UseAuthorization() means rejected requests never get CORS headers — the browser reports a confusing CORS error instead of the real 401.
| Limiter | How it works | Good for |
|---|---|---|
| Fixed window | N requests per fixed clock window; counter resets at the boundary | Simple public quotas |
| Sliding window | Window split into segments that expire gradually — smoother than fixed | Avoiding burst at window edges |
| Token bucket | Tokens refill at a steady rate; each request spends one | Allowing short bursts, steady average |
| Concurrency | Caps simultaneous in-flight requests, not requests per time | Protecting slow or expensive endpoints |
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 2;
});
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
app.UseRateLimiter();
[EnableRateLimiting("name")] on a controller or a single action[DisableRateLimiting] carves out an exception, exactly like [AllowAnonymous].RequireRateLimiting("name")QueueLimit lets a few excess requests wait instead of failing immediately[ApiController]
[EnableRateLimiting("fixed")]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
[HttpGet]
[DisableRateLimiting]
public IActionResult GetAll() { ... }
}
// Minimal API
app.MapPost("/api/auth/login", Login)
.RequireRateLimiting("auth");
A named limiter without a partition key is shared by all callers. Partition by IP or user ID so one client cannot consume everyone's quota.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext =>
{
var key = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)!.Value
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(key, _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
429 Too Many Requests is the correct status — never 400 or 503Retry-After header so clients know when to try againoptions.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(
new { title = "Too many requests",
status = 429 }, token);
};
An origin is scheme + host + port. Compared to https://app.myapi.com:
Before a non-simple request (custom headers, DELETE, PUT, JSON body), the browser asks permission first.
OPTIONS /api/tasks/42 HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type
--- server responds ---
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Access-Control-Max-Age caches the answer, avoiding a preflight on every call.builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.WithOrigins("http://localhost:3000", "https://myapp.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
var app = builder.Build();
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
.AllowAnyOrigin() combined with .AllowCredentials(). The CORS specification forbids it — browsers reject the response, and if it worked it would let any site send the user's cookies to your API.
WithOrigins("https://myapp.com/") never matches, because the browser sends the origin without a trailing slash.
WithOrigins(...). If you genuinely need a public, credential-free API, use .AllowAnyOrigin() without .AllowCredentials() and rely on bearer tokens rather than cookies.
UseHttpsRedirection() answers an HTTP request with a 307 redirect to HTTPSUseHsts() closes that gap: the browser upgrades to HTTPS before sending anythingmax-age, remembered per domainif (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
// Optional: tune the header
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Directive | Meaning |
|---|---|
max-age | Seconds the browser enforces HTTPS for this host — 31536000 is one year |
includeSubDomains | Applies the same rule to every subdomain |
preload | Requests inclusion in the browser's built-in HSTS list, protecting even the first visit |
localhost, the browser will refuse plain HTTP for every local project until you clear the domain's security policy manually.
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
[JsonIgnore]
public string PasswordHash { get; set; } = "";
[JsonIgnore]
public string RefreshToken { get; set; } = "";
}
[JsonIgnore] stops the serializer from writing the property — a useful last line of defenseToString() or log statement can leak itServer and X-Powered-By advertise your stack; disable with options.AddServerHeader = false on KestrelGET /api/users returning every column, when the UI needs only id and nameWork through these steps on your Task Management API project:
"auth" limiter — 5 requests/minute — and apply it to the login and register endpointshttp://localhost:3000, and verify UseCors sits between UseRouting and UseAuthenticationPasswordHash appears in no API response — check every endpoint that returns a userRetry-After headerAddRateLimiter, opt endpoints in with [EnableRateLimiting]AllowAnyOrigin with AllowCredentials[JsonIgnore] is a safety net, not a strategySession 22 — Unit & Integration Testing
Produce a hardened build of your Task Management API plus a short SECURITY.md documenting what you configured and why.
OnRejected handler returning 429 with a Retry-After header and a JSON bodyappsettings.json, not hard-codedUseHsts() enabled only when the environment is not Development, with UseHttpsRedirection() always onPasswordHashSECURITY.md why token bucket suits that endpoint better than a fixed window.