Mert Özen Further With Every Line
Security API Security (OWASP API Top 10) — #02

Closing API1 BOLA Systematically: Tenant/Owner-Scoped Queries, Global Query Filters and Resource-Based Authorization

Mert Özen Nov 8, 2026 14 min 5 views
Closing API1 BOLA Systematically: Tenant/Owner-Scoped Queries, Global Query Filters and Resource-Based Authorization

You close BOLA not by adding a Where to every endpoint but by making scope mandatory in the data access layer: tenant and owner filters with EF Core 10 named query filters, tests that confine unscoped queries to one marked class, relationship checks for sub-resources and bulk operations, the 403/404 split with a resource-based handler, and other-tenant scenarios generated automatically from the matrix.

Version note: The code in this post was built and run with .NET 10 and EF (Entity Framework) Core 10.0.14; named query filters arrived in EF Core 10.

Guess First

In the previous post the authorization matrix gave us eight red rows. The easiest fix is obvious: add && o.TenantId == user.TenantId to the details endpoint and the same to the invoice endpoint. Ten minutes, and the tests turn green. Now think about this: six months later someone new on the team writes a GET /orders/{orderId}/items endpoint. Will they add that line? What about a reporting query, a bulk update or a background job?

BOLA (Broken Object Level Authorization) has been number one on the OWASP (Open Worldwide Application Security Project) API (Application Programming Interface) Security Top 10 for years; in the 2023 edition its code is API1 (the first item on the list). Not because it is technically hard, but because the check has to be remembered again and again, in every endpoint and every query. In this post we remove the need to remember: we put scope into the data access layer as a requirement, confine unscoped queries to one marked place, and generate other-tenant scenarios automatically from the matrix. The foundations of IDOR (Insecure Direct Object Reference) and the 403/404 decision are in the A01 Broken Access Control post of the Web Security (OWASP Top 10) series; here we focus on the API and multi-tenant side.

Why Per-Endpoint Checks Don't Scale

The list endpoint in the previous post was green because the developer wrote the filter by hand. The details endpoint was red because the same developer forgot the same filter there on the same day. That is not an attention problem, it's a design problem. If a check is correct only when it is written correctly in N endpoints × M queries, the security of the system equals the quality of its least attentive moment.

The fix is to move the check from where the query is written to where the query runs. In EF Core the tool for that is the global query filter: a Where attached to an entity type and added automatically to every LINQ (Language Integrated Query) query that touches that type. We covered the mechanics of global query filters for soft delete and multi-tenancy in the global query filter post of the EF Core series. What EF Core 10 adds is the ability to attach several named filters to one entity and switch them off individually. For us that means tenant scope and customer (owner) scope can live as two separate rules.

Deriving the Data Scope from Claims

First we define the request's scope as a single value: which tenant, and which customer within it. Support and admin work tenant-wide, so their customer is null:

// The request's data scope: which tenant, and which customer within it (null = tenant-wide)
public sealed record DataScope(string? TenantId, Guid? CustomerId)
{
    public static DataScope From(ClaimsPrincipal? user)
    {
        string? tenantId = user?.FindFirstValue(LabClaims.Tenant);
        string? role = user?.FindFirstValue(LabClaims.Role);
        string? subject = user?.FindFirstValue(LabClaims.Subject);
        Guid? customerId = role == "customer" && Guid.TryParse(subject, out Guid id) ? id : null;
        return new DataScope(tenantId, customerId);
    }
}
builder.Services.AddScoped(sp => DataScope.From(sp.GetRequiredService<IHttpContextAccessor>().HttpContext?.User));

For an unauthenticated request, or while the seed runs at startup, TenantId is null. I left that on purpose: the filter below returns no rows for TenantId == null. If the scope is unknown, there is no data; fail closed.

Tenant and Owner Scope with Named Query Filters

OrdersDbContext now takes DataScope through its constructor and sets up two named filters:

public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options, DataScope scope) : DbContext(options)
{
    public const string TenantFilter = "Tenant";
    public const string OwnerFilter = "Owner";

    // EF Core reads these fields from this context instance as parameters on every query
    private readonly string? _tenantId = scope.TenantId;
    private readonly Guid? _customerId = scope.CustomerId;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // A tenant filter on every entity implementing ITenantOwned: adding a new entity can't forget the filter
        MethodInfo apply = typeof(OrdersDbContext).GetMethod(nameof(ApplyTenantFilter),
            BindingFlags.NonPublic | BindingFlags.Instance)!;
        foreach (Type clrType in modelBuilder.Model.GetEntityTypes().Select(t => t.ClrType)
                     .Where(t => typeof(ITenantOwned).IsAssignableFrom(t)).ToList())
        {
            apply.MakeGenericMethod(clrType).Invoke(this, [modelBuilder]);
        }

        modelBuilder.Entity<Order>()
            .HasQueryFilter(OwnerFilter, o => _customerId == null || o.CustomerId == _customerId);
        modelBuilder.Entity<Invoice>()
            .HasQueryFilter(OwnerFilter, i => _customerId == null || i.CustomerId == _customerId);
    }

    private void ApplyTenantFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class, ITenantOwned =>
        modelBuilder.Entity<TEntity>().HasQueryFilter(TenantFilter, e => e.TenantId == _tenantId);
}

