The OWASP Top 10 is the closest thing web development has to a shared vocabulary for the risks that keep causing breaches, and the 2025 edition β€” now finalized and published as the current version β€” reshuffles several categories in ways that change what a .NET team should prioritize. This guide walks through the risks that matter most for ASP.NET Core and C# codebases, with runnable mitigations rather than abstract advice: parameterized queries and EF Core, Razor's automatic encoding, the antiforgery system for Minimal APIs and MVC, outbound-request allow-listing for SSRF, and the NuGet auditing built into the SDK. It assumes you already build ASP.NET Core APIs and want to know exactly which defaults protect you, which ones you still have to configure, and where a small implementation detail is the difference between a finding on a scan and a production incident.

What Is the OWASP Top 10?#

The OWASP Top 10 is a periodically updated awareness document published by the Open Worldwide Application Security Project, ranking the ten application security risk categories that show up most often and cause the most damage across contributed testing data and a practitioner survey. It is not a checklist you tick off once. Each entry is a category of related weaknesses, identified by their underlying CWE (Common Weakness Enumeration) numbers, not a single bug, and a real application can be hit by several CWEs inside the same category at once. The OWASP Top 10:2025 has now replaced the OWASP Top 10:2021 as the current edition β€” the official OWASP Top 10 repository marks 2021 as superseded β€” and 2025 is what you should cite in threat models, security reviews and vendor questionnaires going forward.

OWASP Top 10:2025 vs 2021: What Changed for .NET Teams#

The category names and rankings moved enough between editions that mitigations you might have deprioritized under 2021 are worth a second look. Two changes matter most for .NET APIs: Server-Side Request Forgery is no longer a standalone category (it now lives inside Broken Access Control, alongside CSRF), and "Vulnerable and Outdated Components" grew into the much broader "Software Supply Chain Failures."

2025 rank and category2021 equivalentWhat changedPrimary .NET mitigation
A01 Broken Access ControlA01 Broken Access ControlStays #1; now explicitly folds in SSRF (CWE-918) and CSRF (CWE-352)[Authorize], policy-based and resource-based authorization
A02 Security MisconfigurationA05 Security MisconfigurationRises from #5 to #2Environment-gated error pages, UseHsts, hardened Program.cs defaults
A03 Software Supply Chain FailuresA06 Vulnerable and Outdated ComponentsRises from #6 to #3; scope widens from known-CVE packages to the whole supply chaindotnet restore NuGet audit, Dependabot, pinned versions
A04 Cryptographic FailuresA02 Cryptographic FailuresFalls from #2 to #4ASP.NET Core Data Protection, AesGcm, no home-grown crypto
A05 InjectionA03 InjectionFalls from #3 to #5; still includes XSSParameterized SQL, EF Core LINQ, Razor's automatic encoding
A06 Insecure DesignA04 Insecure DesignFalls from #4 to #6Threat modeling, secure-by-default architecture
A07 Authentication FailuresA07 Identification and Authentication FailuresRenamed, same rankCookie/JWT/OIDC handlers, lockout, MFA
A08 Software or Data Integrity FailuresA08 Software and Data Integrity FailuresRenamed, same rankSigned packages, CI provenance, safe deserialization
A09 Security Logging and Alerting FailuresA09 Security Logging and Monitoring FailuresRenamed, same rankStructured logging, no secrets in logs, alerting
A10 Mishandling of Exceptional ConditionsA10 Server-Side Request Forgery (SSRF)New category; SSRF moved into A01Explicit error handling, no silent catch, ProblemDetails

The practical takeaway is that access control, configuration hardening and supply-chain hygiene now outrank injection and cryptography as the categories most likely to be cited against a typical .NET service, even though injection and crypto mistakes are still just as damaging when they happen.

How These Risks Map to the ASP.NET Core Pipeline#

