Your API accepts a JSON object and maps it to a model. That convenience is also how users become admins, balances change, and tenants leak into each other.
Mass assignment is one of those vulnerabilities that feels too simple to be real. The API takes a request body, binds it to an internal object, and saves it. The client sends an extra field. The field is in the model. The server writes it.
That is the whole attack.
It sits at the intersection of two OWASP API risks: API3 (Broken Object Property Level Authorization) and API6 (Unrestricted Access to Sensitive Business Flows). It is also one of the easiest high-impact bugs to find and one of the easiest to prevent — if you stop treating the database model as the API contract.
A minimal example
PATCH /api/users/me Content-Type: application/json { "display_name": "Alice", "role": "admin", "is_verified": true, "tenant_id": "acme-corp" } A naive handler:
@app.patch("/users/me") async def update_me(payload: dict, user: User = Depends(current_user)): for key, value in payload.items(): setattr(user, key, value) await user.save() return user This is not a contrived example. It is the natural consequence of "accept whatever the client sends and update the matching fields." Frameworks have historically encouraged it with helpers like update_attributes(request.json) or Object.assign(entity, body).
The result: privilege escalation, identity changes, billing changes, and tenant hopping — all through fields the UI never displays.
Why it keeps happening
The model is both the database schema and the API contract
If a User model has a role column, and the update endpoint accepts a User-shaped body, then role is writable by default. The developer did not decide to expose it; the framework inferred it.
Partial updates encourage generic binding
PATCH semantics invite "update whatever fields are present." That is convenient for clients and dangerous for security, because "whatever fields are present" is controlled by the attacker.
Nested objects multiply the surface
{ "name": "Acme", "owner": { "id": 42, "email": "attacker@example.com" }, "billing": { "plan": "enterprise", "seats": 1000 } } A single top-level allowlist does not protect nested structures. Each nested object needs its own schema.
Frontend filtering is not a control
The UI sends only the fields it renders. An attacker sends whatever they want. The only boundary that matters is the server.
Variants worth testing
1. Privilege fields
role, is_admin, permissions, scopes, is_staff, approved, verified.
2. Ownership and tenancy fields
tenant_id, organization_id, owner_id, account_id, created_by.
3. State and workflow fields
status, state, paid, refunded, shipped, published, deleted_at.
4. Financial fields
balance, credits, price, discount, plan, seats, billing_email.
5. Security fields
password_hash, mfa_enabled, email_verified, api_key, recovery_codes.
6. Read-only and system fields
id, created_at, updated_at, version, external_id.
7. Nested and array variants
{ "profile": { "role": "admin" }, "memberships": [{ "tenant_id": "other-tenant", "role": "owner" }] } 8. Content-type variants
Some frameworks bind differently for application/json, form data, multipart, and XML. Test each parser your API accepts.
9. HTTP method variants
An endpoint may be safe on POST and vulnerable on PATCH or PUT, or vice versa — especially if they were written at different times.
10. The read side
Mass assignment has a mirror image: excessive data exposure. The endpoint returns the full model, including fields the caller should not see.
{ "id": 42, "name": "Alice", "email": "alice@example.com", "role": "user", "password_hash": "$2b$12...", "internal_notes": "VIP, escalate to sales", "stripe_customer_id": "cus_..." } The frontend may render only name, but the API response is the actual boundary.
How to test for it
Step 1. Get a valid object
Create a user, order, invoice, or project with a known state. Record every field in the response.
Step 2. Send the extra fields
For every write endpoint, send a body containing:
- all legitimate fields,
- every sensitive field you can infer from the response, docs, or naming conventions,
- a few plausible fields that should not exist (
is_admin,tenant_id,credits).
Step 3. Read it back
The write may silently ignore unknown fields — or it may persist them. Fetch the object and check:
- did the value change?
- did a new field appear?
- did a nested object change?
- did a related object change?
Step 4. Test negative and type-confused values
"role": ["admin"], "is_admin": "true", "credits": -100, "status": null. Some frameworks coerce types in surprising ways.
Step 5. Test across roles and tenants
A regular user updating their own profile should not be able to set tenant_id. An admin of tenant A should not be able to set tenant_id to tenant B.
Step 6. Automate it
Generate the payload from the object's own response plus a list of sensitive field names. This is easy to automate and catches regressions when models change.
How to fix it
1. Separate the API contract from the database model
Define explicit input models:
class UpdateProfileRequest(BaseModel): display_name: str | None = None avatar_url: str | None = None locale: str | None = None model_config = ConfigDict(extra="forbid") Only these fields can be written. role, tenant_id, and is_verified are not in the model, so they cannot be set through this endpoint.
2. Allowlist explicitly, never blocklist
Blocklists fail because new sensitive fields are added over time. Allowlists fail closed: a field that is not listed is rejected.
3. Reject unknown fields
extra="forbid" in Pydantic, additionalProperties: false in JSON Schema, strict DTOs in TypeScript, strong parameters in Rails. Rejecting unknown fields turns a silent write into a loud 422, which is exactly what you want during development.
4. Handle nested objects explicitly
Each nested object gets its own input model with its own allowlist. Do not pass a raw dictionary through to the ORM.
5. Use explicit response models
class PublicUser(BaseModel): id: int display_name: str avatar_url: str | None Serialize only what the caller is allowed to see. Never return the ORM object directly.
6. Enforce authorization for sensitive fields
Some fields are legitimately writable by some roles. role may be writable by an admin, but not by the user themselves. Model this as a separate endpoint or an explicit policy check — not as an optional field in a general-purpose update.
7. Version the contract
If you need to add a writable field, add it to the input model deliberately, with tests. The default for new model fields should be "not writable through the API."
Framework notes
FastAPI / Pydantic: use separate request and response models, extra="forbid", and never pass request.json() into setattr.
Django REST Framework: use serializers with explicit fields, avoid fields = "__all__", and mark sensitive fields read_only=True.
Rails: use strong parameters (require/permit) and avoid update(params[:user]).
Node / Express: validate with a schema (Zod, Joi, Yup) and construct the update object explicitly; never spread req.body into a model.
Go: decode into a dedicated request struct, not the database struct, and reject unknown fields with DisallowUnknownFields.
The pattern is the same everywhere: the shape of the request is a security boundary, not a convenience.
Where continuous testing fits
Mass assignment regressions are subtle: a new field is added to the model, a serializer switches to fields = "__all__", a nested object is passed through to the ORM, or a new endpoint reuses a generic update helper. The API still returns 200 OK, and the only signal is a field that should not have changed.
LiveAPISec tests for this as part of its OWASP API Top 10 coverage: mass_assignment, nested_mass_assign, and query-level assignment checks send fields the API was never meant to accept and verify what actually changed. Because scans are repeatable and compared over time, a new writable field shows up as a diff rather than as a surprise in an incident review.
pipx install liveapisec liveapisec scan --help Checklist
Testing
- [ ] Send sensitive and unknown fields to every write endpoint.
- [ ] Test nested objects, arrays, and each supported content type.
- [ ] Test
POST,PATCH, andPUTseparately. - [ ] Read the object back and verify what changed.
- [ ] Inspect responses for fields the caller should not see.
- [ ] Test cross-role and cross-tenant field writes.
Fixing
- [ ] Separate input DTOs from database models.
- [ ] Allowlist writable fields; reject unknown ones.
- [ ] Define explicit response models.
- [ ] Give sensitive fields dedicated endpoints with policy checks.
- [ ] Add regression tests for privilege, tenancy, and billing fields.
- [ ] Review new model fields for accidental API exposure.
Key takeaways
- Mass assignment is not an exotic bug; it is the default behavior of many "bind and save" implementations.
- Any field in the model that is not explicitly excluded is a potential write primitive.
- Frontend filtering is irrelevant — the attacker controls the request.
- Fix it with explicit input models, allowlists, and
extra="forbid"-style validation. - The read side matters too: explicit response models prevent excessive data exposure.
- Test with fields the API was never designed to accept. That is where the bugs are.