A user edits their display name. The browser sends {"display_name": "Bobby"}. An attacker sends {"display_name": "Bobby", "role": "admin"} to the same endpoint, and the server saves both, because the handler copies every field in the request onto the user record. That is a mass assignment vulnerability. It needs no special tooling, only a guess at a field name, and it turns an ordinary profile form into a privilege-escalation endpoint.
Scope. Based on the OWASP Mass Assignment Cheat Sheet and the OWASP API Security Top 10 2023 entry for API3 (both read from OWASP’s GitHub repositories), the Spring Framework data-binding documentation and the Rails Action Controller guide (main branches, read October 22, 2026). The Python example was run with Python 3.13, Flask 3.1.3 and Pydantic 2.13.5 using Flask’s test client. The Spring and Rails snippets are illustrative and were not run.
How it happens
Many frameworks bind request data onto objects automatically. OWASP’s cheat sheet lists the names the weakness goes by: mass assignment in Ruby on Rails and Node.js, autobinding in Spring MVC and ASP.NET MVC, object injection in PHP. The underlying weakness is CWE-915, improperly controlled modification of dynamically determined object attributes.
The risk appears when the object you bind to has more properties than the caller should control: an ORM entity with role, is_verified, org_id or balance. The client never sees those fields in the UI, but OWASP points out that field names can be guessed or discovered and source code access is not needed. API responses often help, because they return the full object, field names included.
OWASP’s cheat sheet cites GitHub’s 2012 incident as a case study: a user was able to add his public key to an organization he didn’t belong to and push changes to its repositories.
In the 2023 OWASP API Security Top 10, mass assignment is part of API3:2023 Broken Object Property Level Authorization. An endpoint is vulnerable if it lets a user read properties they shouldn’t (formerly “Excessive Data Exposure”) or change, add or delete properties they shouldn’t (formerly “Mass Assignment”). OWASP rates exploitability as easy and notes that fuzzing is commonly used to find hidden properties.
A tested example in Python
The vulnerable version writes every key in the JSON body onto the stored user:
from flask import Flask, request, jsonify, g
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, Field
app = Flask(__name__)
USERS = {1: {"id": 1, "email": "[email protected]", "display_name": "Bob",
"role": "member", "plan": "free", "org_id": 7}}
@app.before_request
def fake_auth():
g.user_id = 1 # stand-in for real authentication
# Vulnerable: every JSON key the client sends is written to the record.
@app.patch("/v1/me")
def update_me_vulnerable():
USERS[g.user_id].update(request.get_json())
return jsonify(USERS[g.user_id])
The fixed version defines what a user may change, and rejects anything else:
# Fixed: a dedicated input model lists the only fields a user may change,
# and rejects anything else instead of silently ignoring it.
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr | None = None
display_name: str | None = Field(default=None, max_length=80)
@app.patch("/v2/me")
def update_me_fixed():
try:
changes = ProfileUpdate.model_validate(request.get_json())
except ValidationError as e:
return jsonify(error="invalid fields",
fields=[".".join(map(str, x["loc"])) for x in e.errors()]), 422
USERS[g.user_id].update(changes.model_dump(exclude_unset=True))
return jsonify(USERS[g.user_id])
We sent {"display_name": "Bobby", "role": "admin", "plan": "enterprise"} to each endpoint with Flask’s test client:
v1 200 {'display_name': 'Bobby', 'email': '[email protected]', 'id': 1, 'org_id': 7, 'plan': 'enterprise', 'role': 'admin'}
v2 422 {'error': 'invalid fields', 'fields': ['plan', 'role']}
v2 200 {'display_name': 'Bobby', 'email': '[email protected]', 'id': 1, 'org_id': 7, 'plan': 'free', 'role': 'member'}
The last line is a legitimate request with only display_name, which the fixed endpoint accepts. exclude_unset=True means fields the client didn’t send are left alone, so a partial update doesn’t blank the email. Note that a client that explicitly sends "display_name": null will clear it; add a validator if that should not be allowed. EmailStr needs Pydantic’s optional email extra installed.
This is a demonstration, not production code: authentication is faked and storage is a dictionary.
The same idea in other frameworks
Spring
The Spring documentation’s guidance for safe binding is, in order: prefer immutable objects (records or constructor binding), or dedicated objects designed for the expected input. It says domain objects such as JPA or Hibernate entities “are generally not safe for web binding”, and that if you must bind to them you should declare the allowed properties:
@InitBinder
void initBinder(WebDataBinder binder) {
binder.setAllowedFields("firstName", "lastName", "*Address");
}
Spring also offers setDeclarativeBinding(true), which applies constructor binding always and setter binding only when allowedFields is set. The current documentation says disallowedFields, the blocklist approach, has been deprecated in Spring Framework 7.1 “because it is fragile and easy to get out of sync with the actual properties over time”.
A note from us: @InitBinder configures Spring’s data binder, which handles form and query parameters bound to @ModelAttribute arguments. JSON request bodies read with @RequestBody are converted by an HTTP message converter instead, so for JSON APIs the dedicated input class is the control that matters.
Rails
Strong parameters are on by default: the Rails guide says passing unpermitted parameters to mass assignment raises ActiveModel::ForbiddenAttributesError. Rails 8.0 added params.expect, which the guide calls “the safest and most explicit way to permit parameters”:
def user_params
params.expect(user: [:display_name, :email])
end
The guide warns against params.expect(user: {}), which permits every current and future attribute under that key.
Node.js
OWASP’s cheat sheet shows the allowlist approach with a pick function, copying only named fields from req.body before constructing the model. A schema validator that rejects unknown keys does the same job.
Mistakes that reintroduce the bug
- Blocklisting. Excluding
roleworks until someone addsis_staff. OWASP and Spring both recommend allowlists. - One input model for every caller. An admin endpoint may legitimately set
role; a self-service endpoint must not. Use separate input models, or decide per field based on who is calling. - Nested objects. Binding
{"profile": {...}, "account": {"owner_id": 2}}onto an object graph can reach properties several levels down. OWASP’s cheat sheet tells you to review nested objects; Spring’s allowed-field patterns are property paths for the same reason. - Update paths that bypass the model. A bulk-update job, a GraphQL mutation or an internal admin API that writes the entity directly needs the same allowlist.
- Treating field allowlists as authorization. OWASP says DTOs and allowlists “do not replace” authorization for the requested object and operation. The IDOR guide covers the object-level check, and broken access control covers the wider class.
How to test for it
- For each write endpoint, compare the request the UI sends with the object the API returns. Every property in the response that isn’t in the request is a candidate.
- Send each candidate back in the request, one at a time:
role,is_admin,plan,org_id,owner_id,verified,balance,created_at. - Check the stored result, not just the response code. Some vulnerable endpoints return 200 and the unchanged object, then persist the field anyway, or the reverse. OWASP notes that side-effect analysis may be needed when the property isn’t returned.
- Add regression tests like the one above, so a refactor that switches back to direct binding fails CI.
Mass assignment is one of the items in our list of common API security misconfigurations, and the response-filtering half of API3 belongs in our public REST API checklist.
Limits of this article
Only the Python example was run. Framework behavior depends on version and configuration; check your framework’s documentation for the version you use. We did not independently verify the 2012 GitHub incident beyond OWASP’s citation.
Sources and verification
Checked on October 22, 2026.
| Important claim | Source | Verification |
|---|---|---|
| Definition, alternative names, CWE-915, exploitability without source code, GitHub 2012 case, allowlist over blocklist, DTOs don’t replace authorization, review nested objects | OWASP Mass Assignment Cheat Sheet | Verified |
| API3:2023 combines excessive data exposure and mass assignment; exploitability easy; fuzzing and side-effect analysis | OWASP API Security Top 10 2023: API3 | Verified |
| Spring guidance: immutable or dedicated objects; entities generally unsafe; setAllowedFields; declarativeBinding; disallowedFields deprecated in 7.1 | Spring Framework: Data Binding, Model Design (main-branch documentation source) | Verified; the 7.1 deprecation is stated in current docs |
| Rails ForbiddenAttributesError; params.expect introduced in Rails 8.0; warning on expect with an empty hash | Rails guide: Action Controller Overview and the Rails 8.0 Action Pack changelog | Verified |
| Python example output | Run by us on October 22, 2026 (Python 3.13, Flask 3.1.3, Pydantic 2.13.5) | Verified |
| @RequestBody JSON is not handled by the data binder | Our note | Qualified |
Frequently asked questions
Is mass assignment the same as IDOR?
No. IDOR (insecure direct object reference) is about which object you can access, for example someone else's account by changing an ID. Mass assignment is about which properties of an object you can write. OWASP's API Top 10 tracks them separately, as API1:2023 (object level) and API3:2023 (property level), and a single endpoint can have both.
Should unknown fields be ignored or rejected?
Either stops the attack if the model is an allowlist. Rejecting them, as Pydantic does with extra='forbid', makes probing visible in logs and catches client bugs early. Ignoring them is friendlier to older clients that send extra fields. Pick one deliberately; don't rely on a framework default you haven't checked.
What changed in Rails 8 for strong parameters?
Rails 8.0 added params.expect, which the Rails guide calls the safest and most explicit way to permit parameters. params.expect(user: [:name, :email]) replaces params.require(:user).permit(:name, :email). The guide warns that expect with an empty hash permits all current and future attributes.