Two details matter. First, the filter looks at the _tenantId field, not a constant. The model is built once and cached; when EF Core sees a field reference it reads the value from the current context instance as a parameter on every query. Had we embedded a value such as scope.TenantId directly in the filter, the first request's tenant would stick to the model. Second, the tenant filter isn't added by hand one by one but in a loop for every entity that implements ITenantOwned. If a Shipment entity is added tomorrow, it gets the filter the moment it implements the interface.

I added a CustomerId column to Invoice. We could have written the owner filter through the Order navigation, but the EF Core documentation warns that accessing filtered entities through a required navigation can produce unexpected results because of INNER JOIN. In multi-tenant tables, keeping the tenant and owner columns on every row both simplifies the filter and makes it indexable.

Endpoints are now written without filters. The list endpoint is almost bare:

// No filter here: the DbContext adds tenant and customer scope
orders.MapGet("/", async (OrdersDbContext db, CancellationToken ct) =>
    await db.Orders
        .OrderByDescending(o => o.CreatedAt)
        .Select(o => new OrderSummary(o.Id, o.Status.ToString(), o.Total, o.CreatedAt))
        .ToListAsync(ct));

Let's look at the SQL (Structured Query Language) EF Core generates for Alan's details request. In the endpoint we only wrote o.Id == orderId:

Executed DbCommand (0ms) [Parameters=[@ef_filter___tenantId='?' (Size = 5), @ef_filter__p3='?' (DbType = Boolean), @ef_filter___customerId='?' (DbType = Guid), @orderId='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
SELECT "o"."Id", "o"."CustomerId", CAST("o"."Status" AS TEXT), "o"."Total", "o"."InternalCost", "o"."ShippingAddress", "o"."Note", "o"."CreatedAt"
FROM "Orders" AS "o"
WHERE "o"."TenantId" = @ef_filter___tenantId AND (@ef_filter__p3 OR "o"."CustomerId" = @ef_filter___customerId) AND "o"."Id" = @orderId
LIMIT 1

The _customerId == null part became a boolean parameter called @ef_filter__p3; EF Core evaluates that comparison on the client instead of sending it to the database. False for Alan, true for Margaret. The same request with curl:

$ curl -i http://localhost:19512/orders/0197f001-0000-7000-8000-000000000003 -H "Authorization: Bearer $ALAN"
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Sun, 08 Nov 2026 05:33:20 GMT
Server: Kestrel

Unscoped Queries: Only in an Explicitly Marked Place

Sometimes you really do need to switch the filter off: a platform-level report, a cross-tenant migration, or the detection query we'll see below. The problem is IgnoreQueryFilters() calls spreading across the code base. My rule: this call lives in a single class, and every method there says by its name why it leaves the scope.

// IgnoreQueryFilters lives only in this file: every query that leaves the scope sits here with a name and a reason
public sealed class ScopeProbe(OrdersDbContext db, DataScope scope, ILogger<ScopeProbe> logger)
{
    // For detection: does the requested record exist but outside the caller's scope?
    public async Task ReportIfOutOfScopeAsync<TEntity>(Guid id, CancellationToken ct)
        where TEntity : class, ITenantOwned
    {
        bool existsElsewhere = await db.Set<TEntity>()
            .IgnoreQueryFilters()
            .AnyAsync(e => EF.Property<Guid>(e, "Id") == id, ct);
        if (existsElsewhere)
        {
            logger.LogWarning("Out-of-scope {Resource} {ResourceId} requested by tenant {TenantId}, customer {CustomerId}",
                typeof(TEntity).Name, id, scope.TenantId, scope.CustomerId);
        }
    }
}

The details endpoint calls this method when the result is empty. The response is 404 either way; only the log reflects the difference. Alan's request from a moment ago left this line:

warn: Bookstore.Orders.Api.Data.ScopeProbe[0]
      Out-of-scope Order 0197f001-0000-7000-8000-000000000003 requested by tenant north, customer 0197f000-0000-7000-8000-000000000003

Trying an id that doesn't exist is usually a typo. Trying an id that really exists in another tenant is either an integration bug or a sign that someone is collecting ids. That distinction is very useful when setting up alerts; we'll connect the telemetry side in the tenth post of the series. The cost of the extra query is paid only on the 404 path, and because it runs on every 404, response time doesn't vary with whether the record exists.

To make the rule stick we write two tests. One looks at the model, the other at the source code:

[Fact]
public void Every_tenant_owned_entity_has_a_tenant_filter()
{
    using IServiceScope scope = factory.Services.CreateScope();
    OrdersDbContext db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();

    string[] missing = db.Model.GetEntityTypes()
        .Where(t => typeof(ITenantOwned).IsAssignableFrom(t.ClrType))
        .Where(t => t.FindDeclaredQueryFilter(OrdersDbContext.TenantFilter) is null)
        .Select(t => t.ClrType.Name)
        .ToArray();

    Assert.Empty(missing);
}

[Fact]
public void IgnoreQueryFilters_is_used_only_in_ScopeProbe()
{
    string source = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
        "../../../../../src/Bookstore.Orders.Api"));

    string[] offenders = Directory.EnumerateFiles(source, "*.cs", SearchOption.AllDirectories)
        .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
        .Where(f => File.ReadAllText(f).Contains("IgnoreQueryFilters"))
        .Select(Path.GetFileName)
        .Where(name => name != "ScopeProbe.cs")
        .ToArray()!;

    Assert.Empty(offenders);
}

