Preventing Broken Object Level Authorisation in ASP.NET Core APIs

An authenticated user calls your API with a valid access token. The token contains the right role and the endpoint requires authorisation. Everything appears secure. The user then changes /claims/8d61... to /claims/7a42... and receives somebody else's insurance claim.
This is broken object-level authorisation, usually shortened to BOLA. It happens when an API confirms that a caller may use an endpoint but doesn't confirm that they may access the specific record requested. OWASP currently places it at API1 in its API Security Top 10. ASP.NET Core gives us solid authentication and policy-based authorisation features, but they still need to be applied at the correct level. In this article, we'll build a safer claims endpoint using Minimal APIs, EF Core and ClaimsFence, a small claims based authorisation package for .NET. The insurance claims domain makes the naming slightly entertaining, but ClaimsFence is concerned with identity claims such as permissions and tenant IDs. It doesn't inspect or secure insurance claim records by itself.
How BOLA appears in a .NET API
Consider an API used by several insurance companies. Each company is a tenant, and every insurance claim belongs to one of those tenants. The endpoint below requires an authenticated user, loads a claim using the ID from the route and returns it:
app.MapGet("/claims/{claimId:guid}", async (
Guid claimId,
ClaimsDbContext db,
CancellationToken stopToken) =>
{
var claim = await db.Claims
.AsNoTracking()
.SingleOrDefaultAsync(x => x.Id == claimId, stopToken);
return claim is null
? Results.NotFound()
: Results.Ok(claim);
})
.RequireAuthorization();
RequireAuthorization() prevents anonymous access. It doesn't prove that the authenticated user belongs to the claim's tenant, owns the record or has been assigned to handle it. If an attacker obtains another valid identifier from a log, browser history, email or API response, they can request that object directly. Sequential integer IDs make discovery easier, but changing them to GUIDs doesn't fix the authorisation failure. OWASP explicitly treats integer, UUID and string identifiers as potential attack targets.
Endpoint access and object access are separate decisions
A useful authorisation design answers three questions:
Is the caller authenticated?
Does the caller have permission to perform this operation?
May the caller perform it on this particular record?
The first two decisions can often be made from trusted claims in the access token. The third usually depends on application data. For our claims API, a caller needs the Claims.Read permission, must be operating inside their own tenant and must either be assigned to the requested insurance claim or hold a broader Claims.Read.All permission.
That gives us two enforcement points:
ClaimsFence handles the identity, permission and tenant route rules. EF Core scopes the database query to the records that identity is allowed to read.
Using ClaimsFence at the endpoint boundary
Install ClaimsFence from NuGet into the API project:
dotnet add package ClaimsFence
We can now express the endpoint level rules together instead of scattering HasClaim calls through handlers:
app.MapGet("/tenants/{tenantId:guid}/claims/{claimId:guid}", GetClaimAsync)
.RequireClaimFence(rule => rule
.RequiresAuthenticatedUser()
.RequiresClaim("permission", "Claims.Read")
.RequiresClaim("tenant", ClaimMatch.RouteValue("tenantId")));
This rule says that the caller must be authenticated, must have permission to read claims, and must have a tenant claim matching the tenantId route value. The route value came from the caller, so accepting it without comparison would be unsafe. Matching it against a tenant claim from a correctly validated token prevents a user from simply replacing one tenant ID with another. ClaimsFence is a natural fit here because this is still claims-based authorisation. The rule is concise, reusable and independently testable. It also keeps the endpoint metadata readable when more than one claim must be checked.
It isn't the full BOLA solution, though. Passing this rule only proves that the user can read claims within the requested tenant. It doesn't prove that they may read every insurance claim belonging to that tenant.
Scope the database query to authorised records
The handler should treat authorisation criteria as part of the query rather than loading an arbitrary row and hoping somebody remembers to check it afterwards.
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
static async Task<IResult> GetClaimAsync(
Guid tenantId,
Guid claimId,
ClaimsPrincipal user,
ClaimsDbContext db,
CancellationToken stopToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
{
return Results.Unauthorized();
}
var query = db.Claims
.AsNoTracking()
.Where(x => x.Id == claimId && x.TenantId == tenantId);
var canReadAll = user.HasClaim(
"permission",
"Claims.Read.All");
if (!canReadAll)
{
query = query.Where(x => x.AssignedUserId == userId);
}
var claim = await query
.Select(x => new ClaimResponse(
x.Id,
x.ClaimNumber,
x.PolicyholderName,
x.Status))
.SingleOrDefaultAsync(stopToken);
return claim is null
? Results.NotFound()
: Results.Ok(claim);
}
public sealed record ClaimResponse(
Guid Id,
string ClaimNumber,
string PolicyholderName,
string Status);
The important part is the SQL predicate produced by the query. A normal handler can only retrieve a row when all applicable access conditions match:
WHERE Id = @claimId
AND TenantId = @tenantId
AND AssignedUserId = @userId
A user asking for somebody else's claim receives no row. The application returns 404 Not Found, which also avoids confirming that the identifier exists. Whether your API uses 403 or 404 consistently is a product decision, but returning 404 for inaccessible object IDs reduces information available for enumeration. Projecting directly to ClaimResponse also avoids returning internal or sensitive columns accidentally. Object level authorisation and property level authorisation are different concerns, and an endpoint can get the first right while still exposing too much data.
Why checking after loading is weaker
You could load the record by ID and then compare its tenant and assignment fields:
var claim = await db.Claims.FindAsync([claimId], stopToken);
if (claim?.TenantId != tenantId || claim.AssignedUserId != userId)
{
return Results.NotFound();
}
This can be made correct, but the unrestricted entity has already crossed into application memory. It also creates a fragile pattern, every handler must remember every comparison, and a later refactor can easily return or process the record before the check. Putting stable ownership constraints into the query makes the permitted data set explicit and prevents the unauthorised row from being loaded in the first place.
Add tenant isolation with an EF Core query filter
For a multi tenant application, an EF Core global query filter provides another useful layer. The current tenant is supplied to the DbContext, and EF Core adds the tenant predicate whenever it queries an insurance claim:
public sealed class ClaimsDbContext(
DbContextOptions<ClaimsDbContext> options,
ITenantContext tenantContext)
: DbContext(options)
{
public DbSet<InsuranceClaim> Claims => Set<InsuranceClaim>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<InsuranceClaim>()
.HasQueryFilter(
"TenantFilter",
claim => claim.TenantId == tenantContext.TenantId);
}
}
Named query filters are available in EF Core 10. The filter reduces the chance of a developer accidentally issuing an unscoped query elsewhere in the application. It should still be treated as defence in depth. Code can deliberately disable filters, raw SQL can bypass them and background processes may run outside a normal request tenant. The application still needs a clear, trusted way to resolve the tenant, and privileged code that crosses tenant boundaries should be isolated and closely tested. Don't construct ITenantContext from the route value alone. The route identifies the requested tenant; the authenticated identity establishes which tenant the caller belongs to. ClaimsFence's route comparison is what joins those two pieces at the API boundary.
When resource based authorisation is the better fit
Some access rules can't be represented entirely by token claims or query predicates. A supervisor might be allowed to approve claims below a certain value, or access might depend on a team membership stored in the database. ASP.NET Core supports resource based authorisation through IAuthorizationService. The application loads an already tenant-scoped resource, then asks an authorisation handler whether the requested operation is permitted:
var result = await authorizationService.AuthorizeAsync(
user,
claim,
ClaimOperations.Approve);
if (!result.Succeeded)
{
return Results.Forbid();
}
Microsoft recommends this imperative approach when the decision depends on the resource because endpoint attributes and declarative policies run before the resource has been loaded. ClaimsFence and resource based authorisation therefore complement each other:
| Concern | Enforcement point |
|---|---|
| Authentication | ASP.NET Core authentication |
| Permission claim | ClaimsFence rule |
| Tenant claim matches route | ClaimsFence rule |
| Row belongs to permitted tenant or user | Scoped EF Core query |
| Decision depends on loaded record state | IAuthorizationService handler |
| Response contains only permitted fields | DTO projection |
Trying to make one mechanism solve every layer usually produces either an oversized token or business rules hidden inside endpoint plumbing.
Test for identifier substitution
Happy path tests aren't enough for object level authorisation. The key test is whether one valid user can substitute another valid object's ID.
[Fact]
public async Task GetClaim_ReturnsNotFound_WhenClaimBelongsToAnotherHandler()
{
var client = factory.CreateClientFor(
userId: "handler-17",
tenantId: TenantIds.Fabrikam,
permissions: ["Claims.Read"]);
var response = await client.GetAsync(
$"/tenants/{TenantIds.Fabrikam}/claims/{ClaimIds.AssignedToAnotherUser}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
Your integration tests should exercise the complete authorisation boundary. Verify anonymous access is rejected, users without Claims.Read are denied, and the tenant in the token must match the route. Within a valid tenant, confirm that users can’t access claims assigned to another handler or claims belonging to another tenant. Finally, prove that Claims.Read.All permits access to unassigned claims and that assigned users can retrieve their own claims. The same tests should be applied to reads, updates, downloads and deletes. It's common to protect a GET endpoint correctly while leaving an attachment download or status update exposed.
ClaimsFence rules can also be tested without running the entire API:
var rule = ClaimFence.Rule()
.RequiresAuthenticatedUser()
.RequiresClaim("permission", "Claims.Read");
var result = rule.Evaluate(user);
result.Succeeded.Should().BeFalse();
result.Failures.Should().NotBeEmpty();
That makes the identity level expectations easy to verify, while API integration tests prove that route matching and data scoping work together.
Its easy to get things wrong
GUIDs make identifiers harder to guess, which is useful, but IDs regularly leak through logs, URLs, exports and related API responses. Every ID must still be authorised. Two users can hold the same role while belonging to different tenants or owning different resources. A role establishes a broad capability, not access to an individual row. A tenant ID in a route, header or JSON body is request data. It must be matched against a trusted identity or server-side membership record before being used as an authorisation boundary. Another gotcha sees that the same object may be reachable through search, export, attachment, batch and administrative endpoints. Object level checks must apply to every path that accesses it. Even an authorised caller shouldn't automatically receive every property. Project the fields intended for that operation into a response contract.
The secure endpoint has several small controls working together. ASP.NET Core validates the token. ClaimsFence states that the user needs Claims.Read and that their tenant must match the route. EF Core restricts the query to records the user may access. Resource-based handlers cover decisions that depend on the loaded claim, and the response projection limits exposed fields. ClaimsFence belongs naturally in this design because it handles the claims-based portion cleanly without claiming to know who owns a database row. Keeping that boundary honest makes the package more credible and gives the article a stronger conclusion, claims can get a caller through the front door, but object level authorisation decides which records they may touch once inside.