Every category above intersects the request pipeline at a predictable point, which is useful when you are deciding where a mitigation belongs rather than bolting it onto a controller as an afterthought. Model binding and EF Core sit closest to injection and insecure deserialization. Middleware order and environment checks are where security misconfiguration is won or lost. Authentication middleware owns A07; authorization middleware and [Authorize] own the access-control portion of A01; the antiforgery middleware owns the CSRF portion of A01; and any code that makes an outbound HttpClient call from user-influenced input owns the SSRF portion. This guide covers each of those points, but treats authentication and authorization schemes themselves as a separate concern β€” see Authentication in ASP.NET Core and Authorization in ASP.NET Core for the full mechanics of cookies, JWTs, OpenID Connect, policies and resource-based checks.

Getting Started: A Security Baseline for a New API#

Before touching individual risk categories, start every new ASP.NET Core project from a baseline that assumes production from day one, not a baseline you harden later:

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(/* configure your scheme(s) here */);
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build());
builder.Services.AddAntiforgery();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();

app.MapGet("/health", () => Results.Ok("healthy")).AllowAnonymous();

app.Run();

The fallback policy makes every endpoint require authentication unless it explicitly opts out with AllowAnonymous(), which is the deny-by-default posture that A01 keeps rewarding. The antiforgery middleware must run after authentication and authorization so it never reads form data from an unauthenticated request. The rest of this guide builds on top of this baseline, one risk category at a time.

A01: Broken Access Control#

Access control means checking that the authenticated caller is allowed to touch this specific record, not just that they are logged in. The most common failure is an Insecure Direct Object Reference: an endpoint trusts an id from the URL without checking that the caller owns it.

C#
// Vulnerable: any authenticated user can read any invoice by guessing an id.
app.MapGet("/api/invoices/{id:int}", async (int id, AppDbContext db) =>
{
    var invoice = await db.Invoices.FindAsync(id);
    return invoice is null ? Results.NotFound() : Results.Ok(invoice);
}).RequireAuthorization();

// Fixed: authorize against the resource, not just the endpoint.
app.MapGet("/api/invoices/{id:int}", async (
    int id, ClaimsPrincipal user, AppDbContext db, IAuthorizationService authz) =>
{
    var invoice = await db.Invoices.FindAsync(id);
    if (invoice is null)
    {
        return Results.NotFound();
    }

    var result = await authz.AuthorizeAsync(user, invoice, "InvoiceOwner");
    return result.Succeeded ? Results.Ok(invoice) : Results.Forbid();
}).RequireAuthorization();

IAuthorizationService.AuthorizeAsync takes the loaded resource, so the handler behind the "InvoiceOwner" policy can compare invoice.CustomerId against the caller's claims instead of trusting the route. Apply the same discipline to bulk endpoints, admin panels and anything driven by a client-supplied filter β€” access control failures usually live in the code paths nobody thought needed a review.

A05: Injection (SQL and Command Injection)#

Injection happens whenever untrusted input is concatenated into something that gets parsed as code, most commonly SQL, but the same failure applies to shell commands, LDAP filters and NoSQL query documents.

C#
// Vulnerable: string-built SQL lets input control the query's structure.
var sql = $"SELECT * FROM Orders WHERE CustomerId = '{customerId}'";
using var cmd = new SqlCommand(sql, connection);

// Fixed: parameters travel separately from the query text.
using var cmd = new SqlCommand(
    "SELECT * FROM Orders WHERE CustomerId = @CustomerId", connection);
cmd.Parameters.Add("@CustomerId", SqlDbType.NVarChar, 50).Value = customerId;

// EF Core's LINQ provider parameterizes automatically β€” prefer it outright.
var orders = await db.Orders
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

FromSqlRaw and ExecuteSqlRaw in EF Core are just as exploitable as raw ADO.NET if you interpolate a string into them; always use FromSql/ExecuteSql with interpolated-string overloads or explicit SqlParameter values, which EF Core parameterizes for you even though the call site looks like string interpolation.

Command injection is the same pattern applied to a shell:

C#
// Vulnerable: the shell reinterprets user input as additional arguments.
Process.Start("cmd.exe", $"/c convert {userFileName} output.png");

