Backend Development with .NET
Session 21
API Security: Rate Limiting, CORS & HTTPS
Eng. Seif Mansour  ·  Andalusia Academy
Week 8  ·  2 hours
Session Goals

By the end of this session, you will be able to:

  • Implement rate limiting using the .NET 7+ built-in AddRateLimiter middleware
  • Configure CORS so that only trusted origins can call your API from a browser
  • Enforce HTTPS with UseHttpsRedirection and explain what HSTS adds on top
  • Hide sensitive fields so passwords and tokens never reach a response body
  • Place each security middleware in the correct position in the pipeline
Agenda
Time Segment Type Duration
0:00Security threats this session addressesTheory10 min
0:10Rate limitingTheory + Demo30 min
0:40CORS configurationTheory + Demo20 min
1:00HTTPS enforcement & HSTSTheory + Demo15 min
1:15Hiding sensitive fieldsDemo15 min
1:30Lab — harden the Task APILab25 min
1:55Wrap-upDiscussion5 min
What We Are Defending Against
Authentication and authorization are only two layers. These are the attacks that get through them.
In this section
Four threats a valid token does not stop
Defense in depth as a mindset
Where each middleware sits in the pipeline
Four Threats Auth Does Not Stop
Brute force & credential stuffing
An attacker sends thousands of login attempts per minute against /api/auth/login.
Mitigation: rate limiting
Resource exhaustion
One misbehaving client floods an expensive endpoint and degrades the API for everyone else.
Mitigation: rate limiting
Malicious cross-origin calls
A page on another domain uses the victim's browser session to call your API.
Mitigation: strict CORS policy
Traffic interception
A JWT sent over plain HTTP on public Wi-Fi is readable and replayable by anyone on the network.
Mitigation: HTTPS + HSTS
Plus one you cause yourself
Over-sharing data. Returning an entity straight from the database leaks PasswordHash, RefreshToken, and internal fields to every caller.
Order Matters — The Security Pipeline

Middleware runs top to bottom. Highlighted stages are added in this session.

app.UseHsts()Production only
app.UseHttpsRedirection()Before anything reads the request
app.UseRouting()Selects the endpoint
app.UseCors()After routing, before auth
app.UseRateLimiter()After routing, so per-endpoint limits resolve
app.UseAuthentication()Who are you? (Session 19)
app.UseAuthorization()What may you do? (Session 20)
app.MapControllers()Your endpoint runs
Classic bug
Calling UseCors() after UseAuthorization() means rejected requests never get CORS headers — the browser reports a confusing CORS error instead of the real 401.
Rate Limiting
Capping how many requests a caller may make in a window — built into .NET 7 and later
In this section
The four limiter algorithms
Registering limiters in Program.cs
[EnableRateLimiting] per endpoint
Partitioning by IP or user
429 responses and Retry-After
Four Built-In Limiter Algorithms
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
Fixed window edge case
With 100 requests/minute, a client can send 100 at 11:59:59 and 100 more at 12:00:00 — 200 requests in one second. Sliding window exists to smooth exactly this.
Registering Named Limiters
Program.cs
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();
Each limiter gets a name. Endpoints opt in by that name — nothing is limited until you say so.
Applying a Limiter to Endpoints
  • [EnableRateLimiting("name")] on a controller or a single action
  • [DisableRateLimiting] carves out an exception, exactly like [AllowAnonymous]
  • Minimal API handlers use .RequireRateLimiting("name")
  • Auth endpoints deserve a much stricter limiter than read endpoints
  • 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");
Limiting Per Client, Not Per Server

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.

Program.cs
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)
            });
    });
Authenticated users are limited by user ID; anonymous callers by IP address.
Rejecting Politely — 429 & Retry-After
  • 429 Too Many Requests is the correct status — never 400 or 503
  • Include a Retry-After header so clients know when to try again
  • Return the same problem-details shape used in Session 10 for consistency
  • Do not reveal the exact quota to anonymous callers — it helps attackers tune their rate
options.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);
};
CORS
Cross-Origin Resource Sharing — how a browser decides whether your React app may read your API's responses
In this section
The same-origin policy
What counts as a different origin
The preflight OPTIONS request
Defining a named CORS policy
The two mistakes everyone makes
What Counts as a Different Origin

An origin is scheme + host + port. Compared to https://app.myapi.com:

