Security How-to

Write the authorisation tests before the fix: one matrix, every endpoint, every caller

Agents add endpoints that work and forget who may call them. A single xUnit theory and a route-coverage guard make that a red build. About 20 minutes to set up.

Four heavy riveted doors in a dark concrete hall, three shut and one ajar with gold light spilling across the floor.

Ask an agent for “an endpoint to delete an invoice” and it will give you an endpoint that deletes an invoice. It will compile, it will pass the test the agent wrote for it, and it will let anyone on the internet call it. Nothing in the request said who may delete, so nothing in the code does.

Missing authorisation is the classic generated-code defect because it’s a defect of omission. There’s no wrong line to spot in review, only a line that isn’t there. The fix is to write down who may call what, as a test, before the agent touches the code. Then the agent gets a red build that names the endpoint and the caller, and the same test fails again the next time somebody (or something) adds a route and forgets.

Nothing here is tied to one agent. The technique, the prompt and the check all work with any coding agent that can read files and run dotnet test, and the test is just as useful with a human at the keyboard.

The API under test

A small .NET 10 minimal API with four routes. Reading an invoice should be allowed for its owner or an admin. Deleting one is admin only. The revenue report is admin only. Health is public.

This is the version a first pass tends to produce:

using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Admin", p => p.RequireRole("admin"));
builder.Services.AddSingleton<InvoiceStore>();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/invoices/{id:int}", (int id, ClaimsPrincipal user, InvoiceStore store) =>
    store.Find(id) is { } invoice ? Results.Ok(invoice) : Results.NotFound())
    .RequireAuthorization();

app.MapDelete("/invoices/{id:int}", (int id, InvoiceStore store) =>
    store.Remove(id) ? Results.NoContent() : Results.NotFound());

app.MapGet("/reports/revenue", () => Results.Ok(new { total = 12_400m }))
    .RequireAuthorization("Admin");

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

app.Run();

public record Invoice(int Id, string OwnerId, decimal Amount);

public class InvoiceStore
{
    private readonly Dictionary<int, Invoice> _items = new()
    {
        [1] = new(1, "alice", 120m),
        [2] = new(2, "bob", 80m),
    };

    public Invoice? Find(int id) => _items.GetValueOrDefault(id);
    public bool Remove(int id) => _items.Remove(id);
}

public partial class Program;

Two bugs, both of the sort that survive review. The GET requires a signed-in user, which looks like authorisation, but any signed-in user can read any invoice. The DELETE has no requirement at all. The ClaimsPrincipal user parameter on the GET is even a hint that someone meant to check ownership and didn’t get round to it.

The last line, public partial class Program;, exposes the entry point so the test project can reference it.

Set up the test project

Create the test project next to the API and add the integration-testing package:

dotnet new xunit -n Api.Tests -o Api.Tests
cd Api.Tests
dotnet add reference ../Api
dotnet add package Microsoft.AspNetCore.Mvc.Testing

The test host needs to sign callers in without a real token issuer. Microsoft’s integration-testing docs describe the approach: register a test AuthenticationHandler through ConfigureTestServices, and make it the default scheme. This one reads two headers, so a test can pick a user and a role per request:

using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Api.Tests;

public class TestAuthHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    public const string SchemeName = "TestScheme";

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Test-User", out var user))
            return Task.FromResult(AuthenticateResult.NoResult());

        var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.ToString()) };
        if (Request.Headers.TryGetValue("X-Test-Role", out var role))
            claims.Add(new Claim(ClaimTypes.Role, role.ToString()));

        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
        return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
    }
}

No header means NoResult, which is what an anonymous caller looks like to the pipeline. That’s what produces a 401 on a protected route.

The matrix

The test is one xUnit theory. Each row is a route, a caller and the status code that caller must get. Reading the table is the review: a person (or an agent) can see the whole access policy on one screen.

using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace Api.Tests;