// Fixed: pass discrete arguments with no shell in the middle.
var psi = new ProcessStartInfo("convert") { UseShellExecute = false };
psi.ArgumentList.Add(userFileName);
psi.ArgumentList.Add("output.png");
using var process = Process.Start(psi)!;
await process.WaitForExitAsync(cancellationToken);

ArgumentList passes each argument as a discrete token to the process, so a file name containing ; rm -rf / is just a (rejected) file name, never a second command.

Cross-Site Scripting and Razor Encoding#

Razor HTML-encodes every expression by default, which is why most ASP.NET Core apps are not trivially vulnerable to reflected or stored XSS out of the box:

Razor
@* Safe: Razor encodes Model.Name even if it contains markup. *@
<p>Welcome back, @Model.Name</p>

@* Dangerous: Html.Raw and MarkupString skip encoding entirely. *@
<p>@Html.Raw(Model.Bio)</p>

In Blazor, MarkupString is the equivalent escape hatch β€” reserve it for server-generated, trusted markup, never for anything a user supplied. Add a Content Security Policy as a second layer so that even markup which slips through is less useful to an attacker:

C#
app.Use(async (context, next) =>
{
    context.Response.Headers.Append(
        "Content-Security-Policy", "default-src 'self'; object-src 'none'");
    await next();
});

Cross-Site Request Forgery#

CSRF tricks a signed-in browser into submitting a request the user never intended. ASP.NET Core's antiforgery system covers this for anything that reads form data: Razor Pages and MVC validate tokens automatically through built-in filters, while Blazor and Minimal APIs need UseAntiforgery() in the pipeline plus, for Minimal APIs, the services registered with AddAntiforgery() shown in the baseline above.

C#
// Minimal APIs: form-binding endpoints require a valid token by default
// once AddAntiforgery()/UseAntiforgery() are registered.
app.MapPost("/todo", ([FromForm] TodoInput input) => Results.Ok());

// An endpoint that never reads form data and isn't reachable with cookie
// auth from a browser can opt out explicitly.
app.MapPost("/api/webhooks/payment", (PaymentEvent evt) => Results.Accepted())
   .DisableAntiforgery();

For MVC controllers, apply [ValidateAntiForgeryToken] to state-changing actions, or [AutoValidateAntiforgeryToken] at the controller level so every unsafe HTTP verb is covered without repeating the attribute. Reach for [IgnoreAntiforgeryToken]/DisableAntiforgery() only for endpoints that are not reachable from a browser with cookies, such as machine-to-machine APIs secured with bearer tokens. ASP.NET Core 11, expected alongside .NET 11, adds an automatic CSRF middleware that inspects the Sec-Fetch-Site fetch-metadata header and your CORS policy before a request reaches a form-handling endpoint, reducing how much of this you have to wire up by hand β€” but until that ships, the token-based system above is what you configure yourself.

Server-Side Request Forgery#

SSRF is what happens when your server, not the attacker's browser, is tricked into making a request β€” typically because a URL, hostname or IP came from user input and was fetched without validation. The 2025 edition folds SSRF into Broken Access Control because the underlying failure is the same: trusting a caller-supplied destination as if it were an internal decision.

C#
// Vulnerable: any URL the caller supplies is fetched directly, including
// http://169.254.169.254/ (cloud metadata) or an internal admin endpoint.
var response = await httpClient.GetAsync(request.CallbackUrl);

// Fixed: validate the parsed host against an allow-list before fetching.
if (!Uri.TryCreate(request.CallbackUrl, UriKind.Absolute, out var uri) ||
    uri.Scheme != Uri.UriSchemeHttps ||
    !AllowedWebhookHosts.Contains(uri.Host))
{
    return Results.BadRequest("Callback host is not allowed.");
}

var response = await httpClient.GetAsync(uri, cancellationToken);

Validate the host after parsing the URL, not with a regular expression against the raw string, and re-validate after following any redirect β€” a first request to an allowed host can still redirect to an internal address. Route every such outbound call through a small number of named HttpClient instances so the allow-list only has to be enforced in one place.

Insecure Deserialization#

