Auto-binding a request body to an object model without an allowlist hands an attacker control of every field in that object - not just the ones you intended.
Mass assignment happens when a framework's auto-binding layer maps every key in an incoming request body directly to the model it hydrates - not just the keys you intended to expose. The application publishes an API for price and quantity; the framework also binds is_admin, unit_cost and internal_notes, because those fields exist on the same object and the binding layer sees no reason to stop. The resulting attack surface is invisible to a source-code read: the field is legitimately present in the model, just never meant to be writable from outside.
Most web frameworks - Rails strong parameters, Spring's @ModelAttribute, Django REST Framework's ModelSerializer, Laravel's Eloquent fill(), FastAPI model binding - offer some form of automatic hydration. They read the incoming JSON or form body and set matching properties on a database-bound object. The developer thinks they are exposing { quantity, delivery_note }; the framework binds every key it recognises on the model unless explicitly told not to.
The attack requires no SQL injection, no authentication bypass and no novel technique. It is a plain HTTP request with extra keys. The only prerequisite is knowledge of a field name - and those are often visible in API responses, admin UIs, mobile app decompiles, or JavaScript bundles.
Confirming mass assignment at runtime requires a differential read. Send a request that writes a value to a field outside the documented parameter list, then read the object back and check whether the value persisted. If it did, the field is bindable from outside, regardless of what the documentation says. The probe uses a safe, detectable nonce value so the effect is obvious and reversible - not a real privilege escalation and not a destructive write.
# Step 1 - baseline: read the current object to capture existing field values GET /api/users/42 HTTP/1.1 200 OK {"id":42, "name":"alice", "role":"user", "credit_balance":100.00} # Step 2 - inject: send an undocumented field in a normal update request PATCH /api/users/42 Content-Type: application/json {"name":"alice", "credit_balance":62615533} HTTP/1.1 200 OK # or 204 - response alone does not confirm # Step 3 - verify: read back and check whether the nonce persisted GET /api/users/42 HTTP/1.1 200 OK {"id":42, "name":"alice", "role":"user", "credit_balance":62615533} # confirmed: field was accepted and persisted
The nonce 62615533 (7919 x 7907) makes the result unambiguous: if the read-back returns that exact value, the field was bound, not silently ignored. After the probe the value is reset to the baseline captured in step 1 - the object is returned to its original state and no lasting change remains.
The classic case is a plain JSON body on a REST update endpoint, but the same binding logic appears in forms that produce nested objects, JSON Merge Patch requests (RFC 7386), GraphQL input types, and multipart uploads parsed into model objects. Each surface uses the same auto-bind path; the probe shape adapts but the confirming oracle does not change.
| Surface | Common vulnerable pattern | Probe adaptation | Typical bindable field |
|---|---|---|---|
| REST JSON body | POST/PUT/PATCH with ORM create() or update() | Extra key in JSON object | role, is_admin, price, status |
| JSON Merge Patch | PATCH with Content-Type: application/merge-patch+json | Extra key in merge document | account_tier, verified, locked |
| GraphQL input | Mutation input type without explicit field restriction | Extra field in input argument | internalNote, approvedBy, costCentre |
| Nested objects | address.country bound alongside address.is_primary | Extra key inside nested object | is_primary, billing_override, verified |
GraphQL deserves specific attention: input types in most frameworks default to accepting any field defined on the backing model unless the input type is explicitly allowlisted. A mutation that updates a user's display name may silently bind every other mutable field in the same model if the UserInput type was auto-generated from the ORM layer. The confirming oracle is the same - inject a detectable nonce value into an undocumented field and read it back in the mutation response or a subsequent query.
Mass assignment is not a single severity. The mechanism is the same across all cases; what changes is the impact of controlling that particular field. A user who can set their own credit balance is a financial control bypass. A user who can set is_admin: true on their own account has privilege escalation to full system access. A user who can zero out a product price has a revenue integrity breach. Each confirmed binding is rated at the impact the field enables, not at the class label.
The fix is consistent across frameworks and surfaces: allowlisting, not denylisting. In Rails, use strong parameters to enumerate every permitted key explicitly. In Spring, use @JsonIgnoreProperties or a dedicated DTO that maps only the intended fields. In Django REST Framework, enumerate fields on the serializer rather than using fields = '__all__'. In FastAPI, define an input model that contains only the fields an external caller should control, separate from the full database model. A denylist of sensitive fields is brittle: adding a new sensitive field to the model does not automatically add it to the denylist, and the binding layer picks it up the moment the field exists. An allowlist breaks safely: adding a field to the model does not expose it until the allowlist is explicitly updated.