Skip to main content

Command Palette

Search for a command to run...

Preventing Broken Object Level Authorisation in ASP.NET Core APIs

Updated
10 min readView as Markdown
Preventing Broken Object Level Authorisation in ASP.NET Core APIs
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

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:

  1. Is the caller authenticated?

  2. Does the caller have permission to perform this operation?

  3. 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.