Writing
2 July 2026

The OData endpoint that returned more than you meant it to

OData gives clients the query. That's the point — and the risk. How an innocent [EnableQuery] leaks columns, relationships, and whole tables you never intended to expose.

The convenience is the vulnerability

OData is wonderful precisely because it hands the query to the caller. $filter, $select, $expand, $orderby — the client composes what it needs and you write almost no controller code. That same property is why an OData surface is the easiest place in a .NET API to leak data without noticing.

The problem is rarely a missing auth check on the endpoint. It's that the endpoint is more powerful than the developer who wrote it realised.

One attribute, a whole object graph

Here's the shape I see most often:

[EnableQuery]
[HttpGet]
public IQueryable<Customer> Get() => _db.Customers;

Reasonable-looking. But [EnableQuery] with no bounds means the caller can:

  • $select=PasswordHash,InternalRiskScore — any column on the entity, including the ones you only put there for internal use.
  • $expand=Orders($expand=Payments) — walk every navigation property, several levels deep, straight into tables you never meant this endpoint to touch.
  • $orderby=SomeExpensiveComputed or a deep $filter — turn a cheap list into a table scan and a denial-of-service.

Nobody wrote "expose payments here." The relationship exposed itself, because $expand follows whatever the EF model allows.

Where the real boundary belongs

The fix is not to abandon OData. It's to decide, explicitly, what this surface is allowed to do — and to make the query operate on a projection you control, never the raw entity.

[EnableQuery(
    MaxExpansionDepth = 1,
    AllowedQueryOptions = AllowedQueryOptions.Filter
                        | AllowedQueryOptions.OrderBy
                        | AllowedQueryOptions.Top
                        | AllowedQueryOptions.Skip,
    MaxTop = 100)]
[HttpGet]
public IQueryable<CustomerListItem> Get() =>
    _db.Customers.Select(c => new CustomerListItem
    {
        Id   = c.Id,
        Name = c.Name,
        City = c.City,
    });

Now the shape is closed. $select can only reach the three columns on CustomerListItem. $expand has nothing to walk. MaxTop caps the page. The model builder should back this up so the metadata document itself never advertises what you don't serve.

What I check on an OData review

  • Does the query run over an entity or a DTO? Entity means the whole model is reachable. That's the first thing I flag.
  • Is $expand bounded? Depth, and an allow-list of expandable properties — not "whatever EF knows about."
  • Is MaxTop set, and is there a default page size? An unbounded $top is a load-generator waiting for a bored client.
  • Does $filter reach columns you'd never index or expose? Filtering is a read too — you can leak a value by asking the right yes/no question even without $select.
  • Does the $metadata document match your intent? It's a public map of your model. Read it as an attacker would.

An OData endpoint isn't insecure because someone forgot [Authorize]. It's insecure because the query language is doing exactly what it was designed to do, over a model that was never scoped for it. That gap — between what the endpoint appears to return and what it can return — is one of the first things I go looking for.