The second test may look crude; a Roslyn analyzer would be more elegant. But it does the job: if someone switches the filter off in another file, the build breaks and they have to explain that line in the pull request.

Sub-Resources, Ids in the Body and Bulk Operations

The filter doesn't solve everything on its own. Look at the invoice endpoint: Margaret is on North's support team, so both Alan's order and Grace's invoice are in her scope. For /orders/{alanOrder}/invoices/{graceInvoice} the filter lets both records through. What's missing is not scope but the relationship: does this invoice belong to this order? That's why the sub-resource query includes the parent id in its condition:

// Sub-resource: scope filter plus the parent relationship together
orders.MapGet("/{orderId:guid}/invoices/{invoiceId:guid}",
    async (Guid orderId, Guid invoiceId, OrdersDbContext db, CancellationToken ct) =>
        await db.Invoices
            .Where(i => i.Id == invoiceId && i.OrderId == orderId)
            .Select(i => new InvoiceDetails(i.Id, i.OrderId, i.Number, i.Amount))
            .FirstOrDefaultAsync(ct) is { } invoice
            ? Results.Ok(invoice)
            : Results.NotFound());

I added the row margaret, alan-order+grace-invoice, 404 to the matrix; if someone deletes the relationship condition, this row breaks.

For ids in the body we didn't need anything extra: POST /orders looks the book up through db.Books, and Book is ITenantOwned too. When Linus sends North's book, the filter hides it and the endpoint returns 400 with "Unknown book". The red row from the previous post turned green without touching the endpoint code.

Bulk operations are sneakier. ExecuteUpdateAsync bypasses the change tracker and produces an SQL UPDATE directly; I wanted to see that the filters apply there too. The SQL generated for Margaret's bulk cancel:

UPDATE "Orders" AS "o"
SET "Status" = @p
WHERE "o"."TenantId" = @ef_filter___tenantId AND @ef_filter__p3 AND "o"."Id" = @ids1 AND "o"."Status" = 'Pending'

The filter is there. But the bulk endpoint has one more design decision: what to do if the list contains an out-of-scope id? Silently skipping it and saying "1 order cancelled" looks harmless at first. Yet a client that sends five ids and gets four results may not notice the error. We chose "all or nothing":

orders.MapPost("/cancel", async (BulkCancelRequest request, OrdersDbContext db, CancellationToken ct) =>
{
    Guid[] ids = request.OrderIds.Distinct().ToArray();
    // All in scope or nothing: silently skipping out-of-scope ids hides the error
    int visible = await db.Orders.CountAsync(o => ids.Contains(o.Id), ct);
    if (visible != ids.Length)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["orderIds"] = ["One or more orders are unknown."]
        });
    }

    int cancelled = await db.Orders
        .Where(o => ids.Contains(o.Id) && o.Status == OrderStatus.Pending)
        .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Cancelled), ct);
    return Results.Ok(new BulkCancelResult(ids.Length, cancelled));
}).RequireAuthorization(LabPolicies.Staff);
$ curl -s -X POST http://localhost:19512/orders/cancel -H "Authorization: Bearer $MARGARET" -H "Content-Type: application/json" -d '{"orderIds":["0197f001-0000-7000-8000-000000000002","0197f001-0000-7000-8000-000000000003"]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"orderIds":["One or more orders are unknown."]},"traceId":"00-d70a403f88ed9d8987f951ac50970532-07db92946df7ffdf-00"}