public class AuthorisationMatrixTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private HttpClient ClientFor(string? user, string? role = null)
    {
        var client = factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
            s.AddAuthentication(o =>
            {
                o.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
                o.DefaultChallengeScheme = TestAuthHandler.SchemeName;
            }).AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { })))
            .CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
        if (user is not null) client.DefaultRequestHeaders.Add("X-Test-User", user);
        if (role is not null) client.DefaultRequestHeaders.Add("X-Test-Role", role);
        return client;
    }

    // method, route pattern, concrete path, caller, expected status
    public static TheoryData<string, string, string, string, HttpStatusCode> Cases => new()
    {
        { "GET",    "/invoices/{id:int}", "/invoices/1",      "anonymous", HttpStatusCode.Unauthorized },
        { "GET",    "/invoices/{id:int}", "/invoices/1",      "alice",     HttpStatusCode.OK },
        { "GET",    "/invoices/{id:int}", "/invoices/1",      "bob",       HttpStatusCode.Forbidden },
        { "GET",    "/invoices/{id:int}", "/invoices/1",      "admin",     HttpStatusCode.OK },
        { "DELETE", "/invoices/{id:int}", "/invoices/2",      "anonymous", HttpStatusCode.Unauthorized },
        { "DELETE", "/invoices/{id:int}", "/invoices/2",      "alice",     HttpStatusCode.Forbidden },
        { "DELETE", "/invoices/{id:int}", "/invoices/2",      "admin",     HttpStatusCode.NoContent },
        { "GET",    "/reports/revenue",   "/reports/revenue", "anonymous", HttpStatusCode.Unauthorized },
        { "GET",    "/reports/revenue",   "/reports/revenue", "alice",     HttpStatusCode.Forbidden },
        { "GET",    "/reports/revenue",   "/reports/revenue", "admin",     HttpStatusCode.OK },
        { "GET",    "/health",            "/health",          "anonymous", HttpStatusCode.OK },
    };

    [Theory]
    [MemberData(nameof(Cases))]
    public async Task Endpoint_enforces_who_may_call_it(
        string method, string pattern, string path, string caller, HttpStatusCode expected)
    {
        using var client = caller switch
        {
            "anonymous" => ClientFor(null),
            "admin" => ClientFor("root", "admin"),
            _ => ClientFor(caller),
        };

        var response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), path));

        Assert.True(response.StatusCode == expected,
            $"{method} {pattern} as {caller}: expected {(int)expected} {expected}, got {(int)response.StatusCode} {response.StatusCode}");
    }
}

Three details matter. AllowAutoRedirect = false is in Microsoft’s own example, and it’s what lets an unauthenticated call show up as a status code instead of a redirect. Each call gets a fresh WithWebHostBuilder host, so the DELETE rows don’t poison each other through the in-memory store. And the assertion message carries the route pattern, the caller and both status codes, because that string is what the agent reads when the test fails.

Notice what the table encodes that the code didn’t: bob (a signed-in non-owner) must get 403 on alice’s invoice. That row is the whole point of the exercise. A test that only checked “anonymous gets 401” would pass against the buggy GET.

Run it against the buggy API

dotnet test

It prints, trimmed:

Failed Api.Tests.AuthorisationMatrixTests.Endpoint_enforces_who_may_call_it(... caller: "bob", expected: Forbidden)
  GET /invoices/{id:int} as bob: expected 403 Forbidden, got 200 OK
Failed Api.Tests.AuthorisationMatrixTests.Endpoint_enforces_who_may_call_it(... caller: "alice", expected: Forbidden)
  DELETE /invoices/{id:int} as alice: expected 403 Forbidden, got 204 NoContent
Failed Api.Tests.AuthorisationMatrixTests.Endpoint_enforces_who_may_call_it(... caller: "anonymous", expected: Unauthorized)
  DELETE /invoices/{id:int} as anonymous: expected 401 Unauthorized, got 204 NoContent
Failed!  - Failed:     3, Passed:    10, Skipped:     0, Total:    13

Ten rows pass, including the ones that make the code look secure. Three fail, and each failure says which endpoint, which caller and what it got.

Hand the red tests to the agent

Now the agent gets the job, with the test as the spec. A prompt that works in any coding agent:

