The OWASP Top 10 through a C# lens
The OWASP Top 10 is a great list and a terrible study guide. It's deliberately language-agnostic, so it talks about "broken access control" and "security misconfiguration" in the abstract and leaves you to figure out what that looks like in your stack. For a .NET developer that's a real gap, because most of these categories have a specific ASP.NET Core or EF Core shape: a particular attribute you forgot, a particular method that trusts input, a particular default that isn't safe.
So here's the list translated into C#. Not all ten, just the categories that bite .NET teams hardest. Every one of them has the same anatomy: working code, meeting input built to break it. Here is a sample of what an adversarial suite throws at a fix on Katabench's secure-coding track:
The adversarial suite
../../etc/passwd
path traversal
✓ denied
evil-example.com
suffix confusion
✓ rejected
http://169.254.169.254/
SSRF
✓ blocked
ada\n[INFO] admin granted
log forging
✓ escaped
A01: Broken access control
It's number one on the list for a reason, and in ASP.NET Core it's almost always a sin of omission. You wrote the endpoint, the logic is correct, and you simply forgot to ask who's allowed to call this.
// No attribute. Anyone who can reach the route can run it.
[HttpDelete("/api/users/{id}")]
public async Task<IActionResult> Delete(Guid id) { ... }
The fix is [Authorize], ideally enforced by default so that "forgot to add it" fails closed instead
of open:
[Authorize(Roles = "Admin")]
[HttpDelete("/api/users/{id}")]
public async Task<IActionResult> Delete(Guid id) { ... }
But the nastier version of broken access control passes every [Authorize] check and is still wrong.
It's the IDOR (insecure direct object reference): the user is authenticated, but you never checked
that this resource belongs to them.
// Authenticated, and still broken: any logged-in user can read any invoice.
[Authorize]
[HttpGet("/api/invoices/{id}")]
public async Task<Invoice?> Get(Guid id) =>
await dbContext.Invoices.FindAsync(id);
Authorization isn't just "are you logged in," it's "are you logged in and does this row belong to you." Scope the query to the caller:
[Authorize]
[HttpGet("/api/invoices/{id}")]
public async Task<IActionResult> Get(Guid id)
{
var userId = User.GetUserId(); // from the validated claims, never from the request body
var invoice = await dbContext.Invoices
.FirstOrDefaultAsync(i => i.Id == id && i.OwnerId == userId);
return invoice is null ? NotFound() : Ok(invoice);
}
Returning NotFound rather than Forbidden is deliberate: don't confirm the resource exists to
someone who shouldn't see it.
A03: Injection
The classic. In .NET it's almost never raw SQL string concatenation anymore; it's the interpolated version that looks clean and slips past review:
// Vulnerable: interpolation happens before EF sees the string.
dbContext.Users.FromSqlRaw($"SELECT * FROM Users WHERE Email = '{email}'");
// Safe: the interpolated string is a parameterized template.
dbContext.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Email = {email}");
The rule holds across the whole platform: parameterize values, and for things you can't
parameterize (sort columns, table names) use an allowlist of trusted constants. Injection isn't only
SQL, either; the same "data became code" failure shows up in LDAP filters, OS command arguments
passed to Process.Start, and anywhere you build an executable string from input.
A04 / A10: Insecure design meets SSRF
Server-side request forgery deserves its own callout because .NET makes it so easy to commit. The feature is innocent: "let users import an image by URL," or "fetch this webhook." The implementation hands an attacker your server's network position.
// The user controls the URL. Your server will happily fetch anything,
// including http://169.254.169.254/ (cloud metadata) or internal services.
var bytes = await httpClient.GetByteArrayAsync(userSuppliedUrl);
HttpClient doesn't care that the URL points at your metadata endpoint or a database on the private
subnet. The defense is to validate the destination before you call it: require https, resolve the
host, and reject private and loopback ranges.
if (!Uri.TryCreate(userSuppliedUrl, UriKind.Absolute, out var uri) ||
uri.Scheme != Uri.UriSchemeHttps)
{
return BadRequest("Only absolute https URLs are allowed.");
}
var addresses = await Dns.GetHostAddressesAsync(uri.Host);
if (addresses.Any(IsBlocked))
{
return BadRequest("That host is not allowed.");
}
static bool IsBlocked(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
var b = ip.GetAddressBytes();
if (b.Length != 4) return true; // be strict about IPv6 here
return b[0] == 10 // 10.0.0.0/8
|| (b[0] == 192 && b[1] == 168) // 192.168.0.0/16
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31) // 172.16.0.0/12
|| (b[0] == 169 && b[1] == 254); // link-local incl. cloud metadata
}
Better still, keep an allowlist of hosts you actually intend to talk to. SSRF is a place where "block the bad" loses to "permit the known good."
A05: Security misconfiguration
The category of unsafe defaults and leaked internals. The most common .NET version: a detailed exception page in production, handing attackers your stack traces, connection strings, and framework versions.
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error"); // generic page in production
app.UseHsts();
}
Same family: returning raw ex.Message in API responses, leaving Swagger UI public in production,
and over-permissive CORS (AllowAnyOrigin().AllowCredentials() is a contradiction the framework will
actually warn you about). The pattern is always the same: production should reveal nothing it doesn't
have to.
A07: Identification and authentication failures
Rolling your own password handling is the trap. Stored passwords go through a real, slow, salted hash (ASP.NET Core Identity uses PBKDF2 out of the box; Argon2 or bcrypt are fine choices), never a fast hash like SHA-256, and never plaintext. Equally often missed: compare secrets in constant time so you don't leak information through timing.
// Timing-safe comparison for tokens / API keys
var match = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(providedToken),
Encoding.UTF8.GetBytes(expectedToken));
A08: The .NET special, overposting
One OWASP files under software and data integrity, and .NET developers hit constantly: mass assignment via overposting. You bind straight to your EF entity, and the model binder cheerfully sets every property the attacker sends, including the ones you never put on the form.
// The form has Name and Email. The attacker also sends "IsAdmin": true.
[HttpPost]
public async Task<IActionResult> Update(User user)
{
dbContext.Users.Update(user);
await dbContext.SaveChangesAsync();
return Ok();
}
The model binder doesn't know IsAdmin wasn't on your form. It just sees a property named IsAdmin
in the request body and sets it. The fix is to never bind requests directly to entities. Bind to a
DTO that contains only the fields a user is allowed to change, then map deliberately:
public record UpdateUserRequest(string Name, string Email);
[HttpPost]
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request)
{
var user = await dbContext.Users.FindAsync(id);
if (user is null) return NotFound();
user.Name = request.Name; // only what we explicitly allow
user.Email = request.Email;
await dbContext.SaveChangesAsync();
return Ok();
}
IsAdmin simply has nowhere to land. The request shape is your access policy.
The thread through all of them
Look back at the eight examples. Almost none are exotic. They're code that works, code that ships, code that passes review by a tired engineer at 5pm. The vulnerable version and the safe version do the same thing on the happy path; they diverge only when someone sends input designed to break you. A test suite that checks "does the feature work" cannot tell them apart, which is exactly why these categories are still on the list decades later.
| Category | .NET footgun | The fix |
|---|---|---|
| Broken access control | Missing [Authorize], IDOR |
Authorize + scope queries to the caller |
| Injection | FromSqlRaw($"...") |
FromSqlInterpolated / parameters / allowlists |
| SSRF | HttpClient.GetAsync(userUrl) |
Validate scheme + resolved IP, allowlist hosts |
| Misconfiguration | Dev exception page in prod | Generic handler, HSTS, tight CORS |
| Overposting | Binding to EF entities | Bind to DTOs |
You don't internalize this from a table. You internalize it by writing the safe version under pressure, against something that punishes the unsafe one. That's the whole idea behind Katabench's secure-coding track: every puzzle starts from working-but-vulnerable code and grades you against an adversarial exploit suite, not just the happy path. Blocking the exploit while keeping the feature is the actual skill, and it's one you only build by doing.
The secure-coding track ships with the other four on Pro. If you'd rather train the failure modes than read about them, it's all here.