API5 BFLA: Automated Authorization Tests from the Endpoint Inventory and Separating Admin APIs
You close BFLA for good with tests that list the endpoints the application really exposes through EndpointDataSource and verify that each one carries an explicit authorization decision and has rows in the matrix. We separate the admin surface with a route group, a policy and its own port, show that RequireHost can be bypassed with the Host header and add a real port check, and handle per-method permissions and method override headers.
Version note: The code in this post was built and run with .NET 10 and EF (Entity Framework) Core 10.0.14.
Guess First
The lab has been growing for five posts: orders, invoices, API (Application Programming Interface) keys, reports, SMS (Short Message Service) notifications. How many endpoints are there right now, and can you show in a table who may call each of them? A harder question: what keeps that table correct tomorrow, when someone adds a new endpoint?
Before starting this post I couldn't count them. I searched the code for Map calls, combined group prefixes and noticed that two endpoints had no row in the matrix at all and five were protected only thanks to the fallback policy. I wrote a test to find that, and the test said the same.
This post is about API5 (the fifth item in the OWASP API Security Top 10 2023): BFLA (Broken Function Level Authorization). The basics of the fallback policy and role policies are in the A01 Broken Access Control post of the Web Security (OWASP Top 10) series. Here we build permanent, automated protection on top of that: an inventory of the endpoints the application really exposes, an explicit authorization decision for every endpoint, a two-way check between the matrix and the inventory, and moving the admin surface to a separate port.
Where BFLA Comes From: The Forgotten Endpoint
The API5 page of OWASP (Open Worldwide Application Security Project) lists the typical questions: can a regular user reach admin endpoints, can they perform an operation they aren't allowed to by switching the HTTP (Hypertext Transfer Protocol) method from GET to DELETE, can they call a function that belongs to another group by guessing the URL (Uniform Resource Locator)? What the three have in common is that authorization is a decision "per endpoint", and on some endpoints that decision was never made or was made wrongly.
With BOLA (Broken Object Level Authorization) the question was "which record", and we moved the answer into the data layer. With BFLA the question is "which function", and the answer lives in endpoint metadata. ASP.NET Core already keeps that metadata: every endpoint's route, HTTP methods, authorization and anonymous attributes can be read through EndpointDataSource. Instead of keeping an inventory by hand, we can take it from the application itself.
The Endpoint Inventory: EndpointDataSource
The test takes every route endpoint from the application built by WebApplicationFactory and turns it into the matrix's format:
// The endpoints the application really exposes, in the form "GET /orders/{orderId}"
private IReadOnlyList<(string Endpoint, EndpointMetadataCollection Metadata)> Inventory() =>
factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
.OfType<RouteEndpoint>()
.SelectMany(e => (e.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods ?? ["ANY"])
.Select(method => ($"{method} {Normalize(e.RoutePattern.RawText!)}", e.Metadata)))
.ToList();
// Drops route constraints and a trailing "/": "/orders/{orderId:guid}" -> "/orders/{orderId}"
private static string Normalize(string route)
{
string withoutConstraints = RouteConstraint().Replace(route, "{$1}");
return withoutConstraints.Length > 1 ? withoutConstraints.TrimEnd('/') : withoutConstraints;
}
[GeneratedRegex(@"\{(\w+)(:[^}]+)?\}")]
private static partial Regex RouteConstraint();
There is a subtle point here: the inventory comes from the real application running in the test host, not from scanning source code. Endpoints added only in Development, such as MapOpenApi(), endpoints added by a NuGet package, or a MapGet forgotten inside an extension method all show up in the list. A source scan could miss all of them.
Rule 1: Every Endpoint Carries an Explicit Decision
The fallback policy imposes authentication on endpoints that have no authorization metadata at all. It's a good safety net but not a statement of intent: it doesn't show that whoever wrote the endpoint decided "this should be public", "this should be open to every signed-in user" or "admin only". The first rule is that every endpoint carries either an IAuthorizeData (a policy, a role or RequireAuthorization()) or an explicit IAllowAnonymous:
[Fact]
public void Every_endpoint_declares_an_explicit_authorization_decision()
{
// The fallback policy is a safety net; intent must be written explicitly on every endpoint
string[] implicitOnly = Inventory()
.Where(e => !e.Metadata.Any(m => m is IAuthorizeData or IAllowAnonymous))
.Select(e => e.Endpoint)
.Order()
.ToArray();
Assert.True(implicitOnly.Length == 0, $"Only the fallback policy protects: {string.Join(", ", implicitOnly)}");
}
When I first ran the test against the previous post's code:
failed EndpointInventoryTests.Every_endpoint_declares_an_explicit_authorization_decision (635ms)
Only the fallback policy protects: GET /orders, GET /orders/{orderId}, GET /orders/{orderId}/invoices/{invoiceId}, PATCH /orders/{orderId}, POST /orders/{orderId}/cancel
All five endpoints are in the /orders group and all of them worked correctly, because the fallback policy required authentication. But if someone deletes the SetFallbackPolicy line tomorrow because "it causes trouble with Swagger", these five endpoints silently become anonymous. The fix is one line, at group level:
// An explicit decision at group level: every endpoint here requires at least authentication
RouteGroupBuilder orders = app.MapGroup("/orders").RequireAuthorization();
Rule 2: Every Endpoint in the Matrix, Every Matrix Row in the Application
The second rule ties the matrix from the first post to the inventory. Every endpoint in the application must appear in the matrix with at least one row; every endpoint in the matrix must exist in the application. The second direction stops a deleted or renamed endpoint from leaving "green but meaningless" rows in the matrix.
[Fact]
public void Every_endpoint_is_in_the_authorization_matrix()
{
HashSet<string> inMatrix = AuthorizationMatrix.Load().Select(r => $"{r.Method} {r.Route}").ToHashSet();
string[] missing = Inventory().Select(e => e.Endpoint).Where(e => !inMatrix.Contains(e)).Order().ToArray();
Assert.True(missing.Length == 0, $"Not in the matrix: {string.Join(", ", missing)}");
}
[Fact]
public void Matrix_has_no_rows_for_endpoints_that_do_not_exist()
{
HashSet<string> inventory = Inventory().Select(e => e.Endpoint).ToHashSet();
string[] stale = AuthorizationMatrix.Load().Select(r => $"{r.Method} {r.Route}").Distinct()
.Where(e => !inventory.Contains(e)).Order().ToArray();
Assert.True(stale.Length == 0, $"Matrix rows without an endpoint: {string.Join(", ", stale)}");
}
The first run:
failed EndpointInventoryTests.Every_endpoint_is_in_the_authorization_matrix (0ms)
Not in the matrix: DELETE /admin/api-keys/{id}, GET /health, GET /openapi/{documentName}.json, POST /account/password-reset, POST /admin/api-keys, POST /integrations/orders/{orderId}/shipments
We had written tests for most of the endpoints in this list; API keys and signed requests were tested in detail in the third post. But the matrix should be the single place for the question "what does which actor get on which endpoint?", and these six endpoints weren't there. I added the missing rows: at least one each for anonymous, the wrong role and the right role. For GET /health and GET /openapi/{documentName}.json one row is enough: an anonymous request must get 200; that's a deliberate decision and now it's written in the matrix. Keeping the OpenAPI document closed in production is the subject of the API8 (Security Misconfiguration) post.
Adding a new endpoint now has a price: whoever writes it must add rows to the matrix in the same pull request, or the build breaks. That price is deliberate. The generator from the second post also derives other-tenant scenarios from these rows automatically.
Separating Admin APIs
BFLA shows up most often on admin endpoints, because the authorization decision hangs on a path prefix and that prefix is guessable. The first defence is to gather the whole admin surface in a single route group: the policy is written once and every endpoint added to the group inherits it. The second defence is moving the admin surface to a separate port. The lab now listens on two ports: 19516 for the public API, 19526 for admin only.
// The admin surface in a single group: the policy, host and port constraints apply to every endpoint below it
public static RouteGroupBuilder MapAdminGroup(this IEndpointRouteBuilder app, int adminPort) =>
app.MapGroup("/admin")
.RequireAuthorization(LabPolicies.TenantAdmin)
.RequireHost($"*:{adminPort}")
.AddEndpointFilter(new AdminPortFilter(adminPort));
The first version had only RequireHost("*:19526") and it seemed to work: /admin/users returned 404 from the public port. Then I tried this:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19516/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
200
RequireHost doesn't look at the connection the request came on, it looks at the Host header, and the client writes that header. A request to the public port matched the admin endpoint by saying Host: localhost:19526. So RequireHost is a routing tool, not a security boundary. For a real boundary I added a small filter that looks at which local port the request was accepted on:
// RequireHost looks at the Host header, which the client writes; check the port the request really arrived on as well
public sealed class AdminPortFilter(int adminPort) : IEndpointFilter
{
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
context.HttpContext.Connection.LocalPort == adminPort
? next(context)
: ValueTask.FromResult<object?>(Results.NotFound());
}
The same two requests, first to the public port with a fake Host header, then to the admin port:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19516/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19526/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19526/admin/users -H "Authorization: Bearer $ALAN"
403
Even on the admin port the authorization decision didn't change: Alan gets 403. Port separation doesn't replace authorization, it adds a layer to it. The real value comes at network level: you can bind the admin port to an interface open only to the internal network or a VPN (Virtual Private Network), and never add it to the public load balancer. Then a client on the internet can't even see that the admin endpoints exist.
There is a small difficulty on the test side: TestServer doesn't open a real connection and LocalPort doesn't show the admin port; as soon as I added the filter, every admin request in the test host started getting 404. I added an IStartupFilter to the factory that writes the port from the test client's base address into LocalPort; the admin rows in the matrix run with the base address http://localhost:19526. Since the fake Host header scenario can't be produced in TestServer, I guard the filter with a separate unit test:
[Fact]
public async Task Admin_port_filter_ignores_a_spoofed_host_header()
{
DefaultHttpContext context = new();
context.Connection.LocalPort = 19516;
context.Request.Host = new HostString("localhost", 19526);
bool reachedEndpoint = false;
object? result = await new AdminPortFilter(19526).InvokeAsync(
new DefaultEndpointFilterInvocationContext(context),
_ => { reachedEndpoint = true; return ValueTask.FromResult<object?>(Results.Ok()); });
Assert.False(reachedEndpoint);
Assert.IsType<NotFound>(result);
}
Per-Method Authorization and Method Override Headers
In REST (Representational State Transfer) APIs, using the same route with different methods is normal: GET /books/{bookId} reads the book, DELETE /books/{bookId} deletes it. Here the classic form of BFLA appears: someone thinks about permissions while writing GET, DELETE is added later and inherits the group's default rule.
// Same route, different method, different permission: GET for everyone, DELETE only for the tenant admin
books.MapDelete("/{bookId:guid}", async (Guid bookId, OrdersDbContext db, CancellationToken ct) =>
await db.Books.Where(b => b.Id == bookId).ExecuteDeleteAsync(ct) == 1
? Results.NoContent()
: Results.NotFound())
.RequireAuthorization(LabPolicies.TenantAdmin);
The inventory test helps here too, because we made the method part of the inventory key: GET /books/{bookId} and DELETE /books/{bookId} are separate rows in the matrix. For DELETE there are rows for anonymous, a customer, support, another tenant's admin and the right admin; the last one uses the retired-book record set aside for deletion, so the book other tests use stays in place.
There's one more method-related trap: method override headers. Some frameworks and gateways read a header like X-HTTP-Method-Override: DELETE for old clients that can only send GET and POST, and process the request as DELETE. ASP.NET Core does this only if you add the UseHttpMethodOverride() middleware; the lab doesn't. So why take precautions anyway? If there is a proxy in front of us that interprets this header, the proxy sees the request as DELETE and applies its own rules accordingly, while the application sees POST. The authorization decision is made on different methods in two places. The simplest fix is not to accept requests carrying these headers at all:
// The app doesn't use method override; if a proxy in front of it does, the authorization decision diverges in two places
public sealed class MethodOverrideGuard(RequestDelegate next, ILogger<MethodOverrideGuard> logger)
{
private static readonly string[] OverrideHeaders = ["X-HTTP-Method-Override", "X-HTTP-Method", "X-Method-Override"];
public Task InvokeAsync(HttpContext context)
{
string? header = OverrideHeaders.FirstOrDefault(context.Request.Headers.ContainsKey);
if (header is null)
{
return next(context);
}
logger.LogWarning("Rejected {Method} {Path} carrying {Header}", context.Request.Method,
context.Request.Path, header);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return Task.CompletedTask;
}
}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
403
$ curl -i -X POST http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "X-HTTP-Method-Override: DELETE"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Sun, 08 Nov 2026 06:02:29 GMT
Server: Kestrel
$ curl -i -X POST http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Date: Sun, 08 Nov 2026 06:02:29 GMT
Server: Kestrel
Allow: DELETE, GET
The Allow: DELETE, GET header in the last response is interesting: the 405 response says which methods exist on the route. That's ASP.NET Core's standard behaviour, and RFC (Request for Comments) 9110 requires the Allow header in a 405 response. Instead of trying to hide it, the right approach is to make sure each of those methods carries its own authorization decision; that's exactly what the inventory test does.
Production Notes
Separating the admin port only makes sense if the network side is separated too. In Kestrel, bind the admin endpoint to 127.0.0.1 or an internal network interface and the public endpoint to the external interface; add only the public port to the load balancer. In container environments, don't publish the admin port as a service. If you want a stronger separation, deploy the admin API as a separate application with separate authentication settings (for example, accepting only tokens from the internal identity provider).
The environment the inventory test runs in matters. The test runs in Development and MapOpenApi() enters the inventory. In production that endpoint won't exist; the difference between the two environments comes back in the API9 (Improper Inventory Management) post as part of the "inventory" question.
The AllowedHosts setting and the host filtering middleware are also a Host header defence, but with a different purpose: rejecting requests that arrive with host names you don't recognise. Like RequireHost, it looks at the header and doesn't distinguish ports. Only Connection.LocalPort really knows which port the connection was accepted on; behind a reverse proxy that value is the port the proxy connects to, so you must make the separation in the proxy as well.
As the matrix grows, keep rows grouped by endpoint for readability and write the endpoint's purpose on a comment line at the start of each group. Lines starting with # in the CSV (Comma-Separated Values) file are already ignored.
Common Pitfalls
The first pitfall is leaving the admin check to the client. Hiding the admin menu in Angular is a user experience decision. If the endpoint itself doesn't carry a policy, anyone who knows the URL can call it.
The second is checking permissions by path in a middleware. A check like if (path.StartsWith("/admin")) can behave differently for /Admin/users, //admin/users or URL-encoded variations. Tying authorization to endpoint metadata means deciding on exactly what routing matched.
The third is relying on the group's default policy when adding a new method to an existing route. The default of the /books group is "every signed-in user"; had we not written the admin policy explicitly when adding DELETE, customers could delete books. The inventory test doesn't catch that, because the endpoint carries a decision; the matrix row alan, DELETE, 403 does.
The fourth is treating RequireHost as a security boundary. As we saw, the Host header is under the client's control.
A Small Experiment
★☆☆ Add a GET /orders/{orderId}/items endpoint to the lab and run the tests. Which test broke, and what did its message tell you?
★★☆ Delete the SetFallbackPolicy line and run the tests again. Is the inventory test still green? Did any matrix row turn red? Think about which scenario the fallback policy still helps with.
★★★ Add a third rule to the inventory test: for every endpoint in the matrix there must be at least one rejected (401, 403 or 404) and at least one allowed (2xx) row. How would you mark exceptions such as GET /health?
In the next post we move to API6 (Unrestricted Access to Sensitive Business Flows): identifying which business flows are sensitive, processing repeated POSTs only once with the Idempotency-Key header, and flow-level limits instead of a single rate limit for the whole API.