The tests in Api.Tests/AuthorisationMatrixTests.cs describe who may call each
endpoint in Api/Program.cs. Three rows fail. Run `dotnet test`, then change
Program.cs (not the tests) until every row passes. Don't loosen any expected
status, don't add AllowAnonymous to a route that didn't have it, and don't edit
the Cases table. Explain each change in one sentence.

The constraints do real work. Left alone, an agent under pressure to go green will sometimes change the expectation instead of the code. Telling it the table is read-only, and checking git diff --stat afterwards to see that only Program.cs moved, closes that door.

A correct fix looks like this:

app.MapGet("/invoices/{id:int}", (int id, ClaimsPrincipal user, InvoiceStore store) =>
    store.Find(id) switch
    {
        null => Results.NotFound(),
        var invoice when invoice.OwnerId == user.FindFirstValue(ClaimTypes.NameIdentifier)
                      || user.IsInRole("admin") => Results.Ok(invoice),
        _ => Results.Forbid(),
    })
    .RequireAuthorization();

app.MapDelete("/invoices/{id:int}", (int id, InvoiceStore store) =>
    store.Remove(id) ? Results.NoContent() : Results.NotFound())
    .RequireAuthorization("Admin");

Run dotnet test again:

Passed!  - Failed:     0, Passed:    13, Skipped:     0, Total:    13

One design choice is visible in that snippet. The ownership rule lives inside the handler, because a policy attribute can’t see the invoice. That’s the place agents most often get it wrong in the other direction, by checking the role and forgetting the owner. The bob row is what catches it.

Make forgetting impossible

The theory proves the routes it lists. It says nothing about the route somebody adds next week. The second test closes that gap by asking the framework for every endpoint it actually serves and failing if any lacks a row:

[Fact]
public void Every_endpoint_has_rows_in_the_matrix()
{
    var routes = factory.Services.GetServices<EndpointDataSource>()
        .SelectMany(d => d.Endpoints).OfType<RouteEndpoint>()
        .SelectMany(e => (e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? [])
            .Select(m => $"{m} {e.RoutePattern.RawText}"))
        .ToHashSet();
    var covered = Cases.Select(c => $"{c[0]} {c[1]}").ToHashSet();

    Assert.Empty(routes.Except(covered));
}

Add app.MapPost("/invoices", () => Results.Created()).RequireAuthorization(); to the API and the build goes red:

Assert.Empty() Failure: Collection was not empty
Collection: ["POST /invoices"]
Failed!  - Failed:     1, Passed:    12, Skipped:     0, Total:    13

The message names the route. Whoever added it, human or agent, now has to decide who may call it and write that down as rows. An agent told to “make the tests pass” has no way to do that without producing an access policy, which is the thing it skipped the first time.

Variations

For a controller-based API, the same enumeration works because controller actions are also RouteEndpoints. The route pattern text will include the controller route, so keep the Cases patterns in step with what the guard prints.

If the app uses real JWTs in production, keep the test scheme for this matrix and add a handful of tests with real tokens elsewhere. The matrix checks the authorisation rules, not the token validation, and mixing the two makes rows slow and noisy.

For a TypeScript API, the same shape works with Vitest and a test double for whatever your auth middleware reads: one table, one it.each, and and a guard that lists the routes your framework has registered. The idea doesn’t depend on C#.

What goes wrong

The matrix only checks the callers you list. A tenant boundary, a soft-deleted owner or a service account each need a row, and nothing will remind you. Treat the caller list as part of the design, and add a row whenever a new kind of caller appears.

Status codes are a blunt oracle. A 403 on bob’s request proves the check ran, not that it checked the right thing. If a route returns 404 to hide existence from non-owners (a fair choice), the expected value changes, and so does the row.

The test handler bypasses token parsing, issuer and audience checks. A misconfigured AddJwtBearer can reject every real caller while this suite stays green.

Don’t bother if the API is three routes behind a gateway that already enforces access, and the app never sees an unauthenticated call. The matrix earns its place when routes are added often and by several hands, which is exactly when agents are involved.

The first 20 minutes

Copy TestAuthHandler and the theory into a test project next to your API, and write the rows for your two most sensitive routes by hand, from the requirements and not from the code. Run it. If every row passes first time, add the route-coverage guard and see what it finds.