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

API3 BOPLA: Property-Level Authorization, PATCH Requests and Response Shaping

Mert Özen Nov 8, 2026 12 min 4 views
API3 BOPLA: Property-Level Authorization, PATCH Requests and Response Shaping

You close BOPLA not with serializer settings but with a separate response type per role and a writable-field allowlist per role. Projections with Mapperly that break the build when a field is added, moving a different view to its own endpoint and policy, rejecting locked fields in JSON Merge Patch, and using the OpenAPI schema as a contract guarded by a test.

Version note: The code in this post was built and run with .NET 10, EF (Entity Framework) Core 10.0.14, Riok.Mapperly 4.3.1 and Microsoft.AspNetCore.OpenApi 10.0.14.

Guess First

In the lab's first version GET /orders/{orderId} returned a single DTO (Data Transfer Object): OrderDetails. It contained InternalCost too, because the support team's screen showed that field. In the previous two posts we solved who can see which order. Now the question: when Alan asks for his own order, what's in the response? And what if he sends {"internalCost": 0} to the same endpoint with PATCH?

The answer to the first question is uncomfortable: Alan is allowed to see his own order, but not what that order cost us. At the record level everything is right; at the property level it isn't. That is exactly what API3 (the third item in the OWASP API (Application Programming Interface) Security Top 10 2023) is about: BOPLA (Broken Object Property Level Authorization). The answer to the second question depends on how the endpoint is written, and that's the second half of this post.

The basics of mass assignment and JsonUnmappedMemberHandling are in the A08 Software or Data Integrity Failures post of the Web Security (OWASP Top 10) series. There we said "reject unknown fields in the body". Here we go one step further: the field is known, it exists in the model, but it isn't writable for this role.

The Two Faces of BOPLA

In its 2023 edition OWASP (Open Worldwide Application Security Project) merged two items from 2019: Excessive Data Exposure (returning too much data) and Mass Assignment (accepting too much data). They share the same root: what properties an endpoint can read and write isn't checked against who is making the request. On the read side, the leak usually comes from thinking "the client doesn't show it anyway". Even if the Angular screen doesn't render internalCost, it sits in the JSON (JavaScript Object Notation); anyone who opens the browser's network tab sees it.

On the write side the problem is more dangerous because data changes. If a customer can change total, tenantId or status, the business rules are broken no matter how solid record-level authorization is. We approach both with the same principle: which role may read and write which field must be written in types and in a single allowlist, not in if statements scattered across the code.

A Response Type per Role

The first step is not to hand out the entity, or a single DTO that "contains everything". We write a separate record for each audience:

// A separate response type per role: which field is visible to whom is written in the type
public sealed record CustomerOrderView(Guid Id, string Status, decimal Total, string ShippingAddress,
    string? Note, DateTime CreatedAt);

public sealed record StaffOrderView(Guid Id, Guid CustomerId, string Status, decimal Total, decimal InternalCost,
    string ShippingAddress, string? Note, DateTime CreatedAt);

The alternative is to hide fields by role in a single DTO with [JsonIgnore(Condition = ...)] or a JsonConverter. I don't like that path. A serializer setting is global: someone changes JsonSerializerOptions, a test project serializes with different settings, a log line writes the DTO as is, or a cache layer stores the object with another serializer. If the field isn't in the type, none of these can leak it.

Mapperly: Breaking the Build When a Field Is Added

The problem with hand-written mapping is that when a new field is added to the entity, nobody asks "which view should this field go into?". Mapperly, which is based on a source generator, can force that question at compile time:

[Mapper]
public static partial class OrderViewMapper
{
    public static partial IQueryable<CustomerOrderView> ProjectToCustomerView(this IQueryable<Order> orders);
    public static partial IQueryable<StaffOrderView> ProjectToStaffView(this IQueryable<Order> orders);

    // Every field that doesn't go to the customer is left out here, by name and on purpose
    [MapperIgnoreSource(nameof(Order.TenantId))]
    [MapperIgnoreSource(nameof(Order.CustomerId))]
    [MapperIgnoreSource(nameof(Order.InternalCost))]
    [MapperIgnoreSource(nameof(Order.Items))]
    [MapperIgnoreSource(nameof(Order.Invoices))]
    private static partial CustomerOrderView ToCustomerView(Order order);

    [MapperIgnoreSource(nameof(Order.TenantId))]
    [MapperIgnoreSource(nameof(Order.Items))]
    [MapperIgnoreSource(nameof(Order.Invoices))]
    private static partial StaffOrderView ToStaffView(Order order);
}