https://app.myapi.com/tasksSame origin
http://app.myapi.comDifferent — scheme
https://api.myapi.comDifferent — host
https://app.myapi.com:8443Different — port
Key point
CORS is enforced by the browser, not by your server. Postman, curl, and a mobile app ignore it entirely — CORS protects your users' browsers, it is not an access control layer for your API.
The Preflight Request

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
Only after this succeeds does the browser send the real DELETE. Access-Control-Max-Age caches the answer, avoiding a preflight on every call.
Configuring a CORS Policy
Program.cs
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();
Read the allowed origins from configuration so localhost is permitted in development and only the real domain in production.
Two CORS Mistakes to Avoid
Invalid and insecure
.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.
Silently broken
Trailing slashes. WithOrigins("https://myapp.com/") never matches, because the browser sends the origin without a trailing slash.
Do this instead
List exact origins with WithOrigins(...). If you genuinely need a public, credential-free API, use .AllowAnyOrigin() without .AllowCredentials() and rely on bearer tokens rather than cookies.
HTTPS & HSTS
Encrypting traffic, then telling browsers never to try the unencrypted version again
In this section
UseHttpsRedirection and its limits
Anatomy of the HSTS header
Why HSTS is production-only
Cookie and token transport rules
Redirect, Then Remember
  • UseHttpsRedirection() answers an HTTP request with a 307 redirect to HTTPS
  • That first plain request still travels unencrypted — an attacker can intercept it
  • UseHsts() closes that gap: the browser upgrades to HTTPS before sending anything
  • HSTS applies for the duration of max-age, remembered per domain
  • Enable HSTS only outside development — see the warning on the next slide
if (!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;
});
Anatomy of the HSTS Header
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
DirectiveMeaning
max-ageSeconds the browser enforces HTTPS for this host — 31536000 is one year
includeSubDomainsApplies the same rule to every subdomain
preloadRequests inclusion in the browser's built-in HSTS list, protecting even the first visit
Development warning
HSTS is cached by the browser and is hard to undo. If you send it from localhost, the browser will refuse plain HTTP for every local project until you clear the domain's security policy manually.
Hiding Sensitive Data
The fastest way to leak a password hash is to return the entity that holds it
In this section
[JsonIgnore] as a safety net
DTOs as the real solution
Other accidental leak channels
[JsonIgnore] vs DTOs
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 defense
  • But the entity still travels through your code, and one ToString() or log statement can leak it
  • A DTO (Session 16) is the real fix: the entity never reaches the serializer at all
  • Opt in to the fields you expose rather than opting out of the ones you do not
Rule
Never return an EF Core entity directly from a controller action.
Other Ways Data Escapes
  • Exception details. A stack trace in a 500 response exposes file paths, library versions, and sometimes connection strings — use the global handler from Session 10
  • Verbose headers. Server and X-Powered-By advertise your stack; disable with options.AddServerHeader = false on Kestrel
  • Swagger in production. Publishing the full API surface plus example payloads is a free map for an attacker
  • Over-fetching endpoints. GET /api/users returning every column, when the UI needs only id and name
  • Logs. Logging the whole request body captures passwords on the login endpoint — covered further in Session 23
Key Concept
"CORS protects the browser. Rate limiting protects the server. HTTPS protects the wire. Only DTOs protect the data."
— Session 21
Lab — Harden the Task API

Work through these steps on your Task Management API project:

01
Add a fixed-window rate limiter of 60 requests/minute per IP and apply it to the tasks controller
02
Add a stricter "auth" limiter — 5 requests/minute — and apply it to the login and register endpoints
03
Configure CORS to allow only http://localhost:3000, and verify UseCors sits between UseRouting and UseAuthentication
04
Verify PasswordHash appears in no API response — check every endpoint that returns a user
05
Script 70 requests in Postman and confirm the 61st returns 429 with a Retry-After header
Summary
1
Rate limiting is built into .NET 7+ — register named limiters with AddRateLimiter, opt endpoints in with [EnableRateLimiting]
2
Partition limiters by IP or user ID, or one noisy client consumes the quota for everybody
3
CORS is browser-enforced. Name exact origins; never combine AllowAnyOrigin with AllowCredentials
4
HTTPS redirection fixes the request; HSTS prevents the insecure request from ever being sent — production only
5
Middleware order is security: HSTS, HTTPS, routing, CORS, rate limiter, authentication, authorization
6
Return DTOs, not entities — [JsonIgnore] is a safety net, not a strategy
What's Next

Session 22 — Unit & Integration Testing

  • Writing unit tests for services and handlers with xUnit
  • Mocking dependencies so tests stay fast and isolated
  • Integration tests against an in-memory Web API host
  • Testing the security rules you added today — including that 429 response
Before next session
Make sure your API runs cleanly with rate limiting, CORS, and HTTPS redirection all enabled at once. Note any endpoint that breaks — those are exactly the cases we will write tests for.
Assignment

Produce a hardened build of your Task Management API plus a short SECURITY.md documenting what you configured and why.

  • Two rate limiters registered: a general one for the API and a stricter one for auth endpoints, both partitioned by IP
  • A custom OnRejected handler returning 429 with a Retry-After header and a JSON body
  • A CORS policy whose allowed origins are read from appsettings.json, not hard-coded
  • UseHsts() enabled only when the environment is not Development, with UseHttpsRedirection() always on
  • Postman screenshots proving the 429 response and proving no response contains PasswordHash
Bonus
Add a token-bucket limiter to one expensive endpoint (for example a report or export action) that allows a burst of 10 requests but refills only 1 token every 6 seconds. Explain in SECURITY.md why token bucket suits that endpoint better than a fixed window.
Questions?
Session 21 — API Security: Rate Limiting, CORS & HTTPS