Web Security & Ethical Hacking · Lesson 13 of 23
Web Security: OWASP Top 10 in .NET (Practical)
Why Developers Must Understand Security
Security is not the security team's job — it's every developer's job. The OWASP Top 10 vulnerabilities are found in production systems at every company, at every scale. They're not exotic bugs; they're well-understood patterns that appear again and again because developers didn't know what to look for.
This lesson teaches you how each attack works so you can both recognise and prevent it in your own code.
OWASP #1 — Broken Access Control
The most common vulnerability. Users access data or actions they shouldn't be authorised for.
IDOR (Insecure Direct Object Reference)
GET /api/orders/1042If the server returns this order regardless of who's asking — any user can read any other user's orders by guessing IDs.
// ❌ Vulnerable — returns any order by ID
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _db.Orders.FindAsync(id);
return Ok(order);
}
// ✅ Fixed — scoped to the authenticated user
[HttpGet("{id}"), Authorize]
public async Task<IActionResult> GetOrder(int id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var order = await _db.Orders.FirstOrDefaultAsync(
o => o.Id == id && o.UserId == userId);
if (order is null) return NotFound();
return Ok(order);
}Rule: always filter queries by the authenticated user's ID. Never trust the ID in the URL alone.
OWASP #2 — Cryptographic Failures
Sensitive data exposed due to weak or missing encryption.
// ❌ Storing passwords in plain text or MD5
var hash = MD5.HashData(Encoding.UTF8.GetBytes(password));
// ❌ Storing passwords with SHA-256 (fast hash — easily brute-forced)
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(password));
// ✅ BCrypt — slow by design, salted, resistant to brute force
using BCrypt.Net;
var hash = BCrypt.EnhancedHashPassword(password, 12); // cost factor 12
var isValid = BCrypt.EnhancedVerify(inputPassword, hash);
// ✅ Or use ASP.NET Core Identity (handles this for you)
await _userManager.CreateAsync(user, password); // hashes internally with PBKDF2Other cryptographic failures:
- HTTP instead of HTTPS (all data in plaintext on the wire)
- Weak JWT secrets (short strings are brute-forceable)
- Storing API keys in source code or logs
// ❌ Hardcoded secret
var key = "mySecret123";
// ✅ From environment variable / Azure Key Vault
var key = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("JWT secret not configured");
// Enforce HTTPS
app.UseHsts();
app.UseHttpsRedirection();OWASP #3 — SQL Injection
An attacker injects SQL code into your query by manipulating input.
-- Vulnerable query (string concatenation)
SELECT * FROM Users WHERE Username = 'admin' AND Password = '' OR '1'='1'
-- The injected ' OR '1'='1' always evaluates to true — bypasses authentication// ❌ Vulnerable — string concatenation
var username = request.Username; // "admin' OR '1'='1"
var sql = $"SELECT * FROM Users WHERE Username = '{username}'";
await _db.Database.ExecuteSqlRawAsync(sql);
// ✅ Fixed — parameterised query (EF Core)
var user = await _db.Users
.Where(u => u.Username == request.Username)
.FirstOrDefaultAsync();
// ✅ Fixed — parameterised raw SQL
var user = await _db.Users
.FromSql($"SELECT * FROM Users WHERE Username = {request.Username}")
.FirstOrDefaultAsync();
// EF Core's $"..." interpolation creates safe parameters — not string concatenation
// ✅ Fixed — Dapper with parameters
var user = await db.QueryFirstOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Username = @Username",
new { Username = request.Username });Never build SQL queries by concatenating user input. Always use parameterised queries — every ORM and data access library supports them.
OWASP #4 — Insecure Design
Design flaws that can't be patched — they require architectural rethinking.
Example: A password reset flow that sends a 4-digit OTP via email, without rate limiting. An attacker can try all 10,000 combinations in minutes.
// ❌ 4-digit OTP, no rate limit, no expiry
var otp = Random.Shared.Next(1000, 9999).ToString();
// ✅ Cryptographically random token, expiry, rate limiting
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var reset = new PasswordReset
{
UserId = userId,
Token = token,
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
Used = false,
};
await _db.PasswordResets.AddAsync(reset);
// Rate limit: max 3 reset attempts per hour per email
var recentAttempts = await _db.PasswordResets
.CountAsync(r => r.Email == email && r.CreatedAt > DateTime.UtcNow.AddHours(-1));
if (recentAttempts >= 3)
return BadRequest("Too many reset attempts. Try again in an hour.");OWASP #5 — Security Misconfiguration
Default credentials, verbose error messages, unnecessary features enabled.
// ❌ Stack traces in production responses
app.UseDeveloperExceptionPage(); // shows full stack trace to users
// ✅ Generic error in production, details only in dev
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
else
app.UseExceptionHandler("/error");
// ✅ Never expose internal details in error responses
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/500",
title = "An error occurred.",
status = 500,
});
// Log the full exception internally — never return it to the client
});
});// ✅ Security headers
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'";
await next();
});OWASP #6 — Vulnerable and Outdated Components
Using libraries with known CVEs.
# .NET — check for vulnerable packages
dotnet list package --vulnerable
# Node.js
npm audit
# Fix automatically
npm audit fixEnable Dependabot in GitHub to get automated PRs for security updates.
OWASP #7 — Identification and Authentication Failures
Weak authentication: no MFA, weak passwords, session tokens that don't expire.
// ❌ Long-lived JWT with no refresh mechanism
var token = new JwtSecurityToken(
expires: DateTime.UtcNow.AddDays(365)); // a stolen token is valid for a year
// ✅ Short-lived access token + refresh token
var accessToken = new JwtSecurityToken(
expires: DateTime.UtcNow.AddMinutes(15)); // expires fast
var refreshToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(64));
// Store refresh token in DB, use it to issue new access tokens
// ✅ JWT configuration — strong secret, correct algorithm
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero, // no grace period for expiry
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
};
});OWASP #8 — Software and Data Integrity Failures
Running unverified code, malicious packages, or insecure deserialization.
# ❌ Install package without checking author
npm install some-random-package
# ✅ Check the package before installing
# - Verify npm download count and last published date
# - Check the GitHub repo for activity
# - Run: npm audit after installing// ❌ Deserializing untrusted data with TypeNameHandling
var obj = JsonConvert.DeserializeObject(userInput, new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All // allows arbitrary type instantiation — RCE risk
});
// ✅ Use System.Text.Json — it doesn't support TypeNameHandling
var obj = JsonSerializer.Deserialize<MyKnownType>(userInput);OWASP #9 — Security Logging and Monitoring Failures
No logs = no detection. Attacks that go undetected for weeks cause the most damage.
// ✅ Log all authentication events
_logger.LogWarning(
"Failed login attempt for {Email} from IP {Ip}",
request.Email, context.Connection.RemoteIpAddress);
// ✅ Log all access control violations
_logger.LogWarning(
"User {UserId} attempted to access Order {OrderId} — access denied",
userId, orderId);
// ✅ Alert on suspicious patterns (e.g., 50 failed logins in 5 minutes)
// Use your SIEM, Azure Monitor, or Application Insights for alertingOWASP #10 — Server-Side Request Forgery (SSRF)
An attacker tricks your server into making requests to internal services.
// ❌ Vulnerable — fetches any URL the user provides
[HttpPost("fetch")]
public async Task<IActionResult> FetchContent([FromBody] string url)
{
var content = await _httpClient.GetStringAsync(url);
return Ok(content);
}
// Attack: url = "http://169.254.169.254/metadata" (AWS/Azure instance metadata)
// → leaks cloud credentials
// ✅ Validate the URL against an allowlist
[HttpPost("fetch")]
public async Task<IActionResult> FetchContent([FromBody] string url)
{
var uri = Uri.TryCreate(url, UriKind.Absolute, out var parsed) ? parsed : null;
if (uri is null || !AllowedHosts.Contains(uri.Host))
return BadRequest("URL not allowed.");
var content = await _httpClient.GetStringAsync(uri);
return Ok(content);
}
private static readonly HashSet<string> AllowedHosts = ["api.example.com", "cdn.example.com"];XSS (Cross-Site Scripting)
An attacker injects JavaScript into your pages that runs in other users' browsers.
<!-- ❌ Vulnerable — user input rendered as raw HTML -->
<div id="comment">{userComment}</div>
<!-- If userComment = "<script>document.cookie</script>" — their JS runs -->
<!-- ✅ Fixed — HTML-encode all user content -->
<div id="comment">{escapeHtml(userComment)}</div>// React automatically escapes content — safe by default
function Comment({ text }: { text: string }) {
return <p>{text}</p>; // ✅ text is escaped automatically
}
// ❌ dangerouslySetInnerHTML bypasses escaping — never use with user content
function Comment({ html }: { html: string }) {
return <p dangerouslySetInnerHTML={{ __html: html }} />; // XSS risk
}// Content Security Policy blocks inline scripts even if XSS occurs
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'; object-src 'none'";CSRF (Cross-Site Request Forgery)
An attacker tricks a logged-in user into making a request to your API without their knowledge.
<!-- Attacker's malicious page -->
<img src="https://yourbank.com/transfer?to=attacker&amount=1000" />
<!-- Browser sends the request with the user's cookies — transfer happens silently -->// ✅ Anti-forgery tokens (ASP.NET Core forms)
builder.Services.AddAntiforgery();
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Transfer(TransferRequest request) { ... }
// ✅ SameSite cookies — prevents cookies being sent on cross-site requests
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.HttpOnly = true; // not accessible from JavaScript
});
// ✅ For APIs: require Authorization header (Bearer token)
// Browsers won't automatically send Authorization headers cross-siteSecurity Checklist for Every API
Authentication & Authorization
✅ All sensitive endpoints require authentication
✅ All data queries scoped to authenticated user (no IDOR)
✅ JWT: short expiry (15 min), strong secret (32+ bytes), HTTPS only
Input Handling
✅ All user input validated with FluentValidation or similar
✅ SQL: parameterised queries only — never string concatenation
✅ File uploads: validate type, size, scan for malware
Output
✅ Errors: generic messages to clients, full details in logs only
✅ Stack traces: never exposed in production
✅ Security headers set (CSP, X-Frame-Options, etc.)
Secrets
✅ No credentials in source code — use env vars or Key Vault
✅ JWT secret is at least 256 bits (32 bytes)
✅ Database connection strings in configuration, not hardcoded
Infrastructure
✅ HTTPS enforced (HSTS)
✅ Rate limiting on auth endpoints
✅ Dependencies up to date (dotnet list package --vulnerable)
✅ Authentication and access denial events loggedKey Takeaways
- IDOR is the #1 vulnerability — always scope queries to the authenticated user
- SQL injection is entirely preventable — parameterised queries, always
- XSS: React escapes by default — never use
dangerouslySetInnerHTMLwith user content - CSRF: use
SameSite=Strictcookies and require Authorization headers for APIs - Passwords: BCrypt or ASP.NET Core Identity — never MD5, SHA-256, or plain text
- Error messages: log everything internally, return generic messages externally
- Security headers: set CSP, X-Frame-Options, X-Content-Type-Options on every response
- Security is a feature, not an afterthought — design it in from day one