IQueryable projections produce a Select that EF Core can translate to SQL (Structured Query Language); only the columns in the view come back from the database. In my first attempt I put the ignore attributes directly on the projection method and Mapperly raised RMG065 (a Mapperly diagnostic code), saying object mapping configuration can't be applied to a queryable projection. The configuration lives on a private object mapping method, and the projection uses it.

The real gain is one line in the project file:

<PropertyGroup>
  <!-- Mapperly: an unmapped source/target member is a build error -->
  <WarningsAsErrors>$(WarningsAsErrors);RMG012;RMG020</WarningsAsErrors>
</PropertyGroup>

RMG020 (a Mapperly diagnostic code) is the warning raised when a property on the source type has no counterpart on the target; RMG012 (Mapperly again) is the opposite direction. Turning both into errors hands the "a conscious decision for every field" rule over to the compiler. Let's try it: I add a FraudScore field to Order.

$ dotnet build src/Bookstore.Orders.Api
src/Bookstore.Orders.Api/Endpoints/OrderViews.cs(20,5): error RMG020: The member FraudScore on the mapping source type Bookstore.Orders.Api.Domain.Order is not mapped to any member on the mapping target type Bookstore.Orders.Api.Endpoints.CustomerOrderView
src/Bookstore.Orders.Api/Endpoints/OrderViews.cs(27,5): error RMG020: The member FraudScore on the mapping source type Bookstore.Orders.Api.Domain.Order is not mapped to any member on the mapping target type Bookstore.Orders.Api.Endpoints.StaffOrderView

Whoever adds the field now has to decide separately for both views: either add it to the view or write down with [MapperIgnoreSource] that it's left out. The second option is clearly visible in the pull request and naturally raises "should the customer see this score?" in review.

A Different View, a Different Endpoint

My first design had a single endpoint: GET /orders/{orderId} returned Ok<CustomerOrderView> or Ok<StaffOrderView> depending on the role. It worked and the tests passed. Then I looked at the generated OpenAPI document: for the 200 response there was only StaffOrderView, and CustomerOrderView wasn't even in the schema list. In ASP.NET Core 10, when several response types are declared for the same status code, only one of them is written to the document; the ASP.NET Core 11 release notes say this behaviour has changed and every type is now preserved. So the contract was claiming that the cost field goes to customers.

This small mishap actually pointed to the right design. Choosing the view by role inside the endpoint ties a property-level decision to a runtime if. Putting the views on separate endpoints moves that decision to the function level, i.e. to a policy:

// One schema for everyone: the customer view
orders.MapGet("/{orderId:guid}", async Task<Results<Ok<CustomerOrderView>, NotFound>> (
    Guid orderId, OrdersDbContext db, ScopeProbe probe, CancellationToken ct) =>
{
    if (await db.Orders.Where(o => o.Id == orderId).ProjectToCustomerView().FirstOrDefaultAsync(ct)
        is { } view)
    {
        return TypedResults.Ok(view);
    }
    await probe.ReportIfOutOfScopeAsync<Order>(orderId, ct);
    return TypedResults.NotFound();
});

// The view with the cost field lives on a separate endpoint behind a separate policy
app.MapGet("/support/orders/{orderId:guid}", async Task<Results<Ok<StaffOrderView>, NotFound>> (
    Guid orderId, OrdersDbContext db, CancellationToken ct) =>
    await db.Orders.Where(o => o.Id == orderId).ProjectToStaffView().FirstOrDefaultAsync(ct) is { } view
        ? TypedResults.Ok(view)
        : TypedResults.NotFound())
    .RequireAuthorization(LabPolicies.Staff);

Now every endpoint has a single response schema, and which field goes to whom depends on a policy that the matrix rows test:

