OWASP Top 10 for .NET Developers — Vulnerabilities, Examples, and Fixes
Walk through all 10 OWASP Top 10 vulnerabilities with .NET-specific attack scenarios and C# code showing both the vulnerable pattern and the correct fix. Practical, not theoretical.
Why the OWASP Top 10 Still Matters for .NET Developers
The OWASP Top 10 is updated every few years based on real breach data. Most of the vulnerabilities are not exotic — they are well-understood mistakes that appear in production .NET codebases constantly, including codebases written by experienced engineers who simply did not think about security in that particular moment.
This article goes through all 10 with .NET-specific examples. For each vulnerability, you will see the vulnerable code, the attack vector, and the fixed code. Knowing why the fix works is more valuable than memorizing the fix.
A01 — Broken Access Control
What it is: The application does not verify that the authenticated user has permission to access the requested resource. The most common variant: a resource is fetched by ID from the URL, but the server only checks that the caller is authenticated, not that the caller owns that resource.
Vulnerable
// BAD: only checks that the user is authenticated, not that they own the order
[Authorize]
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
// Any authenticated user can read any order by guessing the GUID
var order = await _db.Orders.FindAsync(id);
return order is null ? NotFound() : Ok(order);
}An attacker who is a legitimate user can enumerate order IDs and read every order in the system (IDOR — Insecure Direct Object Reference).
Fixed: Resource-Based Authorization
// GOOD: verifies ownership before returning the resource
[Authorize]
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
var order = await _db.Orders.FindAsync(id);
if (order is null) return NotFound();
// Verify ownership — the caller must own this order
var authResult = await _authorizationService.AuthorizeAsync(
User, order, "OwnerPolicy");
if (!authResult.Succeeded)
return Forbid(); // 403, not 404 — do not leak whether the resource exists
return Ok(order);
}
// The requirement and handler
public class OwnerAuthorizationHandler
: AuthorizationHandler<OwnerRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx,
OwnerRequirement req,
Order resource)
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (resource.OwnerId.ToString() == userId)
ctx.Succeed(req);
return Task.CompletedTask;
}
}Rule of thumb: every endpoint that fetches a resource by a user-supplied identifier needs ownership verification, not just authentication.
A02 — Cryptographic Failures
What it is: Sensitive data is stored or transmitted with weak or absent cryptography. The classic .NET case: password hashing with MD5 or SHA1, or worse, storing plaintext passwords.
Vulnerable
// BAD: MD5 is broken — collisions are trivially computable.
// SHA1 and SHA256 without salting are also wrong for passwords
// because they are fast (designed for throughput, not security).
public string HashPassword(string password)
{
using var md5 = MD5.Create();
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
return Convert.ToHexString(bytes);
}MD5 is broken for collision resistance and is GPU-crackable in seconds with rainbow tables. A dump of this table exposes all user passwords immediately.
Fixed: BCrypt via ASP.NET Core Identity
// GOOD: ASP.NET Core Identity's PasswordHasher uses PBKDF2-HMACSHA512
// with a random per-user salt and configurable iteration count.
// As of .NET 8, the default is PBKDF2 with 600,000 iterations.
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 12;
options.Password.RequireNonAlphanumeric = true;
})
.AddEntityFrameworkStores<AppDbContext>();
// Hashing (done automatically by UserManager)
await _userManager.CreateAsync(user, "PlaintextP@ssw0rd123");
// Verification (done automatically by SignInManager)
var result = await _signInManager.CheckPasswordSignInAsync(user, "PlaintextP@ssw0rd123", false);If you need to hash outside Identity (e.g., a legacy API), use BCrypt:
dotnet add package BCrypt.Net-Next// BCrypt includes its own salt; no separate salt management needed
var hash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
var verify = BCrypt.Net.BCrypt.Verify(password, hash);Rule of thumb: passwords are not data — never use general-purpose hash functions for them. Use a password hashing function (PBKDF2, BCrypt, Argon2) that is deliberately slow and includes a per-user salt.
A03 — Injection (SQL Injection)
What it is: User-supplied data is incorporated into a query without sanitization, allowing the attacker to change the query's logic.
Vulnerable
// BAD: string concatenation directly into SQL.
// Input "'; DROP TABLE Users; --" causes a classic injection.
public async Task<User?> FindByEmailAsync(string email)
{
var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
var conn = _db.Database.GetDbConnection();
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// ... execute
}Fixed: EF Core Parameterized Queries
// GOOD option 1: EF Core LINQ — always parameterized
public async Task<User?> FindByEmailAsync(string email)
{
return await _db.Users
.FirstOrDefaultAsync(u => u.Email == email);
}
// GOOD option 2: raw SQL with parameters — never string-interpolated
public async Task<User?> FindByEmailRawAsync(string email)
{
// FormattableString overload is SQL-injection safe — EF parameterizes the value
return await _db.Users
.FromSql($"SELECT * FROM Users WHERE Email = {email}")
.FirstOrDefaultAsync();
}
// BAD raw SQL (do not use FromSqlRaw with interpolation):
// _db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Email = '{email}'")
// Use FromSqlRaw only with explicit SqlParameter objects if needed.Rule of thumb: never build SQL strings with user input. EF Core LINQ queries are parameterized by default. When writing raw SQL, use FromSql (interpolated, safe) not FromSqlRaw with string interpolation.
A04 — Insecure Design
What it is: The application reveals internal implementation details in error responses. This is a design issue, not a misconfiguration — the error handling was never designed to be secure.
Vulnerable
// BAD: the default exception handler returns the stack trace to the client.
// In .NET 8 developer exceptions are shown in Development by default,
// but many apps accidentally enable them in production too.
app.UseDeveloperExceptionPage(); // ← dangerous in productionThe response body includes full stack traces, assembly paths, connection strings embedded in messages, and framework internals. Attackers use this to fingerprint your stack and plan targeted attacks.
Fixed: ProblemDetails with IExceptionHandler
// GOOD: structured error responses — no internal details exposed
// Program.cs
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();namespace YourApp.Infrastructure;
public sealed class GlobalExceptionHandler(IProblemDetailsService problemDetails)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx,
Exception exception,
CancellationToken ct)
{
var (status, title) = exception switch
{
ValidationException => (400, "Validation Error"),
NotFoundException => (404, "Resource Not Found"),
UnauthorizedAccessException => (403, "Access Denied"),
_ => (500, "Internal Server Error")
};
// Log full details internally (with correlation ID), expose nothing externally
var logger = ctx.RequestServices
.GetRequiredService<ILogger<GlobalExceptionHandler>>();
var correlationId = ctx.Items["CorrelationId"]?.ToString() ?? "n/a";
if (status == 500)
logger.LogError(exception,
"Unhandled exception. CorrelationId={CorrelationId}", correlationId);
ctx.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx,
ProblemDetails = new ProblemDetails
{
Status = status,
Title = title,
// Safe reference for support; no internal data
Extensions = { ["correlationId"] = correlationId }
},
});
}
}The response body is now:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "Internal Server Error",
"status": 500,
"correlationId": "3f2a8c1b4d9e..."
}Users get a reference number. Attackers get nothing useful.
A05 — Security Misconfiguration (CORS)
What it is: CORS misconfiguration that allows any origin to make credentialed cross-origin requests to your API. The wildcard (*) with credentials is the most dangerous variant.
Vulnerable
// BAD: allows any origin, any method, any header.
// Browsers honour this for simple requests; attackers can host malicious sites
// that call your API using the victim's cookies.
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));Fixed: Named Policy with Explicit Origins
// GOOD: explicit origins from configuration, specific methods and headers
builder.Services.AddCors(o =>
{
o.AddPolicy("FrontendOrigins", p =>
{
var allowed = builder.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? [];
p.WithOrigins(allowed) // e.g. ["https://app.example.com"]
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization", "X-Correlation-Id")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10));
});
});
// Apply the named policy, not the default
app.UseCors("FrontendOrigins");appsettings.Production.json:
{
"Cors": {
"AllowedOrigins": ["https://app.example.com", "https://admin.example.com"]
}
}Rule of thumb: never use AllowAnyOrigin with AllowCredentials. The browser blocks this combination anyway, but it signals that security was not considered in the design.
A06 — Vulnerable and Outdated Components
What it is: Shipping NuGet packages with known CVEs. A package that was secure when you added it may have a critical vulnerability discovered six months later.
Detection
# Check all packages in the solution for known vulnerabilities
dotnet list package --vulnerable
# Include transitive dependencies (packages of packages)
dotnet list package --vulnerable --include-transitiveSample output when a vulnerability exists:
Project `YourApp` has the following vulnerable packages
[net8.0]:
Top-level Package Requested Resolved Severity Advisory URL
> Newtonsoft.Json 12.0.1 12.0.1 High https://github.com/advisories/GHSA-5crp-9r3c-p9vxFix: Automated Scanning in CI
Add this to your GitHub Actions or Azure DevOps pipeline:
# .github/workflows/security.yml
- name: Check for vulnerable packages
run: dotnet list package --vulnerable --include-transitive
# This exits with code 1 if any vulnerabilities are found,
# failing the build before the artifact reaches staging.For Dependabot automatic PRs:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10Rule of thumb: run dotnet list package --vulnerable in CI. If a package has a CVE and a patched version exists, update within your next sprint. If no patch exists, evaluate mitigating controls or replacing the package.
A07 — Identification and Authentication Failures (JWT)
What it is: JWT validation that accepts the none algorithm, expired tokens, tokens signed with weak secrets, or tokens from any issuer. The none algorithm attack is particularly dangerous: an attacker strips the signature and sets alg: none, and a naive validator accepts the forged token as valid.
Vulnerable
// BAD: manual JWT parsing without algorithm validation.
// An attacker can forge tokens with alg:"none" and no signature.
var token = new JwtSecurityToken(rawJwt); // does not validate signature
var claims = token.Claims; // trusts whatever is in the payloadFixed: Explicit Algorithm Validation
// GOOD: AddJwtBearer validates signature, expiry, issuer, audience, and algorithm
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30), // tight tolerance
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
// CRITICAL: whitelist the only acceptable algorithm.
// Reject "none", RS256, and any other algorithm not in this list.
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
RequireSignedTokens = true,
RequireExpirationTime = true,
};
});For production systems, prefer RS256 (asymmetric) with a rotating key pair so the signing key never leaves the auth server:
// RS256 — the public key validates; only the auth server holds the private key
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
// Fetch JWKS from your auth server's /.well-known/jwks.json
var jwksClient = new JsonWebKeySetClient(options.MetadataAddress);
return jwksClient.GetSigningKeys();
},
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
};A08 — Software and Data Integrity Failures
What it is: Consuming packages or artifacts without verifying their integrity. In the NuGet ecosystem this means trusting packages that do not have author or repository signatures, or using dotnet restore without lock files.
The Risk
Without a packages.lock.json, restoring the same version of a package at two different times may produce different binaries if the feed was tampered with. Dependency confusion attacks — where attackers publish malicious packages with the same name as internal packages but higher version numbers — exploit this.
Fix: Lock Files and Signature Validation
<!-- YourApp.csproj — enable deterministic restore -->
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>Commit packages.lock.json to source control. In CI, use --locked-mode to fail the build if the lock file does not match:
dotnet restore --locked-modeFor packages from private feeds, require signed packages:
<!-- NuGet.config -->
<configuration>
<packageSources>
<add key="internal" value="https://pkgs.dev.azure.com/yourorg/_packaging/yourfeed/nuget/v3/index.json" />
</packageSources>
<trustedSigners>
<author name="Microsoft">
<certificate fingerprint="3F9001EA83C560D712C24CF213C3D312B3821E3611" hashAlgorithm="SHA256" allowUntrustedRoot="false" />
</author>
</trustedSigners>
</configuration>A09 — Security Logging and Monitoring Failures
What it is: Logging PII (personally identifiable information) or secrets — or logging so little that a breach goes undetected. Both are security failures. The first violates privacy regulations; the second violates your ability to detect and respond to attacks.
Vulnerable: Logging PII
// BAD: logs user email, IP, and the full request body which may include passwords
logger.LogInformation("Login attempt: {Email} from {IP}, body={Body}",
request.Email, ctx.Connection.RemoteIpAddress, JsonSerializer.Serialize(request));This sends plaintext email addresses and potentially passwords to your log aggregator. If the log aggregator is breached (or even just accessed by a developer), user credentials are exposed.
Fixed: Destructuring Policies
With Serilog's destructuring, you can redact sensitive fields automatically:
// Serilog setup with destructuring policy
builder.Host.UseSerilog((ctx, cfg) =>
{
cfg.Destructure.ByTransforming<LoginRequest>(r => new
{
r.Email.Length, // log the email length, not the value
HasPassword = !string.IsNullOrEmpty(r.Password), // boolean, not the value
})
.Destructure.ByTransforming<UserDto>(u => new
{
u.Id,
// Omit: Email, PhoneNumber, DateOfBirth — all PII
})
.WriteTo.Console(new CompactJsonFormatter());
});
// Now this is safe — the email value never appears in logs
logger.LogInformation("Login attempt: {@LoginRequest}", request);
// Logs: {"Length": 22, "HasPassword": true}What You MUST Log
Log enough to detect and investigate a breach:
// Authentication events
logger.LogWarning("Failed login for user {UserId} from {IpAddress} — attempt {Count}",
userId, ipAddress, attemptCount); // UserId (not email), IP, count
// Authorization failures
logger.LogWarning("Forbidden: user {UserId} attempted to access order {OrderId}",
userId, orderId);
// Sensitive operations
logger.LogInformation("Password changed for user {UserId} by {ActorId}",
targetUserId, actorUserId);
// Never log:
// - Passwords (even hashed)
// - Full credit card numbers
// - Social security numbers
// - Session tokens or JWTs
// - Connection stringsA10 — Server-Side Request Forgery (SSRF)
What it is: The application makes an HTTP request to a URL that is fully or partially controlled by the attacker. The attack lets adversaries pivot from the internet to internal services (metadata APIs, internal admin interfaces, cloud IMDS endpoints) that are not accessible from the outside.
A classic cloud SSRF target: the AWS/Azure instance metadata endpoint at http://169.254.169.254/latest/meta-data/iam/security-credentials/ — accessible from any EC2/VM but not from the internet. An SSRF vulnerability lets attackers extract temporary cloud credentials.
Vulnerable
// BAD: the caller controls the entire URL passed to HttpClient
[HttpPost("fetch-preview")]
public async Task<IActionResult> FetchPreview([FromBody] string url)
{
// An attacker passes "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
// and receives the response containing cloud IAM credentials.
var response = await _httpClient.GetStringAsync(url);
return Ok(response);
}Fixed: Allowlist Validation
namespace YourApp.Security;
/// <summary>
/// Validates user-supplied URLs against an explicit allowlist.
/// Rejects private IP ranges, metadata endpoints, and non-HTTPS schemes.
/// </summary>
public sealed class UrlAllowlistValidator(IConfiguration config)
{
private readonly HashSet<string> _allowedHosts = config
.GetSection("AllowedFetchHosts")
.Get<string[]>()?
.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? [];
private static readonly IPAddress[] PrivateRangeStarts =
[
IPAddress.Parse("10.0.0.0"),
IPAddress.Parse("172.16.0.0"),
IPAddress.Parse("192.168.0.0"),
IPAddress.Parse("169.254.0.0"), // link-local / IMDS
IPAddress.Parse("127.0.0.0"), // loopback
IPAddress.Parse("::1"),
IPAddress.Parse("fc00::"), // IPv6 ULA
];
public ValidationResult Validate(string rawUrl)
{
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
return ValidationResult.Fail("Invalid URL format");
if (uri.Scheme is not ("http" or "https"))
return ValidationResult.Fail("Only http/https allowed");
if (uri.Scheme == "http")
return ValidationResult.Fail("Only HTTPS allowed");
if (!_allowedHosts.Contains(uri.Host))
return ValidationResult.Fail($"Host '{uri.Host}' is not in the allowlist");
// Resolve DNS and check for private IP (blocks DNS rebinding attacks)
var addresses = Dns.GetHostAddresses(uri.Host);
if (addresses.Any(IsPrivateIp))
return ValidationResult.Fail("Resolved IP is in a private range");
return ValidationResult.Ok();
}
private static bool IsPrivateIp(IPAddress ip)
{
var bytes = ip.GetAddressBytes();
return (bytes[0] == 10)
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 169 && bytes[1] == 254)
|| (bytes[0] == 127)
|| ip.Equals(IPAddress.IPv6Loopback);
}
}
public record ValidationResult(bool IsValid, string? Error)
{
public static ValidationResult Ok() => new(true, null);
public static ValidationResult Fail(string e) => new(false, e);
}Use in the endpoint:
[HttpPost("fetch-preview")]
public async Task<IActionResult> FetchPreview(
[FromBody] string url,
[FromServices] UrlAllowlistValidator validator)
{
var result = validator.Validate(url);
if (!result.IsValid)
return BadRequest(new { error = result.Error });
var response = await _httpClient.GetStringAsync(url);
return Ok(response);
}appsettings.json:
{
"AllowedFetchHosts": [
"api.github.com",
"cdn.example.com"
]
}Quick Reference: OWASP Top 10 .NET Fixes
| # | Vulnerability | Primary .NET Fix |
|---|---|---|
| A01 | Broken Access Control | Resource-based authorization with IAuthorizationService |
| A02 | Cryptographic Failures | ASP.NET Core Identity PasswordHasher or BCrypt |
| A03 | Injection | EF Core LINQ / FromSql interpolated (never FromSqlRaw + string interp) |
| A04 | Insecure Design | IExceptionHandler + ProblemDetails, no stack traces in responses |
| A05 | Security Misconfiguration | Named CORS policy with explicit origins, no AllowAnyOrigin |
| A06 | Vulnerable Components | dotnet list package --vulnerable in CI + Dependabot |
| A07 | Authentication Failures | AddJwtBearer with explicit ValidAlgorithms, RequireSignedTokens = true |
| A08 | Software Integrity | packages.lock.json committed, --locked-mode in CI |
| A09 | Logging Failures | Serilog destructuring policies; log events, not PII |
| A10 | SSRF | URL allowlist validation + DNS rebinding check before every external HTTP call |
Enjoyed this article?
Explore the Security & Compliance learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.