Mass Assignment: Code Review Guide
Table of Contents
1. How Mass Assignment Works
Mass assignment (also called auto-binding or object injection) occurs when an application automatically binds HTTP request parameters to internal object properties without filtering which fields the user is allowed to set. Attackers exploit this by adding extra parameters to requests — modifying fields they were never intended to control.
OWASP API Security Top 10
Mass Assignment is API6:2023 in the OWASP API Security Top 10. It is particularly dangerous in REST APIs and modern web frameworks that encourage automatic model binding for developer convenience. The convenience of User.create(req.body) comes at a steep security cost when fields like role, is_admin, or balance are writable.
The Core Problem
1// A simple user profile update endpoint
2// The form only shows "name" and "email" fields
3
4// ❌ VULNERABLE: Passes the entire request body to the ORM
5app.put('/api/profile', async (req, res) => {
6 const user = await User.findById(req.user.id);
7 Object.assign(user, req.body); // Binds ALL fields from request
8 await user.save();
9 res.json(user);
10});
11
12// Normal request:
13// PUT /api/profile
14// { "name": "Alice", "email": "alice@example.com" }
15
16// Attacker's request:
17// PUT /api/profile
18// { "name": "Alice", "role": "admin", "balance": 99999 }
19// ↑ The attacker added "role" and "balance" — the server blindly accepts themThe vulnerability exists because the server trusts the structure of the incoming request. The HTML form may only show name and email, but an attacker can craft requests with any tool (curl, Burp Suite, browser DevTools) and include arbitrary fields. If the server-side code does not explicitly filter which fields to accept, all model properties are at risk.
Mass Assignment Attack Flow
How Mass Assignment Works
Commonly Targeted Fields
A developer argues: 'Our form only has name and email fields, so users can only submit those values.' Why is this reasoning flawed?
2. Vulnerable Code Patterns
Mass assignment vulnerabilities appear whenever request data flows directly into model creation or update operations. The common thread is the absence of an explicit allowlist specifying which fields the user may modify.
Node.js / Express: Vulnerable patterns
1// ❌ VULNERABLE: Spread operator passes all fields
2app.post('/api/users', async (req, res) => {
3 const user = await User.create({ ...req.body });
4 res.json(user);
5});
6
7// ❌ VULNERABLE: Object.assign merges all request fields
8app.put('/api/users/:id', async (req, res) => {
9 const user = await User.findById(req.params.id);
10 Object.assign(user, req.body);
11 await user.save();
12 res.json(user);
13});
14
15// ❌ VULNERABLE: Mongoose .set() with full body
16app.patch('/api/users/:id', async (req, res) => {
17 const user = await User.findByIdAndUpdate(
18 req.params.id,
19 req.body, // All fields from request become update operations
20 { new: true }
21 );
22 res.json(user);
23});
24
25// ❌ VULNERABLE: Sequelize bulk create
26app.post('/api/users/bulk', async (req, res) => {
27 const users = await User.bulkCreate(req.body.users);
28 res.json(users);
29});
30
31// ❌ VULNERABLE: Prisma create with unchecked data
32app.post('/api/users', async (req, res) => {
33 const user = await prisma.user.create({
34 data: req.body, // Passes all request fields to Prisma
35 });
36 res.json(user);
37});Python / Django / Flask: Vulnerable patterns
1# ❌ VULNERABLE: Django — update with **kwargs from request
2def update_profile(request):
3 user = request.user
4 data = json.loads(request.body)
5 User.objects.filter(pk=user.pk).update(**data)
6 # Attacker: {"is_staff": true, "is_superuser": true}
7 return JsonResponse({"status": "updated"})
8
9# ❌ VULNERABLE: Django REST Framework — no field restriction
10class UserSerializer(serializers.ModelSerializer):
11 class Meta:
12 model = User
13 fields = '__all__' # Exposes and accepts ALL model fields
14
15# ❌ VULNERABLE: Flask + SQLAlchemy
16@app.route('/api/users', methods=['POST'])
17def create_user():
18 data = request.get_json()
19 user = User(**data) # Unpacks all JSON fields as constructor args
20 db.session.add(user)
21 db.session.commit()
22 return jsonify(user.to_dict())
23
24# ❌ VULNERABLE: Flask + Marshmallow without explicit fields
25class UserSchema(Schema):
26 class Meta:
27 fields = tuple(User.__table__.columns.keys())
28 # Auto-generates fields from ALL database columnsRuby on Rails: Vulnerable patterns
1# ❌ VULNERABLE: Directly using params hash (Rails < 4 style)
2def create
3 @user = User.new(params[:user])
4 @user.save
5end
6
7# ❌ VULNERABLE: permit! allows all parameters
8def update
9 @user = User.find(params[:id])
10 @user.update(params.require(:user).permit!)
11 # permit! disables Strong Parameters protection entirely
12end
13
14# ❌ VULNERABLE: Overly permissive Strong Parameters
15def user_params
16 params.require(:user).permit(
17 :name, :email, :role, :is_admin, :balance
18 # Developer included sensitive fields in the allowlist
19 )
20endJava / Spring Boot: Vulnerable patterns
1// ❌ VULNERABLE: Spring auto-binding from request
2@PostMapping("/users")
3public User createUser(User user) {
4 // Spring automatically binds ALL request parameters to User fields
5 // POST /users?name=Alice&role=ADMIN&active=true
6 return userRepository.save(user);
7}
8
9// ❌ VULNERABLE: Using @RequestBody with full entity
10@PutMapping("/users/{id}")
11public User updateUser(@PathVariable Long id, @RequestBody User userData) {
12 User user = userRepository.findById(id).orElseThrow();
13 BeanUtils.copyProperties(userData, user);
14 // Copies ALL non-null properties including role, permissions, etc.
15 return userRepository.save(user);
16}
17
18// ❌ VULNERABLE: Jackson deserialization into entity
19@PatchMapping("/users/{id}")
20public User patchUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) {
21 User user = userRepository.findById(id).orElseThrow();
22 ObjectMapper mapper = new ObjectMapper();
23 mapper.updateValue(user, updates);
24 // Any field in the map is written to the User object
25 return userRepository.save(user);
26}Which of these Express.js patterns is vulnerable to mass assignment?
3. Framework-Specific Sinks
Each framework and ORM has specific functions that are "sinks" for mass assignment — places where unfiltered request data can modify model properties. During code review, search for these patterns.
Mass Assignment Sinks by Framework
| Framework / ORM | Dangerous Sink | Why It Is Dangerous |
|---|---|---|
| Mongoose (Node.js) | Model.create(req.body), Model.findByIdAndUpdate(id, req.body) | Accepts any field present in the request body |
| Sequelize (Node.js) | Model.create(req.body), Model.update(req.body) | Auto-maps JSON keys to model columns |
| Prisma (Node.js) | prisma.model.create({ data: req.body }) | Passes all fields to the database layer |
| Django ORM | Model.objects.create(**request.data), .update(**data) | Keyword argument unpacking sets all fields |
| SQLAlchemy (Python) | Model(**data), db.session.merge(obj) | Constructor/merge accepts arbitrary attributes |
| Rails ActiveRecord | Model.new(params), .update(params.permit!) | permit! disables Strong Parameters |
| Spring Data JPA | repository.save(entity), BeanUtils.copyProperties() | Auto-binds request params to entity fields |
| Laravel Eloquent | Model::create($request->all()), ->fill($request->all()) | all() includes every request parameter |
JavaScript utilities that enable mass assignment
1// These JavaScript patterns are also mass assignment vectors
2// when used with unfiltered request data:
3
4// ❌ Object.assign — merges all properties
5Object.assign(user, req.body);
6
7// ❌ Spread operator — copies all properties
8const updatedUser = { ...user, ...req.body };
9
10// ❌ Lodash _.merge / _.assign — deep/shallow merge
11_.merge(user, req.body);
12_.assign(user, req.body);
13
14// ❌ for...in loop without hasOwnProperty check
15for (const key in req.body) {
16 user[key] = req.body[key];
17}
18
19// ❌ Object.keys iteration without filtering
20Object.keys(req.body).forEach(key => {
21 user[key] = req.body[key];
22});The Pattern to Remember
Mass assignment sinks share a common trait: they copy properties from an untrusted source to a trusted object without an explicit field filter. Whether it is Object.assign, **kwargs, BeanUtils.copyProperties, or params.permit!, the root cause is the same — trusting the shape of user input.
A developer uses Prisma: `prisma.user.update({ where: { id }, data: req.body })`. They say it's safe because Prisma validates types. Is this correct?
4. Detection During Code Review
Detecting mass assignment requires tracing request data from its source (HTTP request) to its sink (model creation/update). The key question is: between the source and sink, is there an explicit allowlist of permitted fields?
Grep patterns to find mass assignment sinks
1# Node.js / Express
2rg "Object\.assign\(.*req\." --type js --type ts
3rg "\.create\(.*req\.body" --type js --type ts
4rg "\.update\(.*req\.body" --type js --type ts
5rg "\.findByIdAndUpdate\(.*req\.body" --type js --type ts
6rg "\.\.\.(req\.body|request\.body)" --type js --type ts
7rg "data:\s*req\.body" --type js --type ts
8
9# Python / Django
10rg "\*\*request\.(data|POST|GET)" --type py
11rg "create\(\*\*" --type py
12rg "update\(\*\*" --type py
13rg "fields\s*=\s*'__all__'" --type py
14
15# Ruby / Rails
16rg "params\.permit!" --type ruby
17rg "\.new\(params" --type ruby
18rg "\.update\(params" --type ruby
19
20# Java / Spring
21rg "BeanUtils\.copyProperties" --type java
22rg "@RequestBody.*Entity" --type java
23rg "ObjectMapper.*updateValue" --type java
24
25# PHP / Laravel
26rg "\$request->all\(\)" --type php
27rg "::create\(\$request" --type php
28rg "->fill\(\$request" --type php- Trace every route handler that processes user input — For each POST, PUT, PATCH endpoint, check how request data reaches the database.
- Look for the "pass-through" pattern — Any code that passes
req.body,request.data, orparamsdirectly to a create/update operation is suspect. - Check serializers and DTOs — In Django REST Framework, look for
fields = '__all__'. In Spring, check if DTOs mirror entity classes without field restrictions. - Review model definitions for sensitive fields — Identify which model fields should never be user-writable: role, permissions, balance, verified status, internal IDs.
- Audit bulk operations —
bulkCreate,insertMany, and batch update operations are frequently overlooked and often pass through unfiltered data. - Check nested object handling — If your API accepts nested JSON like
{ "profile": { ... } }, verify that nested objects are also filtered. Attackers often target nested associations.
The Hidden Danger: Schema Changes
Mass assignment vulnerabilities can be introduced silently by schema migrations. If a developer adds a new sensitive field to a model (e.g., is_admin) but the endpoint already passes req.body directly to the ORM, the new field is instantly exploitable. This is why allowlists are critical — they fail-safe against future changes. A blocklist would need to be updated every time the schema changes.
Allowlist vs Blocklist: Why Allowlists Win
Blocklist (Dangerous)
New sensitive fields added later bypass the blocklist silently.
Allowlist (Secure)
Only explicitly permitted fields are accepted — safe by default.
During code review, you see: `User.objects.filter(pk=user_id).update(**serializer.validated_data)`. The serializer uses `fields = '__all__'`. Is this safe?
5. Prevention Strategies
The fundamental fix for mass assignment is simple: never pass unfiltered user input to model operations. Use an explicit allowlist to control which fields a request can modify.
Node.js / Express: Secure patterns
1// ✅ STRATEGY 1: Destructure only allowed fields
2app.put('/api/profile', async (req, res) => {
3 const { name, email, bio } = req.body;
4 const user = await User.findById(req.user.id);
5 await user.updateOne({ name, email, bio });
6 res.json(user);
7});
8
9// ✅ STRATEGY 2: Pick utility for allowlisting
10const pick = (obj, keys) =>
11 Object.fromEntries(keys.filter(k => k in obj).map(k => [k, obj[k]]));
12
13app.put('/api/profile', async (req, res) => {
14 const allowed = pick(req.body, ['name', 'email', 'bio']);
15 await User.findByIdAndUpdate(req.user.id, allowed);
16 res.json({ status: 'updated' });
17});
18
19// ✅ STRATEGY 3: Validation schema (Zod, Joi, etc.)
20import { z } from 'zod';
21
22const updateProfileSchema = z.object({
23 name: z.string().min(1).max(100),
24 email: z.string().email(),
25 bio: z.string().max(500).optional(),
26}).strict(); // .strict() rejects any extra fields
27
28app.put('/api/profile', async (req, res) => {
29 const data = updateProfileSchema.parse(req.body);
30 // 'data' only contains name, email, bio — nothing else
31 await User.findByIdAndUpdate(req.user.id, data);
32 res.json({ status: 'updated' });
33});
34
35// ✅ STRATEGY 4: Mongoose schema-level protection
36const userSchema = new mongoose.Schema({
37 name: String,
38 email: String,
39 bio: String,
40 role: { type: String, default: 'user', immutable: true },
41 // 'immutable: true' prevents this field from being modified after creation
42});Python / Django: Secure patterns
1# ✅ SECURE: Django REST Framework — explicit field list
2class UserUpdateSerializer(serializers.ModelSerializer):
3 class Meta:
4 model = User
5 fields = ['name', 'email', 'bio'] # Only these fields accepted
6 # Never use fields = '__all__' for write operations
7
8# ✅ SECURE: Django REST Framework — read_only_fields
9class UserSerializer(serializers.ModelSerializer):
10 class Meta:
11 model = User
12 fields = ['id', 'name', 'email', 'bio', 'role', 'is_active']
13 read_only_fields = ['id', 'role', 'is_active']
14
15# ✅ SECURE: Explicit field extraction in views
16def update_profile(request):
17 data = json.loads(request.body)
18 allowed_fields = {'name', 'email', 'bio'}
19 filtered = {k: v for k, v in data.items() if k in allowed_fields}
20 User.objects.filter(pk=request.user.pk).update(**filtered)
21 return JsonResponse({"status": "updated"})
22
23# ✅ SECURE: Pydantic models for API validation (FastAPI)
24from pydantic import BaseModel
25
26class UserUpdate(BaseModel):
27 name: str
28 email: str
29 bio: str | None = None
30 # Only declared fields are accepted; extras are rejected
31 class Config:
32 extra = 'forbid' # Raises error if extra fields are sentRuby on Rails: Strong Parameters (the gold standard)
1# ✅ SECURE: Rails Strong Parameters — explicit allowlist
2class UsersController < ApplicationController
3 def update
4 @user = User.find(params[:id])
5 @user.update(user_params)
6 end
7
8 private
9
10 def user_params
11 # Only name, email, and bio are permitted
12 params.require(:user).permit(:name, :email, :bio)
13 # Any extra params (role, is_admin, etc.) are silently dropped
14 end
15end
16
17# ✅ SECURE: Different allowlists for different roles
18def user_params
19 if current_user.admin?
20 params.require(:user).permit(:name, :email, :bio, :role, :active)
21 else
22 params.require(:user).permit(:name, :email, :bio)
23 end
24endJava / Spring Boot: DTO pattern
1// ✅ SECURE: Use a DTO (Data Transfer Object) instead of the entity
2public class UserUpdateDTO {
3 @NotBlank private String name;
4 @Email private String email;
5 @Size(max = 500) private String bio;
6 // Only these fields exist — no role, no permissions, no balance
7 // Getters and setters...
8}
9
10@PutMapping("/users/{id}")
11public UserResponse updateUser(
12 @PathVariable Long id,
13 @Valid @RequestBody UserUpdateDTO dto
14) {
15 User user = userRepository.findById(id).orElseThrow();
16 user.setName(dto.getName());
17 user.setEmail(dto.getEmail());
18 user.setBio(dto.getBio());
19 // Explicit field-by-field mapping — no mass assignment possible
20 return new UserResponse(userRepository.save(user));
21}
22
23// ✅ SECURE: Spring @InitBinder to block specific fields
24@InitBinder
25public void initBinder(WebDataBinder binder) {
26 binder.setDisallowedFields("id", "role", "permissions", "balance");
27 // Or better — use setAllowedFields for an allowlist:
28 // binder.setAllowedFields("name", "email", "bio");
29}The Prevention Hierarchy
- Best: Explicit field mapping — Manually set each field:
user.name = dto.name - Great: Validation schema with strict mode — Zod
.strict(), Pydanticextra="forbid" - Good: Framework allowlist — Rails Strong Parameters, DRF explicit fields
- Acceptable: Pick/filter utility —
pick(req.body, allowedFields) - Dangerous: Blocklist — Easy to forget new fields, fails open
- Worst: No filtering —
Model.create(req.body)
Which prevention approach is most resilient against future schema changes?