$ curl -s -X POST http://localhost:19512/orders/cancel -H "Authorization: Bearer $MARGARET" -H "Content-Type: application/json" -d '{"orderIds":["0197f001-0000-7000-8000-000000000002"]}'
{"requested":1,"cancelled":1}

The error message doesn't say which id is unknown. Even if it did, little would leak, since the client sent the ids itself; but keeping the message plain also makes it impossible to tell an out-of-scope id from one that never existed.

Where Scope Isn't Enough: Resource-Based Authorization

Scope answers "may they see this record?". "May they perform this operation on this record?" is a separate question. For cancelling orders the rule is: a customer may cancel only their own Pending order, support may cancel a Pending or Paid order, an admin may cancel any order that isn't already cancelled. This fits ASP.NET Core's resource-based authorization model exactly:

public sealed class OrderAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, Order>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        OperationAuthorizationRequirement requirement, Order order)
    {
        // The query already applied the scope; checking the tenant again here is a cheap second lock
        if (order.TenantId != context.User.FindFirstValue(LabClaims.Tenant))
        {
            return Task.CompletedTask;
        }

        bool isOwner = order.CustomerId.ToString() == context.User.FindFirstValue(LabClaims.Subject);
        bool allowed = requirement.Name switch
        {
            nameof(OrderOperations.Cancel) => context.User.FindFirstValue(LabClaims.Role) switch
            {
                "admin" => order.Status != OrderStatus.Cancelled,
                "support" => order.Status is OrderStatus.Pending or OrderStatus.Paid,
                "customer" => isOwner && order.Status == OrderStatus.Pending,
                _ => false
            },
            _ => false
        };

        if (allowed)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}
orders.MapPost("/{orderId:guid}/cancel", async (Guid orderId, ClaimsPrincipal user, OrdersDbContext db,
    IAuthorizationService authorization, CancellationToken ct) =>
{
    Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
    if (order is null)
    {
        return Results.NotFound();
    }

    // The record is visible but the operation is forbidden: a 403 leaks nothing here
    AuthorizationResult result = await authorization.AuthorizeAsync(user, order, OrderOperations.Cancel);
    if (!result.Succeeded)
    {
        return Results.Forbid();
    }

    order.Status = OrderStatus.Cancelled;
    await db.SaveChangesAsync(ct);
    return Results.NoContent();
});

Here the 403-versus-404 decision becomes concrete for an API. For a record outside the scope, 404: even confirming the record exists is information. RFC (Request for Comments) 9110 also says a server that wants to hide the existence of a forbidden resource may respond with 404 instead of 403. For a record inside the scope but closed to the operation, 403: the user already sees the record, there's nothing to hide, and 403 tells them correctly what went wrong. When Alan tries to cancel his own Paid order:

$ curl -i -X POST http://localhost:19512/orders/0197f001-0000-7000-8000-000000000001/cancel -H "Authorization: Bearer $ALAN"
HTTP/1.1 403 Forbidden
Content-Length: 0
Date: Sun, 08 Nov 2026 05:33:20 GMT
Server: Kestrel

The tenant check in the handler may seem to do the same job as the scope filter. It's a deliberate repetition: if the handler is one day called from another path, for example from a background job with an Order loaded without filters, the second lock kicks in.

Generating Other-Tenant Scenarios from the Matrix

Writing "other tenant" rows into the matrix by hand is possible with the rule from the previous post, but it can be forgotten. Yet all those rows come from the same template: every allowed request, made by the same actor against the same kind of record in the other tenant, must be rejected. We turned that into a generator:

// Every allowed row yields a "same request, other tenant's record" row:
// 404 for an id in the route, 400 for an id in the body
public static IEnumerable<MatrixRow> CrossTenantVariants(IEnumerable<MatrixRow> rows)
{
    foreach (MatrixRow row in rows.Where(r => r.Actor != "anonymous" && r.Expected is >= 200 and < 300))
    {
        if (row.Resource == "-" || row.Resource.Contains('+'))
        {
            continue;
        }

        string actorTenant = SeedData.UserNamed(row.Actor).TenantId;
        (string tenant, string counterpart) = Tenancy[row.Resource];
        if (tenant != actorTenant)
        {
            continue;
        }

        bool inRoute = Resources[counterpart].Any(p => row.Route.Contains($"{{{p.Parameter}}}"));
        yield return row with { Resource = counterpart, Expected = inRoute ? 404 : 400 };
    }
}

In the test class we added a second MemberData to the Theory: [MemberData(nameof(CrossTenantRows))]. A new endpoint that enters the matrix with an allow row gets its other-tenant row for free. Some of what the generator produced:

passed Endpoint_returns_expected_status(endpoint: "POST /orders", actor: "alan", resource: "south-book", expected: 400) (602ms)
passed Endpoint_returns_expected_status(endpoint: "POST /orders/cancel", actor: "margaret", resource: "linus-order", expected: 400) (0ms)
passed Endpoint_returns_expected_status(endpoint: "POST /orders/{orderId}/cancel", actor: "barbara", resource: "alan-order", expected: 404) (0ms)
passed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "ada", resource: "linus-order", expected: 404) (0ms)

Test run summary: Passed!
  total: 41
  failed: 0
  succeeded: 41
  skipped: 0
  duration: 1s 211ms

All eight red rows from the previous post are green. And the change to endpoint code was almost entirely deletion: the hand-written Where calls in the list endpoint are gone.

Production Notes

We generate ids with UUIDv7 (Universally Unique Identifier, version 7), which makes guessing much harder than with sequential integer ids. According to RFC 9562, the first 48 bits of a UUIDv7 are a millisecond timestamp and 74 bits are random. So the id also carries the record's creation time, and it is "hard to guess", not "unguessable". Most importantly, ids are not secrets: they travel in URLs (Uniform Resource Locators), logs, emails and browser history. Random ids are one layer of defence in depth; they don't replace authorization.

Know the paths the filter doesn't cover. Raw SQL calls such as Database.SqlQuery and ExecuteSql, and Dapper queries, don't go through the EF Core model and get no filter; in those queries you must write the tenant condition by hand and test it separately. The same rule applies to the queries we wrote in the Dapper series. If you want a second lock at the database level, RLS (Row-Level Security) in PostgreSQL and SQL Server sits nicely under the application-level filter.

This setup doesn't work directly with DbContext pooling. AddDbContextPool reuses context instances across requests, so the context effectively behaves like a singleton; taking the tenant from the constructor doesn't fit that model. For this case the EF Core documentation recommends a scoped factory that sets the tenant on each context taken from the pooled factory. The same documentation also says compiled models generated with dotnet ef dbcontext optimize don't support global query filters; check that before moving to a compiled model for startup time. For most APIs a scoped context without pooling is fast enough.

On the detection side, the ScopeProbe log is not an alert on its own. A user trying many out-of-scope ids in a short time, or a sudden rise in the 404 rate from one tenant, are meaningful signals. We write the id and the tenant to the log, not personal data.

Common Pitfalls

The first pitfall is taking the tenant from the client. You can use an X-Tenant-Id header or a /tenants/{tenantId}/orders route, but scope must always come from the verified claim in the token. If the tenant id in the route doesn't match the claim, return 404.

The second is setting up the filter with a constant. A local variable as in HasQueryFilter(o => o.TenantId == currentTenant) gets baked into the model and every request sees the first tenant. The filter must always look at a field or property of the context instance.

The third is filtering the list endpoint and forgetting the details, sub-resource and bulk endpoints. The filter mostly solves this, but it doesn't solve the sub-resource relationship (does this invoice belong to this order?). That condition must stay in the query.

The fourth is adding IgnoreQueryFilters() because "the filter causes trouble in this query". If the filter breaks a query, the query is usually in the wrong place or running with the wrong context. Keeping the exception in a single file forces that conversation every time.

A Small Experiment

★☆☆ Comment out the tenant filter loop in OrdersDbContext and run the tests. Which tests broke? What did Every_tenant_owned_entity_has_a_tenant_filter tell you?

★★☆ Add a GET /orders/{orderId}/items endpoint and write just db.OrderItems.Where(i => i.OrderId == orderId). Add only one allow row to the matrix. Did the generator produce the other-tenant row, did the test pass? And for another customer's order in the same tenant? Why does OrderItem need an owner filter too?

★★★ Design a version that takes the tenant from the route: /tenants/{tenantId}/orders/{orderId}. Write an endpoint filter that returns 404 without entering the endpoint when the tenant in the route differs from the tenant in the claim, and add two rows to the matrix.

In the next post we move to API2 (Broken Authentication), but for machines instead of human users: designing API keys properly for B2B (Business to Business) integrations, storing only their hash, rotation and revocation, HMAC (Hash-based Message Authentication Code) signed requests and protecting token endpoints.