Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #17

Putting the OWASP Top 10 Together: Secure Defaults for ASP.NET Core and Angular

Mert Özen Nov 6, 2026 13 min 3 views
Putting the OWASP Top 10 Together: Secure Defaults for ASP.NET Core and Angular

We put the ten categories together in a single Program.cs: fallback policy, cookie flags, ProblemDetails, rate limiting that returns 429, security headers and rejecting unknown JSON fields. We verify everything with curl, do SAST with the .NET analyzers, write security regression tests with WebApplicationFactory, and talk about DAST and moving to ASVS 5.0.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

The Bookstore API has a fallback policy: nobody who is not signed in can reach any endpoint. One day, in a hurry, you add this line: app.MapGet("/admin/stats", () => new { Books = books.Count });. It has neither RequireAuthorization nor AllowAnonymous. Ada, who has the customer role, signs in and sends a request to it. What comes back: 401, 403 or 200?

We will see the answer in a real output shortly, and that answer carries the main idea of this post. We went through the ten categories one by one; now we put them together in a single Program.cs, a security test project and a checklist. The goal: the secure path should be the one you get without writing extra code. A forgotten line should produce a closed door, not an open one.

As in the rest of the series, everything runs on your own machine against localhost. Only try these techniques on systems you own or have written permission to test.

A Program.cs with Secure Defaults

First the service registrations. The comment above each block says which Top 10 category it maps to:

using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);

// A01: deny by default
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.ExpireTimeSpan = TimeSpan.FromHours(1);
    });
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("admin", policy => policy.RequireRole("Admin"));

// A10 + A02: one error shape, no stack traces
builder.Services.AddProblemDetails();

// A06 + A07: limit login attempts per client IP
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1) }));
});

// A08: unknown JSON fields are an error, not silently ignored
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
builder.Services.AddValidation();

builder.Services.AddSingleton<UserStore>();

The fallback policy is the policy applied to endpoints that carry no authorization metadata at all. In minimal APIs endpoints are anonymous by default; this line turns that around. The cookie gets three flags written out explicitly: JavaScript cannot read it (HttpOnly), it only travels over HTTPS (Secure), and it is not attached to requests coming from other sites (SameSite=Strict). The rate limiter's default rejection code is 503; if you don't change it to 429, clients think the server is down. UnmappedMemberHandling.Disallow is the JSON side of the lesson from the mass assignment post: if a field that is not on the DTO arrives, the request is rejected with 400. AddValidation turns on the built-in minimal API validation that came with .NET 10.

Next, the pipeline. Here the order matters as much as the lines themselves:

WebApplication app = builder.Build();

app.UseSecurityHeaders();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();
    app.UseHsts();
}
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

In my first attempt I had put UseSecurityHeaders after UseExceptionHandler and had not called the authentication and authorization middleware myself. The result: 401 and 500 responses came back without the headers. If you don't call them, WebApplication adds the authentication and authorization middleware at the very start of the pipeline, so the 401 was produced before my middleware ever ran. The exception handler, when it catches an error, clears the response together with its headers. The fix has two parts: move the header middleware to the front and write the headers with OnStarting, right before the response is sent.

static class SecurityHeadersExtensions
{
    // A02: ASP.NET Core has no built-in CSP middleware
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
        app.Use(async (context, next) =>
        {
            context.Response.OnStarting(() =>
            {
                IHeaderDictionary headers = context.Response.Headers;
                headers.XContentTypeOptions = "nosniff";
                headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
                headers["Referrer-Policy"] = "no-referrer";
                return Task.CompletedTask;
            });
            await next();
        });
}

Because this is a JSON API, the CSP is as strict as it gets: no resources can load, and the response cannot be embedded in any frame. Finally, the endpoints and a small UserStore that holds two dev-only users:

List<Book> books = [new(1, "Clean Code", 35.00m), new(2, "Refactoring", 42.50m)];

app.MapGet("/books", () => books).AllowAnonymous();
app.MapGet("/books/{id:int}", (int id) => books.FirstOrDefault(b => b.Id == id) is Book book
    ? Results.Ok(book)
    : Results.NotFound()).AllowAnonymous();

RouteGroupBuilder admin = app.MapGroup("/admin").RequireAuthorization("admin");

admin.MapPost("/books", (BookInput input) =>
{
    Book book = new(books.Count + 1, input.Title, input.Price);
    books.Add(book);
    return TypedResults.Created($"/books/{book.Id}", book);
});
admin.MapGet("/stats", () => new { Books = books.Count });

app.MapPost("/login", async (LoginInput input, UserStore users, HttpContext http) =>
{
    ClaimsPrincipal? principal = users.Verify(input.Email, input.Password);
    if (principal is null)
    {
        return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid email or password.");
    }
    await http.SignInAsync(principal);
    return Results.NoContent();
}).AllowAnonymous().RequireRateLimiting("login");