Deserializing untrusted bytes into live objects is dangerous when the deserializer can be told which type to construct, because that turns the payload into a limited form of remote code execution. BinaryFormatter is the canonical example in .NET: it is unsafe by construction, and starting in .NET 9 the in-box implementation throws on every call, with no configuration switch left to re-enable it. Replace any remaining use of it with System.Text.Json against a known, non-polymorphic contract:

C#
// System.Text.Json deserializes into a specific, known type by default,
// which is what makes it safe for untrusted input in the first place.
var payload = JsonSerializer.Deserialize<WebhookPayload>(json, jsonOptions);

If you still use Newtonsoft.Json for legacy compatibility, never combine TypeNameHandling with untrusted input β€” it lets the payload itself choose which .NET type to instantiate, which is exactly the primitive that made BinaryFormatter dangerous. Keep TypeNameHandling.None (the default) unless you fully control both ends of the serialized data.

A02: Security Misconfiguration#

Security misconfiguration covers everything from a stack trace leaking to production users to a missing HSTS header. The baseline in Getting Started already covers the fundamentals; extend it as the app grows:

C#
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseStatusCodePages();

// Don't expose Swagger/OpenAPI or detailed 404s in production by default.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

Beyond the pipeline, review the defaults you inherit: remove or replace the Server response header, confirm cookies used for authentication set Secure, HttpOnly and an appropriate SameSite, and make sure connection strings and API keys never ship in appsettings.json β€” see Secrets Management in .NET for the user secrets, Key Vault and managed identity patterns that replace hardcoded configuration.

A03: Software Supply Chain Failures (NuGet Audit)#

Depending on packages you did not write means inheriting their vulnerabilities. Starting in .NET 8, dotnet restore runs a NuGet security audit automatically, reporting known vulnerabilities in your dependencies with severity and an advisory link:

XML
<PropertyGroup>
  <NuGetAudit>true</NuGetAudit>
  <NuGetAuditLevel>moderate</NuGetAuditLevel>
  <NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>

NuGetAuditLevel (low, moderate, high, critical) sets the severity that fails the build. NuGetAuditMode controls scope: .NET 8 and 9 audit only direct package references by default, while .NET 10 audits both direct and transitive references by default, which is a meaningfully wider net if you have not revisited this setting since upgrading. Pair the build-time audit with an on-demand check and, for CI, GitHub's Dependabot alerts:

Bash
dotnet list package --vulnerable --include-transitive

See Creating and Publishing NuGet Packages the Right Way for the other half of supply-chain hygiene: what you should verify before adding a dependency in the first place.

A09: Security Logging and Alerting Failures#

Logging failures are rarely about missing log statements; they are about logging the wrong things or not being able to act on what you logged. Never log credentials, tokens or full request bodies for authentication endpoints:

C#
// Vulnerable: the password lands in every log sink, from the console to
// whatever centralized log storage the team uses.
logger.LogInformation("Login attempt for {User} with password {Password}",
    request.Username, request.Password);

// Fixed: log identifiers and outcomes, never secrets.
logger.LogInformation("Login attempt for {User} succeeded: {Succeeded}",
    request.Username, result.Succeeded);

Use structured logging (message templates with named placeholders, as above) so the fields are queryable rather than buried in free text, and make sure security-relevant events β€” sign-in, permission denied, password reset, admin actions β€” are distinguishable enough to alert on. A perfect audit trail nobody monitors satisfies the letter of A09 and fails its intent.

Best Practices#

  • Default to deny: require authorization globally with a fallback policy and opt individual endpoints out with AllowAnonymous, rather than opting protected ones in one at a time.
  • Treat every external input as hostile, including headers, query strings, file names, and webhook payloads β€” not only form fields and JSON bodies.
  • Keep NuGetAudit on and fail CI at high or critical, rather than treating the audit as advisory output to skim later.
  • Centralize outbound HTTP calls behind a small number of named HttpClient instances so an SSRF allow-list is enforced in one place, not scattered across handlers.
  • Run dependency and secret scanning on every pull request, not on a schedule β€” see CI/CD for .NET with GitHub Actions and Azure DevOps for wiring this into a pipeline.
  • Log security-relevant events with enough context to investigate an incident, and audit what you log for anything that could itself become a secret.