$ curl -s http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
{"id":"0197f001-0000-7000-8000-000000000001","status":"Paid","total":54.9,"shippingAddress":"alan street 1","note":null,"createdAt":"2026-11-02T10:00:00"}
$ curl -s http://localhost:19514/support/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $MARGARET"
{"id":"0197f001-0000-7000-8000-000000000001","customerId":"0197f000-0000-7000-8000-000000000003","status":"Paid","total":54.9,"internalCost":31.2,"shippingAddress":"alan street 1","note":null,"createdAt":"2026-11-02T10:00:00"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19514/support/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
403

403 is the right answer here: even if Alan knows this endpoint exists we lose nothing, and record-level scope (the global query filter runs on /support/orders too) still hides other tenants' orders behind 404.

PATCH: The Writable-Field Allowlist

There are two standards for partial updates: RFC (Request for Comments) 7396 JSON Merge Patch and RFC 6902 JSON Patch. Merge patch is simple: the client sends a JSON object with the fields it wants to change, and null clears a field. JSON Patch is a list of op, path, value operations; with .NET 10 the System.Text.Json-based Microsoft.AspNetCore.JsonPatch.SystemTextJson package arrived too. Whichever you choose, the security rule is the same: every field an operation touches must be in the list of fields writable for that role. We use merge patch:

public const string MediaType = "application/merge-patch+json";

// Writable-field allowlist per role; any field not listed here is rejected
private static readonly Dictionary<string, string[]> Writable = new()
{
    ["customer"] = ["shippingAddress", "note"],
    ["support"] = ["shippingAddress", "note", "status"],
    ["admin"] = ["shippingAddress", "note", "status"]
};

public static Dictionary<string, string[]> Validate(JsonObject patch, string role, Order order)
{
    string[] allowed = Writable.GetValueOrDefault(role, []);
    Dictionary<string, string[]> errors = [];
    foreach ((string field, JsonNode? value) in patch)
    {
        // The error message only repeats the field name the client sent; it doesn't describe the internal model
        if (!allowed.Contains(field, StringComparer.Ordinal))
        {
            errors[field] = ["Field is not writable."];
        }
        else if (field is "shippingAddress" && (value?.GetValueKind() != JsonValueKind.String
                 || value.GetValue<string>().Length is 0 or > 200))
        {
            errors[field] = ["Must be a non-empty string up to 200 characters."];
        }
        else if (field is "note" && value is not null && (value.GetValueKind() != JsonValueKind.String
                 || value.GetValue<string>().Length > 500))
        {
            errors[field] = ["Must be null or a string up to 500 characters."];
        }
        else if (field is "status" && (value?.GetValueKind() != JsonValueKind.String
                 || !Enum.TryParse(value.GetValue<string>(), out OrderStatus _)))
        {
            errors[field] = ["Unknown status."];
        }
    }

    // A customer can only change an order that hasn't been shipped yet
    if (role == "customer" && order.Status is not (OrderStatus.Pending or OrderStatus.Paid) && errors.Count == 0)
    {
        errors["order"] = ["Only pending or paid orders can be changed."];
    }
    return errors;
}

I read the patch as a JsonObject instead of binding it to a DTO. The reason is the meaning of merge patch: {"note": null} and not sending note at all are different things. A DTO with nullable properties loses that difference; JsonObject keeps it. For the same reason validation doesn't work as "ignore unknown fields" but as "reject every field not on the list". Silently ignoring gives the client the impression that its request was applied; rejecting shows whoever writes the integration their mistake right away.

The endpoint checks the media type first, then loads the record within scope, and validates the fields last. The order is deliberate: returning a field error for an out-of-scope order would reveal that the order exists.

orders.MapPatch("/{orderId:guid}", async (Guid orderId, HttpRequest request, ICurrentUser user,
    OrdersDbContext db, CancellationToken ct) =>
{
    if (request.ContentType != OrderPatch.MediaType)
    {
        return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
    }
    if (await JsonNode.ParseAsync(request.Body, cancellationToken: ct) is not JsonObject patch)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]> { [""] = ["Expected a JSON object."] });
    }

    Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
    if (order is null)
    {
        return Results.NotFound();
    }

    Dictionary<string, string[]> errors = OrderPatch.Validate(patch, user.Role, order);
    if (errors.Count > 0)
    {
        return Results.ValidationProblem(errors);
    }

    OrderPatch.Apply(patch, order);
    await db.SaveChangesAsync(ct);
    return Results.NoContent();
})
.Accepts<OrderPatchRequest>(OrderPatch.MediaType);

Alan's attempts:

$ curl -s -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/merge-patch+json" -d '{"note":"hi","internalCost":0,"tenantId":"south","total":1}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"internalCost":["Field is not writable."],"tenantId":["Field is not writable."],"total":["Field is not writable."]},"traceId":"00-c9155db610131c1e70e785df23eb43d7-5b561089bd8eb26e-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/merge-patch+json" -d '{"shippingAddress":"2 Harbour Road","note":null}'
204
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -d '{"note":"x"}'
415

In the first request nothing was saved, even though note was valid: the request is applied as a whole or not at all. The error response only repeats the names of the three fields Alan sent. The message "Field is not writable" may look like it says InternalCost exists, but a field that isn't in the model, such as tenantId or isAdmin, gets the same message; the message says the field isn't writable, not that it exists. The opposite, returning the internal type name, the column name or the validation library's internal path in the error message, gives an attacker free information about the model.

The OpenAPI Schema Is a Contract

A side effect of using separate types for requests and responses is that the document is honest. In the generated OpenAPI document the PATCH body contains only three optional fields:

"OrderPatchRequest": {
  "type": "object",
  "properties": {
    "shippingAddress": { "type": [ "null", "string" ] },
    "note": { "type": [ "null", "string" ] },
    "status": { "type": [ "null", "string" ] }
  }
}