// no attribute at all: the fallback policy still requires a signed-in user
app.MapGet("/orders/mine", (ClaimsPrincipal user) => new { User = user.Identity!.Name, Orders = Array.Empty<int>() });

app.MapGet("/boom", () =>
{
    throw new InvalidOperationException("Connection string 'Server=db;Password=dev-only-secret' failed.");
}).AllowAnonymous();

app.Run();

public record Book(int Id, string Title, decimal Price);
public record BookInput([Required, StringLength(200)] string Title, [Range(0.01, 10000)] decimal Price);
public record LoginInput(string Email, string Password);

sealed class UserStore
{
    private readonly PasswordHasher<string> _hasher = new();
    private readonly Dictionary<string, (string Hash, string Role)> _users = new();

    public UserStore()
    {
        // dev-only test users, never a real password
        _users["ada@example.com"] = (_hasher.HashPassword("ada@example.com", "dev-only-Correct-Horse-42"), "Customer");
        _users["grace@example.com"] = (_hasher.HashPassword("grace@example.com", "dev-only-Battery-Staple-7"), "Admin");
    }

    public ClaimsPrincipal? Verify(string email, string password)
    {
        if (!_users.TryGetValue(email, out (string Hash, string Role) user))
        {
            return null;
        }
        PasswordVerificationResult result = _hasher.VerifyHashedPassword(email, user.Hash, password);
        if (result == PasswordVerificationResult.Failed)
        {
            return null;
        }
        Claim[] claims = [new(ClaimTypes.Name, email), new(ClaimTypes.Role, user.Role)];
        return new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
    }
}

public partial class Program;

The admin endpoints live in a route group; we gave the policy to the group once and every new endpoint inherits it. The public partial class Program; line at the bottom is there so the test project can start the application.

Checking with Real Requests

We start the app in the Production environment and send requests one after another:

$ ASPNETCORE_ENVIRONMENT=Production dotnet run --no-launch-profile --urls http://localhost:5171
$ curl -i http://localhost:5171/books
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:02:53 GMT
Transfer-Encoding: chunked
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer

[{"id":1,"title":"Clean Code","price":35.00},{"id":2,"title":"Refactoring","price":42.50}]

$ curl -i http://localhost:5171/orders/mine
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Location: http://localhost:5171/Account/Login?ReturnUrl=%2Forders%2Fmine
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"00-3e7ad446...-00"}

There is no Server: Kestrel header, and the security headers are on every response. /orders/mine, which has no attribute at all, returned 401 to the anonymous request. You can see a Location header, but the status code is 401, not 302: in .NET 10, cookie authentication returns 401/403 for API endpoints instead of redirecting to a login page. Now let's sign in with two users:

$ curl -i -c ada.txt -X POST http://localhost:5171/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"dev-only-Correct-Horse-42"}'
HTTP/1.1 204 No Content
Cache-Control: no-cache,no-store
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHC...; path=/; secure; samesite=strict; httponly
...

$ curl -i -b ada.txt http://localhost:5171/admin/stats
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,"traceId":"00-e7190151...-00"}

$ curl -i -b grace.txt -X POST http://localhost:5171/admin/books -H "Content-Type: application/json" -d '{"title":"Dune","price":-5}'
HTTP/1.1 400 Bad Request
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 0.01 and 10000."]},"traceId":"00-ffc6b7fa...-00"}

$ curl -i -b grace.txt -X POST http://localhost:5171/admin/books -H "Content-Type: application/json" -d '{"title":"Dune","price":20,"isFeatured":true}'
HTTP/1.1 400 Bad Request
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"00-787581db...-00"}

The cookie's three flags are on the Set-Cookie line. Ada, a customer, got 403 from the admin group. Even Grace, an admin, could not add a book with a negative price or with an isFeatured field that is not on the DTO. Next up are a brute-force attempt and an error response:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5171/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"wrong"}'; done
401 401 401 429 429 429

$ curl -i http://localhost:5171/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Cache-Control: no-cache,no-store
...
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-998e2b5e...-00"}

Why did 429 show up after three 401s instead of five? Because the two successful logins by Ada and Grace in the same minute were counted too: the limiter counts every request to /login regardless of its result. The 500 response shows no trace of the connection string, but look at the server log: the exception message is there, Password=dev-only-secret included. Information that does not leak to the client can still leak into the logs, which is exactly why the logging post talked about redaction.

Defaults on the Angular Side

