Lesson 15 of 45 intermediate

Validation and API Security

Never trust the request, and never keep secrets where git can see them

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Your API has a strict CORS policy. Can someone still call it with curl from any machine?

Open interactive version (quiz + challenge)

Real-world analogy

At a bank counter, the clerk checks every form and copies only the boxes the bank's own form has. The vault key never leaves the building, and the lock is changed if it is ever copied. Here the clerk is validation at the edge, and copying only known boxes is a request DTO that blocks over-posting. The vault key is a secret kept out of source control.

What is it?

API security is a set of layers. Validation rejects bad input early. Parameterised queries stop injection. HTTPS protects data in transit, rate limiting slows abuse, and CORS controls which browser origins may read your responses. Secrets and password storage cover what happens when someone gets a copy of your code or database. No single layer is enough, and each one covers the gap the others leave.

Real-world relevance

Most real breaches here are boring. A connection string with a password is committed to a public repo. A search box builds SQL with string concatenation and an attacker dumps the users table. A "wildcard" CORS policy is added to make an error go away and left in. A login endpoint has no rate limit, so a script tries a million passwords. These are cheap to prevent and expensive to explain afterwards.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Which approach protects a search endpoint from SQL injection?

What is the right way to store user passwords?

Code example

public class RegisterRequest
{
    [Required, EmailAddress, StringLength(254)]
    public string Email { get; set; } = "";

    [Required, StringLength(100, MinimumLength = 12)]
    public string Password { get; set; } = "";
}

[ApiController]
[Route("register")]
[EnableRateLimiting("strict")]
public class RegisterController : ControllerBase
{
    [HttpPost]
    public IActionResult Post(RegisterRequest request) => Accepted();   // bad input never gets here: automatic 400
}

// Program.cs
builder.Services.AddCors(o => o.AddPolicy("web", p => p
    .WithOrigins("https://app.example.com")
    .WithMethods("GET", "POST")
    .WithHeaders("Content-Type", "Authorization")));

builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.AddPolicy("strict", http => RateLimitPartition.GetFixedWindowLimiter(
        http.Connection.RemoteIpAddress?.ToString() ?? "unknown",   // one window per client IP
        _ => new FixedWindowRateLimiterOptions
            { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
// app.UseCors("web"); app.UseRateLimiter();

Line-by-line walkthrough

  1. 1. RegisterRequest uses attributes: Email must be a valid address, Password must be 12 to 100 characters.
  2. 2. The controller has [ApiController], so a request that breaks those rules gets a 400 with field errors before Post runs.
  3. 3. EnableRateLimiting("strict") applies the named limiter to this endpoint only.
  4. 4. The CORS policy lists one exact origin, the methods and the headers. It is a rule for browsers, not a firewall.
  5. 5. AddPolicy gives each client IP its own fixed window of 5 requests per minute. QueueLimit 0 means extra calls get 429 at once. Behind a proxy, configure forwarded headers so the IP is the real caller.
  6. 6. The last line is a comment showing that UseCors and UseRateLimiter must be added to the pipeline too.

Spot the bug

public async Task<List<User>> Search(string name)
{
    var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";
    return await _db.Users.FromSqlRaw(sql).ToListAsync();
}
Need a hint?
What happens if name is x' OR '1'='1 ?
Show answer
The SQL string is built by concatenating user input, so a crafted name changes the query (SQL injection) and can return or damage any data. Fix: use FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}") or a plain LINQ Where, so the value is sent as a parameter.

Explain like I'm 5

A clerk checks every form before it goes in, and ignores any strange extra boxes. The big key to the safe stays at home, and the clerk only lets a few people in a minute.

Fun fact

For a simple cross-origin POST, your server may still run the request. The browser only stops the page from reading the answer.

Hands-on challenge

Say it out loud in 30 seconds: "How do you secure a public ASP.NET Core API?" Then check: did you mention validation with DTOs, parameterised queries, secrets outside git, hashed passwords, and rate limiting?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook