# Preventing BOLA Vulnerabilities in ASP.NET Core APIs

## What Is Broken Object-Level Authorization?

Broken Object-Level Authorization (BOLA) - also known as Insecure Direct Object Reference (IDOR) - sits at number one on the OWASP API Security Top 10. It has been there for years, and it stays there because it is deceptively simple to introduce and painfully hard to catch in code review. The attack is straightforward: a user sends a request with someone else's resource ID and the API returns that resource without checking whether the caller actually owns it. No privilege escalation, no fancy exploit - just swap an integer in the URL and read somebody's private data.

In production, I have seen this pattern slip through in teams that had thorough unit tests and JWT authentication wired up correctly. The auth layer was fine - the problem was one layer deeper, in the controllers that fetched resources by ID and assumed the authenticated user already had the right to see them. If you want to see how resource-level ownership authorization fits inside a full production ASP.NET Core codebase - alongside role-based policies, claims, and API-key middleware - [Chapter 8 of the Zero to Production course](https://aspnetcoreapi.codingdroplets.com/) covers all of it in a single working project you can run immediately.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg)](https://aspnetcoreapi.codingdroplets.com/)

The complete defence patterns - including tenant scoping, EF Core global query filters, and edge cases the happy path never tests - are available on [Patreon](https://www.patreon.com/CodingDroplets) with annotated, production-ready source code.

## Why BOLA Is So Common in .NET APIs

ASP.NET Core makes authentication easy. Plug in JWT bearer, add `[Authorize]`, done - the user is authenticated. What the framework does not do for you is enforce *ownership*. A `GET /api/orders/{id}` endpoint that verifies the JWT but then fetches `orderId` from the route without checking the owner is vulnerable by default.

The failure mode I see most often in .NET codebases looks like this:

```csharp
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetOrder(int id)
{
    var order = await _repository.GetByIdAsync(id);
    if (order is null) return NotFound();
    return Ok(order);
}
```

The `[Authorize]` attribute confirms the caller is authenticated. The `GetByIdAsync(id)` call retrieves whatever record matches the ID. Those are two completely independent checks - and there is no bridge between them. An authenticated attacker who guesses or enumerates IDs can read any order in the database.

This is not a gap in the framework. It is a pattern problem. The fix belongs at the data access level and in how you design ownership enforcement.

## The Vulnerable Pattern

BOLA vulnerabilities typically appear in three places:

**1. Direct ID lookups without ownership check**

Fetching a record by its primary key and returning it without verifying the current user is its owner. This is the classic case above.

**2. Nested resource endpoints without parent ownership validation**

A `GET /api/invoices/{invoiceId}/line-items` endpoint might check that `invoiceId` exists but forget to verify the invoice belongs to the current user's account.

**3. Predictable IDs**

Sequential integer IDs make enumeration trivial. An attacker does not need to know valid IDs - they just increment from 1. This is an amplifier, not the root cause, but it makes the surface area much larger.

## How to Prevent BOLA in ASP.NET Core

### Always Scope Queries to the Authenticated User

The most reliable fix is to make it structurally impossible to return a resource the current user does not own. Every query that fetches by ID should include a user filter:

```csharp
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetOrder(int id)
{
    var userId = User.GetUserId(); // claim extraction helper
    var order = await _repository.GetByIdForUserAsync(id, userId);
    if (order is null) return NotFound();
    return Ok(order);
}
```

The `GetByIdForUserAsync` call adds a `WHERE user_id = @userId` clause alongside the ID filter. If the ID exists but belongs to a different user, the query returns null and the endpoint returns 404 - not 403. Returning 404 instead of 403 is intentional: it avoids leaking the existence of a resource to an unauthorized caller.

This approach works at the repository level and does not require the controller to do the ownership check explicitly - the data layer simply cannot return data the user does not own.

### Use EF Core Global Query Filters for Tenant-Level Scoping

If your API is multi-tenant or user-scoped across many entity types, using EF Core global query filters is a cleaner way to enforce ownership at the DbContext level, so ownership becomes ambient rather than something every query has to remember:

```csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>()
        .HasQueryFilter(o => o.UserId == _currentUserService.UserId);
}
```

With this in place, every EF Core query against `Orders` automatically includes the user filter. Developers cannot accidentally forget it - the scoping is structural. For a deep dive into multi-tenant data isolation approaches, see [Multi-Tenant Data Isolation in ASP.NET Core: Row-Level vs Schema vs Database-per-Tenant](https://codingdroplets.com/multi-tenant-data-isolation-aspnet-core-row-level-vs-schema-vs-database-per-tenant).

### Use Resource-Based Authorization for Fine-Grained Policies

For resources with more complex ownership rules - for example, where multiple users can share access to a resource - use ASP.NET Core's `IAuthorizationService` with resource-based authorization. This lets you express "can this specific user access this specific resource" as a named policy:

```csharp
var authResult = await _authorizationService
    .AuthorizeAsync(User, order, "CanAccessOrder");

if (!authResult.Succeeded)
    return Forbid();
```

The corresponding `AuthorizationHandler<CanAccessOrderRequirement, Order>` can check whatever ownership condition your domain requires - including shared access, team membership, or role overrides - without coupling that logic to the controller.

This is the approach I reach for when ownership is not a simple `user_id` match. The data-layer scoping handles the 90% case; resource-based authorization handles the rest.

### Avoid Predictable IDs in Public-Facing Endpoints

Sequential integers tell attackers exactly how many records exist and which IDs to try. For externally-facing identifiers, use GUIDs (`Guid.NewGuid()`) or ULID. This does not fix the authorization gap - it just makes enumeration much harder.

The pattern I prefer is to keep internal integer primary keys for database performance but expose GUIDs in the API contract. The mapping stays in the domain layer, and external callers never see the internal ID.

### Validate Ownership for Nested Resources

Every nested route should trace ownership back to a resource the current user controls:

```csharp
[HttpGet("{invoiceId}/line-items")]
[Authorize]
public async Task<IActionResult> GetLineItems(int invoiceId)
{
    var userId = User.GetUserId();
    var invoice = await _repository.GetInvoiceForUserAsync(invoiceId, userId);
    if (invoice is null) return NotFound();

    var lineItems = await _repository.GetLineItemsForInvoiceAsync(invoiceId);
    return Ok(lineItems);
}
```

Confirming the parent invoice belongs to the caller before fetching the child records is the key step. Without it, even if the `GetInvoiceForUserAsync` check exists elsewhere in the codebase, the nested endpoint becomes an independent vulnerability.

## Defence-in-Depth Checklist

Layer your defences - no single control is sufficient on its own:

- **Scope every data query to the authenticated user or tenant** - this is the primary control
- **Return 404 (not 403) when an ID exists but the caller cannot access it** - avoids resource enumeration
- **Use non-sequential, non-guessable resource IDs in public API contracts** (GUIDs, ULIDs)
- **Apply EF Core global query filters** for systematic tenant or user scoping across all entity types
- **Use resource-based authorization** for complex ownership rules that go beyond a simple user ID match
- **Audit every `[HttpGet("{id}")]` endpoint** to confirm ownership filtering is applied - this is often a one-time code review exercise that catches most legacy gaps
- **Test with a second authenticated user** - in integration tests, verify that user B cannot access user A's resources by ID. This is the test most teams skip and the one that would catch BOLA every time
- **Log and alert on authorization failures** - a spike in 404s from a single user on sequential IDs is an enumeration attempt

For building APIs that complement this with idempotency and correct HTTP semantics, see [Idempotency Keys in ASP.NET Core: Preventing Duplicate Payments and Orders](https://codingdroplets.com/idempotency-keys-in-aspnet-core-preventing-duplicate-payments-and-orders).

## What BOLA Looks Like in a Real Incident

A team I worked with had JWT auth and role-based policies on all administrative endpoints. Their user-facing endpoints - GET `/api/documents/{id}`, GET `/api/reports/{id}` - were also behind `[Authorize]`. The logic was: "all our users are authenticated, docs belong to users, so it's fine."

The gap was in the report-sharing feature. Reports could be shared between accounts, but the endpoint to retrieve a shared report still accepted any integer ID. Authenticated users from competing accounts could read any report in the system by incrementing the ID.

The fix took about four hours: global query filter on the Report entity scoped to the tenant, a resource-based authorization handler for the shared-access case, and integration tests that verified cross-tenant access returned 404. The vulnerability had been live for 11 months.

The lesson: authentication is a prerequisite, not a substitute for authorization. Every endpoint that touches user-owned data needs explicit ownership enforcement.

## Frequently Asked Questions

**What is the difference between BOLA and IDOR?**
They describe the same vulnerability. IDOR (Insecure Direct Object Reference) is the older term from the OWASP Web Application Security Top 10. BOLA (Broken Object-Level Authorization) is the term OWASP uses in the API Security Top 10. In practice they are identical - an API that exposes resource IDs and does not verify ownership is vulnerable to both.

**Does JWT authentication protect against BOLA?**
No. JWT authentication confirms who the caller is. BOLA is about what that caller is allowed to access. An authenticated user can exploit BOLA just as easily as an unauthenticated one - the only difference is they have a valid token. Authentication and authorization are separate controls.

**Should I return 403 Forbidden or 404 Not Found when an ownership check fails?**
Return 404. Returning 403 tells the attacker the resource exists but they cannot access it - which is itself information disclosure. Returning 404 gives nothing away. This is a standard API security practice and is consistent with how GitHub, Stripe, and most well-secured APIs behave.

**Can EF Core global query filters fully prevent BOLA?**
They are a strong control for simple ownership models, but they are not sufficient alone. Global query filters can be bypassed with `IgnoreQueryFilters()`, and they do not cover raw SQL queries or Dapper calls. Use them as a safety net, not a sole defence.

**How do I test for BOLA in an ASP.NET Core API?**
Create two test users (User A and User B) in your integration test setup. Create a resource under User A, then make a request as User B using that resource's ID. The response should be 404. If it returns 200 with data, the endpoint is vulnerable. This pattern should be in your standard integration test suite for every resource-fetching endpoint.

**Does using GUIDs instead of integers prevent BOLA?**
No - GUIDs make enumeration harder but do not fix the underlying ownership gap. If an attacker obtains a GUID (from a shared link, logs, or another endpoint), they can still exploit BOLA if ownership is not enforced. Non-sequential IDs are a useful defence-in-depth measure, not a substitute for proper authorization.

---

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

- GitHub: [codingdroplets](http://github.com/codingdroplets/)
- YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
- Website: [codingdroplets.com](https://codingdroplets.com/)