Angular's defaults are safe: interpolation ({{ }}) is always escaped, values bound to [innerHTML] are sanitized, and the AOT compiler is the default. Your job is not to break these defaults. Flag every DomSanitizer.bypassSecurityTrust* call in code review; behind each one you should be able to say "this value definitely comes from us".

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    // adds the token read from the cookie as a header on same-origin POST/PUT/DELETE requests
    provideHttpClient(withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' })),
  ],
};

HttpClient adds this token only to mutating requests and only to the same origin; it skips GET and other domains. On the server you have to pair it with IAntiforgery; the CSRF post covers the details. Don't keep the token in localStorage: OWASP's HTML5 cheat sheet also says session identifiers should not be stored there, because a single XSS can read them all. An HttpOnly cookie, plus a BFF (backend for frontend) where needed, is a sturdier starting point.

On the server that hosts the app, start with a strict nonce- or hash-based CSP. Since Angular 16 you can pass a nonce with ngCspNonce, and since Angular CLI 19 the autoCsp option generates hashes for inline scripts at build time. For Trusted Types, Angular's policy name is angular; if you use bypassSecurityTrust*, you also have to allow angular#unsafe-bypass. According to MDN, Trusted Types has been available in all major browsers since February 2026.

Security Tests: SAST, Regression and DAST

SAST: Through the Compiler's Eyes

Static analysis (SAST) reads the code without running it. The analyzers that ship with the .NET SDK are the cheapest place to start and need no extra tooling. But you have to switch the security rules on; a single property does that. Consider this legacy code:

using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;

public static class LegacyQueries
{
    public static string HashPassword(string password) =>
        Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(password)));

    public static SqliteCommand FindBooks(SqliteConnection connection, string title)
    {
        SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, title FROM books WHERE title = '" + title + "'";
        return command;
    }
}

With default settings this project built with zero warnings. Let's add two lines to the csproj:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <AnalysisModeSecurity>All</AnalysisModeSecurity>
  <WarningsAsErrors>$(WarningsAsErrors);CA2100;CA5351</WarningsAsErrors>
</PropertyGroup>
$ dotnet build
Bookstore.Legacy/LegacyQueries.cs(8,29): error CA5351: HashPassword uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)
Bookstore.Legacy/LegacyQueries.cs(13,9): error CA2100: Review if the query string passed to 'string SqliteCommand.CommandText' in 'FindBooks', accepts any user input (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2100)
Build FAILED.
    0 Warning(s)
    2 Error(s)