I first wrote OrderPatchRequest as a positional record and the schema showed all three fields as required; since none of them is mandatory in a merge patch, I turned it into a record with init properties. In the same document, the quantity field of CreateOrderRequest appears as ["integer", "string"]; ASP.NET Core's web defaults allow numbers to be read from strings too, and the schema reflects that faithfully. These are small details, but they are exactly what the integration team reading the contract relies on.

A test guards the schema too. If a developer accidentally adds InternalCost to CustomerOrderView or puts Total into CreateOrderRequest, the test breaks:

[Fact]
public async Task OpenApi_request_and_response_schemas_are_separate()
{
    JsonObject document = (await factory.CreateClient().GetFromJsonAsync<JsonObject>("/openapi/v1.json", Ct))!;
    JsonObject schemas = document["components"]!["schemas"]!.AsObject();

    string[] Properties(string schema) => schemas[schema]!["properties"]!.AsObject().Select(p => p.Key).ToArray();

    Assert.DoesNotContain("internalCost", Properties("CustomerOrderView"));
    Assert.Contains("internalCost", Properties("StaffOrderView"));
    Assert.Equal(["bookId", "quantity", "shippingAddress"], Properties("CreateOrderRequest"));
    Assert.Equal(["shippingAddress", "note", "status"], Properties("OrderPatchRequest"));
}

The other tests check behaviour: the customer view has no internalCost or customerId, a patch with locked fields is rejected and nothing changes in the database, a customer can't change status but support can, plain application/json gets 415. I also added nine rows to the matrix for PATCH /orders/{orderId} and GET /support/orders/{orderId}; the generator from the previous post derived the other-tenant rows by itself.

Test run summary: Passed!
  total: 74
  failed: 0
  succeeded: 74
  skipped: 0
  duration: 1s 307ms

Production Notes

Keep the number of views under control. Creating a new DTO for every role and every screen quickly becomes unmanageable. My practical rule: views by data sensitivity, not by screen. "Customer view" and "staff view" are two separate sensitivity levels; if a screen needs less, trimming on the client is fine, because the trimmed data is data that user is allowed to see anyway.

Property-level decisions extend to logs and error messages too. An error response where the exception middleware serializes the entity wipes out the most careful DTO design in an instant. Error responses should be limited to ProblemDetails; we discussed the details in the A10 post of the Web Security series.

Watch out for cache layers. If an output cache or a CDN (Content Delivery Network) returns the same response for the same URL (Uniform Resource Locator) without distinguishing roles, a response a staff member saw could go to a customer. That's another benefit of putting views on separate URLs: the cache key is already different. We'll cover Cache-Control for authenticated responses in the API8 (Security Misconfiguration) post.

Finally, in APIs where the client selects fields, such as GraphQL or OData, the same problem shows up as field-level authorization; those topics belong to the upcoming gRPC and GraphQL series.

Common Pitfalls

The first pitfall is using one DTO for both reading and writing. Returning OrderDto on GET and accepting it on PUT makes every readable field writable. Fields such as Id, Total and CreatedAt belong in the read DTO and never in the write DTO.

The second is validating permissions against values the client sent. Checks like if (patch.Total == order.Total), "it's fine if unchanged", hide the fact that the client shouldn't send the field at all, and they disappear in the next refactor. If a field isn't on the list, reject it whatever its value.

The third is treating [JsonIgnore] as an authorization tool. [JsonIgnore] on the entity hides that field everywhere; but a second serializer, a log formatter or a message queue may not know that attribute. Moreover, it can't express a role-based rule like "support sees it, customers don't".

The fourth is giving internal details in error messages. A message like "Column 'InternalCost' cannot be updated by role 'customer'" describes the column name, the role name and the rule itself in a single line.

A Small Experiment

★☆☆ In the lab, as Margaret, set the status of Alan's order to Shipped with PATCH /orders/{orderId}. Then try to change the address of the same order as Alan. Which rule kicked in?

★★☆ Add a GiftMessage field to Order. Which error did the build give? Add the field to the customer view, show it in the staff view too, and make it writable for customers only on Pending orders. How many files did you touch?

★★★ Write the same allowlist for JSON Patch: add an endpoint that accepts a JsonPatchDocument with the Microsoft.AspNetCore.JsonPatch.SystemTextJson package and validate every operation's path against the allowlist. Don't forget to check the from field for move and copy operations.

In the next post we move to API4 (Unrestricted Resource Consumption): maximum page size, sort and filter field allowlists, request timeouts, a separate limit for expensive queries, and per-tenant budgets for calls that cost money such as SMS (Short Message Service) or email.