How does mass assignment work?
Mass assignment happens when the server binds the whole request body to a model in one call, like User.update(req.body), and the model has fields the client was never meant to set. The attacker reads the model's fields from a response and sends them back with new values.
A profile form only offers a display name. The API response gives away the rest of the object:
GET /v1/users/me HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>HTTP/1.1 200 OK
Content-Type: application/json
{"id": "user_a", "display_name": "demo", "role": "member", "plan": "free"}The update request adds two fields the form never sent:
PATCH /v1/users/me HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>
Content-Type: application/json
{"display_name": "demo", "role": "admin", "plan": "enterprise"}HTTP/1.1 200 OK
Content-Type: application/json
{"id": "user_a", "display_name": "demo", "role": "admin", "plan": "enterprise"}The response echoes the new role and plan. The handler was probably one line, await user.update(req.body), and the ORM wrote every key that matched a column.
Where does mass assignment fit in the OWASP API Top 10?
In the 2023 edition it is part of API3:2023 Broken Object Property Level Authorization (BOPLA). That entry merges two 2019 categories, API3:2019 Excessive Data Exposure and API6:2019 Mass Assignment, because both are the same missing check: whether this caller may read or write this property of an object.
| Direction | 2019 name | Example |
|---|---|---|
| Read | Excessive Data Exposure | The response includes internal fields the UI hides |
| Write | Mass Assignment | The request sets internal fields the UI never offers |
The two usually appear together. The read side leaks the field names that the write side then abuses, as in the example above. OWASP's API3 scenarios include a marketplace host changing a total_stay_price property and a video platform user flipping a blocked flag.
BOPLA sits between the other two authorization failures. BOLA is access to the wrong object. BFLA is access to the wrong function. Mass assignment is write access to the wrong property of an object you are allowed to edit.
What does it look like in practice?
The best-known public case is GitHub in March 2012. A user exploited the public key update form to add his key to the Rails organization account, and GitHub's post-incident write-up names the root cause as a failure to check incoming form parameters, "a problem known as the mass-assignment vulnerability." NVD records it as CVE-2012-2055: the form accepted a public_key[user_id] value, so the key could be assigned to another user.
Common targets:
- Privilege fields:
role,is_admin,permissions,groups. - Ownership fields:
owner_id,org_id,tenant_id. Setting these moves an object into another tenant, which turns mass assignment into a privilege escalation or a cross-tenant write. - Commercial state:
plan,credits,trial_ends_at,discount. These are business logic impacts delivered through a binding bug. - Workflow state:
status,approved,verified,email_verified. - Nested objects:
{"profile": {"org": {"plan": "enterprise"}}}when the framework follows nested attributes into related models.
How do you test for mass assignment?
Collect every property the API ever returns for an object, then try to write each one back. A property that changes when it should not is a finding.
- Harvest field names. Read GET responses, list endpoints, the OpenAPI spec, GraphQL input types, JS bundles and error messages. Note fields the UI never lets you edit.
- Find every write path. Create (
POST), full update (PUT), partial update (PATCH), and side routes such as signup, invite acceptance and profile import. Signup is a frequent miss because it runs before any role exists. - Add one extra field at a time to a legitimate request. One at a time tells you exactly which field was accepted.
- Check the effect, not just the echo. Some APIs echo the request body without saving it. Re-fetch the object, or check whether the new role unlocks an admin route.
- Try naming variants:
isAdmin,is_admin,admin, nested forms likeuser[role], and JSON versus form-encoded bodies. - Try the read side. Compare each response to what the UI displays. Extra internal fields are the Excessive Data Exposure half of BOPLA and belong in the report too.
How do you fix mass assignment?
Allowlist the fields each endpoint accepts, per role, and bind only those. Denylists fail the first time someone adds a new column.
Django REST Framework, with explicit fields and read-only internals. The DRF docs strongly recommend listing fields explicitly over "__all__":
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "display_name", "role", "plan"]
read_only_fields = ["id", "role", "plan"]Express with a plain allowlist, before anything reaches the ORM:
const EDITABLE = ["display_name"];
app.patch("/v1/users/me", requireAuth, async (req, res) => {
const changes = Object.fromEntries(
Object.entries(req.body).filter(([key]) => EDITABLE.includes(key))
);
const user = await User.findByIdAndUpdate(req.user.id, changes, { new: true });
res.json({ id: user.id, display_name: user.display_name });
});Mongoose's strict schema option, on by default, drops keys not in the schema. It does not help when role is in the schema, which is the usual case, so it complements an allowlist and never replaces one. Set strict: "throw" in development to surface unexpected fields as errors.
Rails 8 adds params.expect, which requires and permits in one step and rejects parameters that don't match the expected shape:
# Rails 8+
def user_params
params.expect(user: [:display_name])
end
# Older Rails
def user_params
params.require(:user).permit(:display_name)
endWhat holds across stacks:
- Use a separate input type per endpoint and role. An admin update DTO and a member update DTO are different classes.
- Set ownership and role from the session, never from the body.
- Shape responses explicitly so internal fields never leak their names.
A WAF does not catch this: "role": "admin" in a JSON body is a valid request.
[ Sources ]
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- NVD: CVE-2012-2055 (GitHub public key form mass assignment)
- GitHub Blog: Public key security vulnerability and mitigation (March 2012)
- Django REST Framework: Serializers
- Ruby on Rails 8.0 release notes: params.expect
Written by Parameter · Last reviewed

