Mass assignment is a web application vulnerability that lets an attacker modify properties of a server-side object by including extra fields in a request body that the developer didn’t intend to be user-settable. The result is typically privilege escalation, price manipulation, status change, or account takeover — achieved through nothing more exotic than adding a field to a JSON payload.
The OWASP API Security Top 10 (2023) classifies this under API3:2023 — Broken Object Property Level Authorization: the API exposes object properties that should not be directly modifiable by the client.
How Mass Assignment Works
Consider a user profile update endpoint that accepts a JSON body:
PATCH /api/v1/users/profile
Content-Type: application/json
Authorization: Bearer <token>
{
"displayName": "Alice",
"email": "[email protected]"
}
On the server side, the developer uses an ORM or object mapping library to update the user record directly from the request body:
// Express + Mongoose — vulnerable
app.patch('/api/v1/users/profile', auth, async (req, res) => {
const user = await User.findById(req.user.id);
Object.assign(user, req.body); // All fields from body applied to user object
await user.save();
res.json(user);
});
The User model has a schema that includes fields the user should never modify directly:
const userSchema = new mongoose.Schema({
displayName: String,
email: String,
role: { type: String, default: 'user' }, // 'user' or 'admin'
isVerified: { type: Boolean, default: false },
credits: { type: Number, default: 0 }
});
An attacker who inspects the API response or reads API documentation notices additional fields on user objects. They craft a request:
PATCH /api/v1/users/profile
Content-Type: application/json
{
"displayName": "Alice",
"email": "[email protected]",
"role": "admin",
"credits": 999999
}
If the server applies all fields from req.body without filtering, the attacker has just given themselves admin privileges and a large credit balance.
Why Frameworks Make This Easy to Get Wrong
The underlying problem is that web frameworks and ORMs are designed for developer convenience — and the most convenient way to update a database record from a form submission is to pass the entire request body directly.
Node.js / Express
The pattern Object.assign(model, req.body) or Mongoose’s findByIdAndUpdate(id, req.body) applies all request fields. Mongoose’s .set() method on a model has the same behaviour.
Python / FastAPI with SQLModel
# Vulnerable FastAPI pattern
@router.patch("/users/me")
async def update_user(updates: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
for field, value in updates.items():
setattr(current_user, field, value) # No field filtering
db.commit()
return current_user
Django REST Framework
# Vulnerable DRF serializer — all fields writable
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__' # This is the problem
Fixes
Node.js: Explicit Field Allowlist
// Safe pattern — allowlist permitted fields
const PERMITTED_PROFILE_FIELDS = ['displayName', 'email', 'bio', 'avatarUrl'];
app.patch('/api/v1/users/profile', auth, async (req, res) => {
const updates = Object.fromEntries(
Object.entries(req.body).filter(([key]) => PERMITTED_PROFILE_FIELDS.includes(key))
);
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
const user = await User.findByIdAndUpdate(
req.user.id,
{ $set: updates },
{ new: true, runValidators: true }
);
res.json(user);
});
Python / Pydantic (FastAPI)
Define separate input schemas for each operation that include only the fields the user is permitted to set:
from pydantic import BaseModel
from typing import Optional
# Input schema — only contains user-settable fields
class UserProfileUpdate(BaseModel):
display_name: Optional[str] = None
email: Optional[str] = None
bio: Optional[str] = None
class Config:
extra = "forbid" # Reject any fields not in the schema
# Response schema — includes all fields returned to client
class UserResponse(BaseModel):
id: int
display_name: str
email: str
bio: Optional[str]
role: str
is_verified: bool
credits: int
@router.patch("/users/me", response_model=UserResponse)
async def update_profile(
updates: UserProfileUpdate, # Validated input — no role/credits/isVerified
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
update_data = updates.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
db.commit()
db.refresh(current_user)
return current_user
The extra = "forbid" config causes Pydantic to reject requests with unexpected fields, returning a 422 error rather than silently ignoring extra data.
Django REST Framework: Explicit Field Lists
# Safe DRF serializer
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['display_name', 'email', 'bio'] # Explicit allowlist, not __all__
read_only_fields = [] # Empty — all listed fields are writable
class UserProfileUpdateView(generics.UpdateAPIView):
serializer_class = UserProfileSerializer
permission_classes = [IsAuthenticated]
def get_object(self):
return self.request.user
Never use fields = '__all__' on a serializer that accepts user input. Always define explicit field lists.
Finding Mass Assignment in Code Review
Patterns to flag in code review:
// JavaScript — flag these
Object.assign(model, req.body)
Model.create(req.body)
Model.findByIdAndUpdate(id, req.body)
model.update(req.body)
# Python — flag these
for key, value in request.data.items():
setattr(model, key, value)
Model.objects.filter(id=id).update(**request.data)
# DRF — flag this
fields = '__all__'
Testing for Mass Assignment
A quick manual test: make a normal update request, then replay it with extra fields appended:
# Normal request
curl -X PATCH https://api.example.com/users/profile \
-H "Authorization: Bearer $TOKEN" \
-d '{"displayName": "Test"}'
# Mass assignment probe
curl -X PATCH https://api.example.com/users/profile \
-H "Authorization: Bearer $TOKEN" \
-d '{"displayName": "Test", "role": "admin", "isAdmin": true, "verified": true}'
If the response shows "role": "admin" or "isAdmin": true, the endpoint is vulnerable. If the extra fields are silently ignored in the response, verify that they weren’t applied by fetching the user profile separately.
Automated tools: Burp Suite’s active scanner will detect basic mass assignment in many cases. OWASP ZAP’s fuzzing rules include mass assignment probes.
The fix is always the same: define exactly what each endpoint accepts, and reject or ignore everything else. Convenience APIs that accept arbitrary field maps are a liability.