Mass Assignment: How Attackers Use Your Own API to Escalate Privileges

Mass assignment vulnerabilities let attackers modify object properties they were never meant to touch — including role, isAdmin, and account balance. How the attack works, how frameworks enable it by default, and how to fix it in Node.js and Python.

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.