How to Protect ASP.NET Core APIs Against Broken Function Level Authorization

Broken Function Level Authorization (BFLA) โ listed as API5 in the OWASP API Security Top 10 โ is one of the most exploited vulnerabilities in production APIs, and it is surprisingly easy to miss in ASP.NET Core when authorization is applied inconsistently across controllers and minimal API endpoints. Unlike Broken Object Level Authorization (BOLA), which controls who can access a specific resource, BFLA is about who can invoke a specific operation โ an admin-only endpoint left accessible to regular users, an HTTP DELETE method that a read-only role should never reach, or an internal management route that leaks into the public surface. If you want the full authorization implementation โ including role hierarchies, custom requirement handlers, and a working test suite โ the complete source code is available on Patreon, where production-ready implementations go deeper than any article can.
Understanding ASP.NET Core Authorization Strategies: RBAC vs. ABAC vs. Policy-Based gives you the building blocks, but BFLA prevention is about enforcing those strategies consistently at every function boundary โ not just the obvious ones. The gap that attackers exploit is not a missing [Authorize] attribute on a known endpoint; it is the one endpoint someone forgot, the admin controller that inherited incorrect defaults, or the HTTP method escalation that role checks never accounted for. If you want to see all of these authorization patterns โ policies, custom requirements, resource-based checks, and API key middleware โ wired into a complete production codebase, Chapter 8 of the Zero to Production course walks through exactly that, with everything connected and running.
What Is Broken Function Level Authorization?
BFLA occurs when an API exposes privileged operations โ administrative actions, bulk data access, state-changing operations โ without adequately verifying that the caller has the right to invoke that specific function, regardless of whether they are authenticated.
The distinction matters: a user can be fully authenticated, have a valid JWT, and still be exploiting BFLA if they can reach an endpoint they should never touch. Authentication proves identity. Authorization controls capability. BFLA is always an authorization failure, never an authentication one.
Common patterns in ASP.NET Core that introduce BFLA:
A controller marked
[Authorize]at the class level but missing[Authorize(Roles = "Admin")]on individual admin actionsHTTP verb escalation: a
GETendpoint is scoped, but the correspondingPUTorDELETEon the same route lacks its own authorization checkA
/api/internal/...or/api/admin/...route that relies on obscurity rather than policy enforcementMinimal API endpoints registered after
app.UseAuthorization()but without explicit.RequireAuthorization(...)callsAuthorization policies defined correctly but never applied โ left as aspirational configuration
Why It Is Consistently Underestimated
Most development teams apply authorization carefully to the first implementation of a controller or endpoint. The vulnerability typically appears during iteration โ when a new HTTP method is added to an existing route, when a controller is refactored, or when an internal utility endpoint is promoted to a shared API surface without a full security review.
The other reason BFLA persists is that it is invisible in routine testing. Functional tests validate that authorized users can do what they should. They rarely test that unauthorized roles cannot reach privileged functions. This asymmetry in test coverage is exactly what attackers rely on.
The Threat Model: What an Attacker Can Do
Consider a SaaS platform built on ASP.NET Core. The API has endpoints for regular users and admin-only operations:
GET /api/users/{id} โ accessible to the user themselves
GET /api/admin/users โ should be admin-only
DELETE /api/users/{id} โ should be admin-only
POST /api/admin/impersonate โ should be admin-only (or disabled entirely)
If the admin endpoints return HTTP 200 to a regular authenticated user, the attacker can enumerate all users, delete accounts, or impersonate others. The authorization check was either omitted, applied at the wrong level, or applied to the wrong HTTP method. Each of those is a BFLA instance.
Defence Layer 1: Apply a Fallback Authorization Policy
The single most effective preventative measure in ASP.NET Core is requiring authorization by default, then explicitly opting endpoints out rather than opting them in.
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
With a fallback policy in place, any endpoint that does not declare its own authorization policy will require authentication at minimum. This eliminates the "forgot to add [Authorize]" class of BFLA entirely. Endpoints that genuinely need to be public โ health checks, Swagger UI in development, public content โ are then explicitly marked with [AllowAnonymous] or .AllowAnonymous().
This is a deny-by-default posture: nothing passes unless it is explicitly permitted. It is the correct default for any API that handles sensitive data or privileged operations.
Defence Layer 2: Role-Based Checks at the Action Level, Not Just the Controller
Applying [Authorize] to a controller class does not protect individual actions from role escalation. Each privileged action needs its own role or policy declaration.
[ApiController]
[Route("api/admin/users")]
[Authorize] // Any authenticated user can reach the controller
public class AdminUserController : ControllerBase
{
[HttpGet]
[Authorize(Roles = "Admin")] // Only admins can list all users
public IActionResult GetAllUsers() { ... }
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")] // Only admins can delete users
public IActionResult DeleteUser(Guid id) { ... }
}
The pattern to internalize: [Authorize] on the class proves the caller is authenticated. [Authorize(Roles = "Admin")] on the action proves the caller is both authenticated and authorized for that specific function. Both are needed when different roles share the same controller.
For Minimal APIs, the equivalent is:
app.MapDelete("/api/users/{id}", DeleteUserHandler)
.RequireAuthorization("AdminOnly");
Never rely on the controller-level [Authorize] alone when individual actions have different privilege requirements.
Defence Layer 3: Policy-Based Authorization for Complex Rules
Role strings are convenient but brittle at scale. When authorization logic involves multiple conditions โ active subscription, department membership, feature flag status โ named policies with IAuthorizationRequirement provide a maintainable, testable boundary.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("SuperAdminOnly", policy =>
policy.RequireRole("Admin")
.RequireClaim("permission", "super_admin"));
});
Policies centralize the authorization logic: the claim name, the role hierarchy, the required conditions. Controllers and endpoints reference policy names โ they do not duplicate logic. When the rule changes, it changes in one place.
For operations that require inspecting the resource being acted on and the function being invoked, custom IAuthorizationRequirement handlers let you combine both checks without scattering logic across the action method.
Defence Layer 4: HTTP Method-Level Authorization
BFLA frequently manifests as HTTP verb escalation. The route is correct, the resource check passes โ but the method (POST, PUT, DELETE, PATCH) carries higher privilege than the caller should have.
ASP.NET Core's [Authorize] attribute applies to the action method, which is already mapped to a specific HTTP verb. The risk is not in the framework โ it is in accidentally omitting the attribute on the higher-privilege methods while leaving it on the read methods.
A practical enforcement pattern: treat every state-changing HTTP method (POST, PUT, PATCH, DELETE) on any controller as implicitly requiring a more restrictive policy than the corresponding GET. Build this into your team's code review checklist and architecture decision record. The ASP.NET Core API Code Review Checklist covers this as a standing verification step.
Defence Layer 5: Audit and Test Unauthorized Access Explicitly
BFLA is invisible without tests that deliberately attempt unauthorized function calls. Functional tests prove correct behavior for authorized callers. BFLA tests prove that unauthorized callers are rejected.
With WebApplicationFactory, you can write integration tests that authenticate as a lower-privilege role and verify that elevated endpoints return HTTP 403:
[Fact]
public async Task AdminEndpoint_ReturnsForbidenForRegularUser()
{
var client = _factory.CreateClientWithRole("User");
var response = await client.DeleteAsync("/api/admin/users/some-id");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
Every privileged endpoint should have at least one test from each lower-privilege role asserting HTTP 403. These tests are fast, deterministic, and catch the regression immediately when someone accidentally removes an authorization attribute during refactoring.
Defence Layer 6: Minimise the Attack Surface
Not every internal function needs to be an HTTP endpoint. Administrative operations that are only invoked by internal tooling, background jobs, or deployment pipelines do not belong on the public API surface โ even behind authentication.
Design principles that reduce BFLA exposure:
Keep admin controllers in a separate area or project with a distinct route prefix (
/api/admin/...) and apply a gateway-level or middleware-level policy to the entire prefixUse
[ApiExplorerSettings(IgnoreApi = true)]to exclude internal endpoints from Swagger โ not as a security control (they are still accessible), but to avoid advertising their existenceFor operations that should never be HTTP-reachable, implement them as background jobs, CLI commands, or internal service methods โ not as controller actions
Obscurity alone is not defence. But minimizing the HTTP surface reduces the number of endpoints that need BFLA coverage.
Defence-in-Depth Checklist
Run through this checklist for every ASP.NET Core API before shipping to production:
โ Fallback authorization policy set to
RequireAuthenticatedUserโ deny by defaultโ Every public endpoint that genuinely needs no auth is explicitly
[AllowAnonymous]โ Every admin or elevated action has its own role or policy attribute โ not just the controller class
โ Every state-changing HTTP method (POST, PUT, PATCH, DELETE) has an explicit authorization check independent of the GET on the same route
โ Authorization policies are defined centrally and referenced by name โ not duplicated inline
โ Integration tests exist for each privileged endpoint, asserting HTTP 403 for each lower-privilege role
โ Admin routes are separated from user routes โ distinct controllers, route prefixes, or API areas
โ No endpoint relies on route obscurity as its only protection
โ Prefer a one-time tip? Buy us a coffee โ every bit helps keep the content coming!
FAQ
What is the difference between BOLA and BFLA in ASP.NET Core? BOLA (Broken Object Level Authorization, OWASP API1) controls whether a caller can access a specific record โ for example, whether user A can read user B's data. BFLA (API5) controls whether a caller can invoke a specific function โ for example, whether a regular user can reach an admin-only delete endpoint. BOLA is about data ownership; BFLA is about operational privilege. Both can coexist in the same API and require separate mitigation strategies.
Does adding [Authorize] to a controller class prevent BFLA? Partially. It prevents unauthenticated access. It does not prevent BFLA if different actions within the controller require different roles or policies. A regular authenticated user who bypasses the class-level [Authorize] check still has no protection against reaching admin-only actions that lack their own [Authorize(Roles = "Admin")] declaration. Class-level [Authorize] is a necessary condition, not a sufficient one.
What is the fallback authorization policy and why does it matter for BFLA? The fallback policy in AddAuthorization applies to any endpoint that has no explicit authorization declaration. Setting it to RequireAuthenticatedUser means no endpoint is accidentally left open. This is the foundational BFLA control โ any endpoint added in the future that omits its own authorization attribute will still require authentication at minimum, rather than being silently accessible to everyone.
How does BFLA differ from missing authentication? A missing authentication check allows unauthenticated callers (no token, no session). BFLA allows authenticated callers to reach functions their role should not permit. The fix for missing authentication is adding [Authorize]. The fix for BFLA is adding the correct role or policy constraint. Both can exist on the same endpoint โ a missing [Authorize] that also lacks a role check fails on both dimensions simultaneously.
Should HTTP DELETE always require a higher privilege than HTTP GET on the same route? In most API designs, yes. GET operations are read-only and can often be scoped to the resource owner. DELETE is destructive and typically requires elevated privilege โ admin role, specific permission claim, or both. ASP.NET Core action-level authorization lets you express exactly this: a read policy on the GET action and a write or admin policy on the DELETE action at the same route. Treating read and write verbs as having equal authorization requirements is one of the most common sources of BFLA.
Can Minimal APIs in ASP.NET Core be affected by BFLA? Yes. Minimal APIs require explicit .RequireAuthorization(...) calls on each route. Unlike controller actions, there is no class-level [Authorize] to inherit. Every route handler in a Minimal API surface must be individually secured. The fallback policy mitigates this โ unauthenticated access is blocked โ but role or policy constraints still need to be applied per-endpoint.
How should admin endpoints be structured to reduce BFLA risk? Group admin endpoints into a dedicated controller or route group with a distinct prefix (e.g., /api/admin/...), apply a named policy at the group level, and then add per-action policies for any actions within the group that carry even higher privilege. This layered approach ensures that even if an individual action loses its attribute during refactoring, the group-level policy provides a safety net.






