---
key: "function-property-authorization"
title: "Authorize functions and fields as well as objects"
url: "https://softwaresecurity.com/domains/appsec/authorization/function-property-authorization/"
api: "https://softwaresecurity.com/api/v1/articles/function-property-authorization.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-915","CWE-213","CWE-862","CWE-863","A01:2025","A08:2025","API3:2023","API5:2023","V8.2.1","V8.2.3","V15.3.1","V15.3.3"]
tags: ["Access control","API security"]
stages: ["build","test"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Authorize functions and fields as well as objects

Prevent mass assignment, sensitive-field exposure and administrative actions that a valid object lookup alone cannot protect.

Canonical URL: https://softwaresecurity.com/domains/appsec/authorization/function-property-authorization/

## In short

Beyond the object check, decide which operations the caller may invoke and which fields they may write and read. Bind each request to an operation-specific type that lists only the writable fields and rejects the rest, never to the stored entity. Return an explicit response projection instead of the database record. Put privileged changes such as role or tenant in separate actions that check the actor and tenant on the server.

- Owning a record does not make every field writable: `role`, `tenant`, `owner` and prices need their own permission.
- Bind to a DTO, serializer or schema that names the writable fields; never copy the request onto the entity with `Object.assign` or framework auto-binding.
- Reject unknown fields with 400 so mistaken clients fail visibly, and validate every allowed value.
- Return a projection per audience; hiding a field in the interface does not remove it from the JSON.
- Check function permission on the server for every admin route, including tenant scope and the current state of the target.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Ruby on Rails unpermitted parameters | `config.action_controller.action_on_unpermitted_parameters = :raise` | The default is `:log` in development and test, and `false` otherwise. | Rails API: ActionController::Parameters |
| .NET System.Text.Json | `JsonUnmappedMemberHandling.Disallow` on the request type or serializer options | .NET 8 and later; the default skips unknown members silently. | .NET API documentation |
| Spring Boot Jackson | `spring.jackson.deserialization.fail-on-unknown-properties=true` | — | Spring Boot application properties |
| NestJS | `new ValidationPipe({whitelist: true, forbidNonWhitelisted: true})` | — | NestJS validation documentation |
| Pydantic and FastAPI | `model_config = ConfigDict(extra="forbid")` on request models | — | Pydantic configuration documentation |
| Laravel | `$fillable` on each model, plus `Model::preventSilentlyDiscardingAttributes()` | — | Laravel Eloquent documentation |

## Verify it

1. PATCH your own profile with `role`, `tenant` and a nested copy of them, and confirm a 400 and an unchanged stored row.
2. Call each admin route directly as a member, as an administrator of another tenant and unauthenticated; each must be denied.
3. Compare every response body with its documented projection and assert sensitive fields are absent, not merely empty.
4. Add a column in a test migration and confirm it is neither writable nor returned until a contract adds it.
5. Search for `Object.assign(`, `fields = '__all__'`, `permit!` and request bodies typed as entities, and replace each.

## What you will learn

- Separate object, function, write-field and read-field permission.
- Construct an operation-specific input and output projection.
- Verify atomic rejection and privileged state transitions.

Audience: API and backend developers who bind requests to models or expose admin operations, and reviewers checking mass-assignment and data-exposure findings.

Before you start:

- Explain object ownership and the difference between reading a record and editing its fields.

Time: About 27 minutes: 8 to read and 19 for the exercise

## Ask four separate permission questions

Object permission says whether Alice may access profile A. It does not decide whether she may invoke an administrator operation on it, change its role, change its tenant or read internal review notes. Those are function and property permissions. A generic endpoint that copies every submitted field into an authorized object can therefore create privilege escalation without any object-id tampering.

Use a profile with `id` A, `tenant` red, `displayName` Alice, `locale` en, `role` member and `internalRiskReview` pending. Alice may update `displayName` and `locale` on her own profile. Only an authorized tenant administrator may assign an allowed role within that tenant, through a separate action. No public profile response includes `internalRiskReview`. A `PATCH` containing `displayName=Alice2` and `role=admin` should not silently turn into an administrator change simply because Alice owns the row.

This is how GitHub was compromised in March 2012. Its Rails application bound a public-key form to the model without restricting attributes, so a user could submit another account's user id with his own key (CVE-2012-2055) and then push a commit to the `rails/rails` repository. Rails soon made attribute allowlisting the default for new applications and later replaced it with strong parameters, which name the permitted fields per action.

## Build writable and readable projections explicitly

A vulnerable implementation executes `Object.assign(profile, request.body)`. A safer design parses an operation-specific input schema and constructs a new update object from the allowed fields. For this example, reject unknown fields with 400 rather than silently ignoring them, so mistaken client assumptions are visible. Validate types, lengths and locale values after the allowlist; an allowed field name does not make an arbitrary value valid. Do not let nested properties smuggle equivalent authority through an alternate path.

Use a separate response projection as well. Returning a database entity wholesale can leak internal fields added by a later migration even if today's response looks harmless. Explicitly return `id`, `displayName` and `locale` for the public profile. Administrators may need a different projection with an independently authorized purpose. Hiding fields in the browser does not prevent their disclosure in the network response, and marking a property read-only in client-generated documentation does not constrain the server.

The module below is the projection for the self-service `PATCH`, with the exercise requests as tests. It distinguishes an omitted field, which leaves the stored value unchanged, from an explicit `null`, which the contract rejects, and it rejects `role`, a nested `profile` object and an own `__proto__` key as unknown fields. Run `node profile-update.mjs`; it prints `PASS: 2 updates accepted; 6 forbidden or invalid bodies rejected`.

```javascript
// profile-update.mjs (Node.js 22+): operation-specific projection, after own-profile authorization.
import assert from 'node:assert/strict';

export function selectProfileUpdate(body) {
  const allowed = new Set(['displayName', 'locale']);
  if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error('invalid_input');
  if (Object.keys(body).some(key => !allowed.has(key))) throw new Error('unknown_field');
  const update = {};
  if (Object.hasOwn(body, 'displayName')) {
    const name = body.displayName;
    if (typeof name !== 'string' || name.length < 1 || name.length > 80)
      throw new Error('invalid_name');
    update.displayName = name;
  }
  if (Object.hasOwn(body, 'locale')) {
    if (!['en', 'fr'].includes(body.locale)) throw new Error('invalid_locale');
    update.locale = body.locale;
  }
  return update; // the handler writes only these fields, in one transaction
}

// Requests from the exercise; JSON.parse mirrors what a body parser produces.
const parse = json => selectProfileUpdate(JSON.parse(json));
assert.deepEqual(parse('{"displayName":"Alice2","locale":"fr"}'), {displayName: 'Alice2', locale: 'fr'});
assert.deepEqual(parse('{"locale":"fr"}'), {locale: 'fr'}); // omitted name is left unchanged
for (const [json, error] of [
  ['{"displayName":"Alice2","role":"tenant_admin"}', 'unknown_field'], // R2
  ['{"profile":{"role":"tenant_admin"}}', 'unknown_field'], // R3
  ['{"__proto__":{"role":"tenant_admin"}}', 'unknown_field'],
  ['[]', 'invalid_input'],
  ['{"locale":null}', 'invalid_locale'],
  ['{"displayName":""}', 'invalid_name'],
])
  assert.throws(() => parse(json), {message: error}, json);
console.log('PASS: 2 updates accepted; 6 forbidden or invalid bodies rejected');

```

## Use your framework's field allowlist

Most frameworks bind requests automatically, and most default to dropping unknown fields silently or, worse, binding every field the model has. Give each operation its own input type, and turn on the setting that makes extra fields an error so tests notice them. Schema strictness alone is not field authorization: Mongoose `strict` mode drops paths that are not in the schema, but `role` is in the schema, so the handler must still pick the writable keys. The fix cards at /fix/mass-assignment/ show this allowlist in seven frameworks.

The C# program below binds the same `PATCH` to a record that has only `DisplayName` and `Locale`. `JsonUnmappedMemberHandling.Disallow` turns `role` and the nested `profile` object into errors instead of silently ignoring them, and a check before binding refuses explicit nulls, because a nullable property cannot otherwise tell `null` from an omitted field. It ran with `dotnet run` on .NET 10; without the `Disallow` setting, the `role` body is accepted with the field ignored.

Framework binding can be mass assignment one level down. In 2022 Spring's data binding let request parameters walk from a form object to the class loader on JDK 9 and later (CVE-2022-22965, Spring4Shell), and the denylist workaround leaked twice; an allowlist of bindable fields would have stopped the walk at the first step: /domains/appsec/secure-coding/case-study-spring4shell/.

```csharp
// Program.cs (.NET 8+): bind the body to a DTO that holds only the writable fields.
using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, // unknown field: error
};

ProfileUpdate Parse(string json)
{
    using var doc = JsonDocument.Parse(json);
    if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException("shape");
    foreach (var field in doc.RootElement.EnumerateObject()) // null is not "leave unchanged"
        if (field.Value.ValueKind == JsonValueKind.Null) throw new JsonException(field.Name);
    var update = doc.RootElement.Deserialize<ProfileUpdate>(options)!;
    if (update.DisplayName is { Length: < 1 or > 80 }) throw new JsonException("displayName");
    if (update.Locale is not (null or "en" or "fr")) throw new JsonException("locale");
    return update; // the handler copies these values onto the caller's own profile
}

Check(Parse("""{"displayName":"Alice2","locale":"fr"}""") == new ProfileUpdate("Alice2", "fr"), "R1");
Check(Parse("""{"locale":"fr"}""") == new ProfileUpdate(null, "fr"), "omitted name stays unchanged");
foreach (var body in new[] {
    """{"displayName":"Alice2","role":"tenant_admin"}""", // R2: privileged field
    """{"profile":{"role":"tenant_admin"}}""",             // R3: nested smuggling
    """{"locale":null}""", """{"locale":"de"}""", """{"displayName":""}""", "[]" })
{
    try { Parse(body); throw new InvalidOperationException("accepted " + body); }
    catch (JsonException) { }
}
Console.WriteLine("PASS: 2 updates bound; 6 forbidden or invalid bodies rejected");

static void Check(bool ok, string what) { if (!ok) throw new InvalidOperationException(what); }

public sealed record ProfileUpdate(string? DisplayName, string? Locale);

```

| Framework | Bind only the writable fields | Make extra fields an error |
| --- | --- | --- |
| Ruby on Rails | `params.expect(profile: [:display_name, :locale])` in Rails 8, or `require(:profile).permit(...)` | `action_on_unpermitted_parameters = :raise` |
| Django REST framework | A serializer with an explicit `fields` list and `read_only_fields` | Unknown keys are dropped silently; compare `initial_data` with the declared fields to reject them |
| Django forms | `ModelForm` with `fields = ["display_name", "locale"]`, never `"__all__"` | Fields outside the form are ignored |
| Spring MVC | A request DTO per operation, never the entity; `WebDataBinder.setAllowedFields` for form binding | `spring.jackson.deserialization.fail-on-unknown-properties=true` |
| ASP.NET Core | A DTO per action; `[Bind("DisplayName,Locale")]` for MVC form posts | `JsonUnmappedMemberHandling.Disallow` (.NET 8 and later) |
| Laravel | `$fillable` on the model and `$request->validated()` | `Model::preventSilentlyDiscardingAttributes()` |
| Express with Mongoose | Pick the allowed keys before `findOneAndUpdate` | `strict: 'throw'` on the schema for paths it does not define |
| NestJS | DTO classes with class-validator decorators | `ValidationPipe` with `whitelist: true` and `forbidNonWhitelisted: true` |
| FastAPI and Pydantic | A request model with only the writable fields | `model_config = ConfigDict(extra="forbid")` |

## Bind privileged transitions to current state

Function authorization must be performed on the server for every route or RPC method. A member cannot gain a role by calling a hidden `/admin` endpoint directly. An administrator's role may itself be tenant-scoped, and the target user must belong to the same tenant. Define allowed transitions: promoting a user to `billing_admin` is different from changing the user's tenant or granting a global support role. Centralize the policy so alternate routes, GraphQL resolvers, batch APIs and background jobs cannot choose a weaker path. The fix cards at /fix/missing-authorization/ show the server-side check in each framework.

For sensitive changes, check permission consistently with the committed state. If the actor loses administrator status while a queued action waits, decide whether execution requires current authority. If two administrators race to remove the last administrator, a per-request check may pass for both unless the invariant is protected transactionally. For approvals, bind the decision to the exact version and material fields approved, so a change to payment details after approval invalidates or repeats it. These are state-transition rules beyond a field allowlist. Record actor, target, allowed operation and changed field names, while excluding secret field values from logs.

## Test forbidden fields and forbidden functions independently

The test matrix should preserve Alice's ordinary name update while rejecting role and tenant modifications. Check both top-level and nested fields, null, arrays and unexpected JSON shapes. Inspect the stored row after rejection; a handler that updates `displayName` before discovering `role` must roll back or validate the entire request before writing. The exercise chooses atomic rejection of mixed valid and invalid fields.

Read tests should assert absence, not merely an empty value, of `internalRiskReview` in the serialized response. Function tests should call the endpoint directly as a member, as a tenant administrator, as an administrator of another tenant and without authentication. If a framework serializer or form model automatically binds fields, review its actual allowed-field configuration and migrations. Keep policy tests near the operation contract so a new database property does not accidentally become writable or public.

## Separate omission, deletion and unchanged values

Partial updates need precise semantics. An omitted `locale` means leave the stored locale unchanged. A supplied `locale=null` is not the same operation unless the schema explicitly supports clearing it; the fixture rejects it. For `displayName`, an empty string fails the stated minimum, while an omitted field does not erase the name. A general object merge often blurs these distinctions and can turn missing or null values into unintended updates.

Consider an administrator request to assign the role already present. It may be a permitted no-op, but the route must still check administrator authority and tenant scope. Returning success before authorization because 'nothing would change' can leak sensitive current state or establish a bypass that becomes dangerous when the operation later gains side effects. Conversely, audit logging should distinguish a no-op from an actual privilege change so investigators do not infer a promotion that never occurred.

For bulk profile edits, repeat field and function authorization per operation rather than authorizing only the container request. A member may be allowed to update their display name but not another user's locale or anyone's role. If the endpoint promises atomic behavior, validate every requested change before committing any. The regression should include a mixed request with one permitted name update and one forbidden role change and require zero stored changes, preserving the same atomic rejection principle used in R2.

## Guided practice

Apply a complete profile write/read policy to supplied requests.

### Supplied material

```text
Stored A={id:A,tenant:red,displayName:Alice,locale:en,role:member,internalRiskReview:pending}.
Alice=(id A,red,member). Cara=(id C,red,tenant_admin). Dan=(id D,blue,tenant_admin).
Self PATCH accepts only displayName (1..80 characters) and locale (en|fr); rejects unknown fields atomically.
Public GET returns id,displayName,locale only.
Admin role action: same-tenant tenant_admin may assign member or billing_admin.
R1 Alice PATCH {displayName:Alice2,locale:fr}.
R2 Alice PATCH {displayName:Alice2,role:tenant_admin}.
R3 Alice PATCH {profile:{role:tenant_admin}}.
R4 Alice invokes admin role action on A ->billing_admin.
R5 Cara invokes same action. R6 Dan invokes same action.
Each request starts from original state.
```

### 1. Compare assignment with projection

Evaluate R1 and R2 under the stated policy, including final `displayName` and `role`.

Worked answer: R1 succeeds: `Alice2`, `fr`, `member`. R2 rejects the whole request with 400; `displayName` remains `Alice` and `role` remains `member`. Blind assignment would incorrectly allow role escalation.

### 2. Handle nested and malformed fields

Evaluate R3, then a body that is an array or `locale=null`.

Worked answer: R3 rejects unknown `profile`; arrays reject input shape; null locale rejects its enum/type constraint. None mutates any stored field. Allowed top-level names alone do not validate their values.

### 3. Authorize the privileged operation

Evaluate R4–R6 separately.

Worked answer: R4 denies because Alice is a member. R5 allows `billing_admin` because Cara has the required role in red. R6 denies because Dan's authority is in blue. Ownership and administrator status each answer only part of the question.

### 4. Project the response

Write the exact public GET fields after R1 and identify the absent values.

Worked answer: Return `{id:A,displayName:Alice2,locale:fr}`. Do not include `role`, `tenant` or `internalRiskReview` under this specific public contract. Merely hiding `pending` in the UI would still disclose it if present in JSON.

Deliverable: A write/function/read matrix with exact stored and serialized fields.

Scope: Paper exercise: the stored profile, callers and policy are supplied. `profile-update.mjs` and the C# `Program.cs` above run R1 to R3 and the malformed bodies in code, and the express-api project below applies the same rules to a running API.

## Executable practice

- [Mass assignment lab: Update only the fields users may edit](https://softwaresecurity.com/labs/mass-assignment/)
- [Secure a real HTTP API: project ZIP](https://softwaresecurity.com/downloads/projects/express-api-519f6cf7960d8d96.zip) — Node.js 22+ · npm

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Mass Assignment](https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html)
- [OWASP Authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [OWASP API3:2023 Broken Object Property Level Authorization](https://api-security.owasp.org/editions/2023/en/0xa3-broken-object-property-level-authorization/)
- [OWASP API5:2023 Broken Function Level Authorization](https://api-security.owasp.org/editions/2023/en/0xa5-broken-function-level-authorization/)
- [Rails API: ActionController::Parameters](https://api.rubyonrails.org/classes/ActionController/Parameters.html)
- [.NET: handle overflow JSON or unmapped members](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/missing-members)
- [NVD: CVE-2012-2055 (GitHub mass assignment)](https://nvd.nist.gov/vuln/detail/cve-2012-2055)