After rewriting the query with a $title parameter and deleting the MD5 method (Identity's PasswordHasher already exists for passwords), the build passed again with zero errors. CA2100 says "review", it does not prove SQL injection; but a rule that breaks the build in CI beats a line that slips through review. For deeper analysis, tools such as GitHub's CodeQL-based code scanning sit on top of this.

Regression Tests: Tying the Rules to Tests

Everything we saw with curl above can break with the next change. So we write the most critical rules as tests. The Microsoft.AspNetCore.Mvc.Testing package (10.0.14) starts the application in memory:

using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

public class SecurityBaselineTests : IClassFixture<WebApplicationFactory<Program>>
{
    // every anonymous endpoint must be listed here on purpose
    private static readonly string[] PublicEndpoints = ["GET /books", "GET /books/{id:int}", "POST /login"];

    private readonly WebApplicationFactory<Program> _factory;

    public SecurityBaselineTests(WebApplicationFactory<Program> factory) =>
        _factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Production"));

    [Fact]
    public void Only_listed_endpoints_allow_anonymous_access()
    {
        EndpointDataSource source = _factory.Services.GetRequiredService<EndpointDataSource>();
        string[] anonymous = source.Endpoints
            .OfType<RouteEndpoint>()
            .Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
            .Select(e => $"{e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods[0]} {e.RoutePattern.RawText}")
            .ToArray();

        Assert.Empty(anonymous.Except(PublicEndpoints));
    }

    [Fact]
    public async Task Protected_endpoint_returns_401_for_anonymous_user()
    {
        HttpClient client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
        HttpResponseMessage response = await client.GetAsync("/orders/mine");
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Error_response_keeps_security_headers_and_hides_details()
    {
        HttpClient client = _factory.CreateClient();
        HttpResponseMessage response = await client.GetAsync("/boom");
        string body = await response.Content.ReadAsStringAsync();

        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
        Assert.DoesNotContain("Password", body);
    }
}
$ dotnet test
  Failed SecurityBaselineTests.Only_listed_endpoints_allow_anonymous_access [44 ms]
  Error Message:
   Assert.Empty() Failure: Collection was not empty
Collection: ["GET /boom"]
...
Failed!  - Failed:     1, Passed:     2, Skipped:     0, Total:     3, Duration: 273 ms - Bookstore.SecurityTests.dll (net10.0)

The first test did its job: we had opened /boom with AllowAnonymous and nobody had put it on the list. In the lab we keep this endpoint on purpose, so after adding "GET /boom" to the list with a comment, all three tests passed. In a real project this test catches, in the pull request, the endpoint someone made anonymous "temporarily" and forgot.

DAST: Looking at the Running App from Outside

Dynamic analysis (DAST) sends requests to the running application like a client and inspects the responses. The best-known open source tool is ZAP; many sources still call it OWASP ZAP, but it left OWASP in 2023 and is now developed as "ZAP by Checkmarx". Its "baseline" scan crawls the application and inspects the responses passively; it reports missing headers, cookie flags and error pages that leak information, and makes no active attack attempts. We don't run ZAP in this post. If you do run it, the target must be the app on your own machine or a test environment you own; scanning someone else's system needs permission, even when the scan is "passive". DAST catches configuration mistakes (A02), but it knows nothing about business logic.

Using the Top 10 Well and Moving to ASVS

The Top 10 is not a standard but an awareness document. There is no "Top 10 compliant" certificate. The list works best for three jobs: giving the team a shared vocabulary, reminding reviewers where to look in code review, and setting priorities when you talk about risk. In documents and issues, write the category with its year (A01:2025); the numbers shift with every edition, and we will see that with a tool in post 20.

The answer to "what should I verify?" is the OWASP Application Security Verification Standard, ASVS. The current stable version is 5.0.0, published on May 30, 2025. Instead of categories, ASVS lists verifiable requirements and groups them into levels; you pick a level according to your application's risk and check that level's requirements. A practical approach: pick the requirements that apply, tie each one to a test or a CI rule, and put the rest on the code review list. For the "how to" part, go to the OWASP Cheat Sheet Series; we look at these projects separately in the last post of the series.

Common Pitfalls

The first is the answer to the opening question. In the first version, /admin/stats was not in the group and had nothing on it, and with Ada's cookie this came back:

$ curl -i -b ada.txt http://localhost:5171/admin/stats
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{"books":2}

The fallback policy only says "be signed in"; it does not know the role for you. Deny by default is a safety net for authentication; the authorization decision is still yours. That is why the admin endpoints live in a group with a policy.

The second is AddValidation not seeing your types. In my first attempt the DTOs were not public (record BookInput(...)) and a negative price was silently accepted: 201 Created, {"id":3,"title":"Dune","price":-5}. In this run, the .NET 10 validation source generator produced validation code only for public types.

The third is middleware order. As described above, the headers disappeared from 401 and 500 responses. Check the headers on error responses too; the third test method does that.

The fourth is mistaking a tool's green light for security. The analyzer reported zero warnings and the ZAP report was clean; that does not mean there is no IDOR.

Checklist

On the server: the fallback policy is on and admin operations live in groups with policies (A01). In Production there is no developer exception page, there is ProblemDetails, the Server header is off, and CSP, nosniff and frame-ancestors are on every response, error responses included (A02). Dependencies are watched with NuGetAudit and dotnet package list --vulnerable, and lock files are committed (A03). Passwords go through PasswordHasher with the iteration count raised to OWASP's recommendation, and secrets are not in the code (A04). Queries are parameterized and output is encoded (A05). Login and expensive endpoints have rate limiting that returns 429 (A06, A07). Cookies carry the HttpOnly, Secure and SameSite flags (A07). Unknown JSON fields are rejected and DTOs are separate (A08). Failed logins and authorization denials are logged, and secrets do not end up in logs (A09). There is a global exception handler, and on error the system stays closed (A10).

On the Angular side: no bypassSecurityTrust*, or each one has a reason; a strict CSP and Trusted Types; XSRF configured; the token is not in localStorage. In the process: security analyzers run in CI at error level, rules such as the anonymous endpoint list live in tests, DAST runs only against your own environment, and every new feature gets a threat modeling question.

A Small Experiment

★☆☆ Add a Permissions-Policy: camera=(), microphone=(), geolocation=() header to UseSecurityHeaders. Then write an assert for it in the Error_response_keeps_security_headers_and_hides_details test, and first remove the header to watch the test fail.

★★☆ Add [EmailAddress] and [StringLength(128)] to LoginInput. Make the record non-public and send a request with an invalid email, then make it public again. Write an integration test that shows the difference.

★★★ Add antiforgery to the cookie-protected POST /admin/books: set the header name to X-XSRF-TOKEN with AddAntiforgery, write an endpoint that hands out the token in an XSRF-TOKEN cookie, and prove with a test that a request without the token is rejected. Remember that validation is not automatic for minimal API endpoints with a JSON body; you will have to call IAntiforgery.ValidateRequestAsync yourself.

The backbone ends here. In the next post we travel back in time: to the OWASP Top 10:2017 list, the web of those years and lessons that still hold today, such as XXE and insecure deserialization.