Mass assignment

Mass assignment occurs when request body fields are bound directly to a data model without an explicit allowlist of which fields a caller is permitted to set. The result is that a caller who knows (or guesses) an internal field name can write to it.

The fields most commonly exploited are privilege fields (role, is_admin, is_verified), financial fields (balance, credits, plan), and status fields (email_verified, account_locked). None of these are fields a regular caller would be documented as being able to set, but if the binding is unrestricted, the documentation and the behaviour disagree.

Separate input models from data models

The most reliable defence is a Pydantic model that declares exactly which fields the caller is permitted to provide. Any field not in the model is either rejected or silently ignored, depending on configuration:

from pydantic import BaseModel, ConfigDict, EmailStr

class UserUpdateRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")  # reject unknown fields rather than ignoring

    name: str
    email: EmailStr

extra="forbid" causes Pydantic to raise a ValidationError if the request body includes any field not declared in the model. The alternative, extra="ignore" (the default), silently drops unknown fields, safer than an unrestricted ORM bind, but the rejection behaviour is worth preferring: a legitimate client that sends unexpected fields gets actionable feedback, and an attacker gets no more information than from a silent ignore.

A separate model for admin operations can include additional fields, applied only after a role check:

class AdminUserUpdateRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    email: EmailStr
    role: str
    is_verified: bool

The endpoint selects which model to apply based on the caller’s role, not on the contents of the request.

Django REST Framework

DRF serialisers provide equivalent control via fields and read_only_fields:

class UserUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["name", "email"]
        read_only_fields = ["role", "balance", "is_verified"]

Fields not in fields are not processed from input. read_only_fields provides a secondary signal: those fields appear in serialised output but are ignored on write. The tighter control is fields: restricting to an explicit list is clearer than a combination of inclusion and read-only exclusion.

SQLAlchemy direct update

If updating model attributes directly rather than via an input model, update only the specific fields rather than merging the request body dictionary onto the model:

# unsafe: any key in the dict is applied to the model
for key, value in request.get_json().items():
    setattr(user, key, value)

# safe: explicit per-field assignment
user.name = body.name
user.email = body.email
db.session.commit()

The loop pattern is the direct ORM equivalent of mass assignment: it applies whatever the caller sends.

Testing

Sending a request with additional fields (role, is_admin, balance) and then reading the resource confirms whether the endpoint enforces field restrictions. A 200 response where the sensitive field value changed is the finding; a 400 or a 200 that left the field unchanged are both acceptable outcomes.