Common Pitfalls#

  • Assuming Razor's automatic encoding protects Html.Raw or MarkupString output too β€” it explicitly does not, by design.
  • Calling .DisableAntiforgery() on a Minimal API endpoint to make a form post "just work," then never revisiting whether that endpoint actually needed the protection.
  • Silencing the NuGet audit with <NuGetAudit>false</NuGetAudit> instead of triaging the advisory or pinning a patched version.
  • Building SQL or shell commands with string interpolation for an internal tool that later gets exposed on a public endpoint without anyone revisiting the query layer.
  • Validating an SSRF allow-list against the raw input string instead of the parsed Uri.Host, which redirects and encoding tricks can bypass.

When Automated Scanning Is Not Enough#

Static analysis, dotnet list package --vulnerable and CI-gated NuGet audits catch known patterns and known CVEs, but broken access control and insecure design β€” the two highest-ranked categories in 2025 β€” are usually business-logic problems that no scanner can infer from source code alone. A tool cannot know that /api/invoices/{id} should check ownership; it can only tell you the endpoint exists. Pair automated scanning with periodic threat modeling for anything that handles money, personal data or administrative privilege, and with manual or third-party penetration testing before major releases. Cryptographic mistakes deserve the same treatment: see Cryptography and Data Protection in .NET for the algorithms and APIs that keep A04 out of your codebase in the first place.

Frequently Asked Questions#

Is the OWASP Top 10:2025 the edition I should use now?#

Yes. The OWASP Top 10:2025 is published as final on the official repository, and the 2021 edition is explicitly marked superseded there. New threat models, security reviews and audits should reference 2025; existing documents that cite 2021 are still broadly useful but use the older category names and ranking.

Do I need to fix every OWASP Top 10 risk before shipping?#

No single list can replace prioritization based on what your application actually does. Rank fixes by exposure (is the endpoint public or internal?) and data sensitivity (does a failure leak PII, financial data or credentials?), and treat A01 Broken Access Control as the default starting point, since it affects the widest range of applications and is usually the cheapest to get partly right with a deny-by-default policy.

Does ASP.NET Core protect me from these risks automatically?#

Partially. Razor's output encoding, EF Core's parameterized LINQ provider, and the antiforgery system for MVC and Razor Pages are on by default and close off large classes of XSS, injection and CSRF. Authorization, HSTS, outbound-request validation and log hygiene are not automatic β€” they require the explicit configuration shown throughout this guide.

How is XSS different from injection in the 2025 list?#

It isn't a separate category. Cross-site scripting is grouped under A05 Injection because both are the same underlying failure β€” untrusted input reaching a context where it is interpreted as code, whether that's a SQL parser or an HTML renderer β€” just with a browser instead of a database as the target.

What changed the most for a typical internal .NET API between 2021 and 2025?#

Security misconfiguration and supply-chain failures both jumped several ranks, from #5 and #6 in 2021 to #2 and #3 in 2025, while SSRF disappeared as its own category and moved into broken access control. If your last security review predates 2025, misconfiguration and dependency hygiene are the two areas most likely to be under-prioritized relative to the current data.

Summary#

  • The OWASP Top 10:2025 is the current, finalized edition; cite it instead of 2021 going forward, and expect the reordered categories to shift where your team spends review time.
  • Broken Access Control stays #1 and now explicitly includes SSRF and CSRF; a deny-by-default authorization policy is the highest-leverage single fix.
  • EF Core's LINQ provider and ArgumentList-based process starts close off SQL and command injection without extra libraries.
  • Razor's automatic encoding handles most XSS, but Html.Raw and MarkupString are explicit, deliberate exceptions you must audit yourself.
  • dotnet restore's built-in NuGet audit, tunable with NuGetAudit, NuGetAuditLevel and NuGetAuditMode, now covers transitive dependencies by default starting in .NET 10.
  • BinaryFormatter throws unconditionally starting in .NET 9; System.Text.Json against a known type is the safe default for deserializing untrusted input.

Further Reading#