🎭 Mass Assignment: The Vulnerability Your ORM Ships By Default
Here's a fun exercise. Go find your PATCH /api/users/:id endpoint — the one that lets a logged-in user update their own profile — and go read the handler. Not the route. Not the tests. The actual code that takes the request body and does something with it. I'll wait.
If what you found looks roughly like this, we need to talk:
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const user = await User.findById(req.params.id);
Object.assign(user, req.body);
await user.save();
res.json(user);
});
That endpoint works beautifully for { "displayName": "Anuragh" }. It also works beautifully for { "role": "admin" }, { "isVerified": true }, and { "accountBalance": 999999 } — because Object.assign doesn't know the difference between a field you meant to expose and a field that just happens to live on the same row. This is mass assignment, and it's one of those bugs that's less "someone wrote bad code" and more "the framework's default behavior is the vulnerability, and you have to actively opt out of it to be safe."
The Attack Is Embarrassingly Simple
No SQL injection payloads, no clever bypass chains, no burp suite gymnastics required. Just read the API response you already got back from a legitimate request, notice a field you didn't send, and send it back yourself:
PATCH /api/users/42
Content-Type: application/json
{
"displayName": "Totally Normal User",
"role": "admin"
}
If the server-side model has a role column and the handler doesn't explicitly filter which fields it accepts, that second field rides along for free. This exact pattern — an attacker discovering an internal field name by inspecting a GET response, then replaying it on a write endpoint — is how mass assignment bugs get found in the wild, and it's exactly why it sits in the OWASP API Security Top 10 as its own named category ("Broken Object Property Level Authorization") separate from BOLA. BOLA is about which object you're allowed to touch; mass assignment is about which properties of an object you're allowed to touch once you're already, legitimately, in the door. It's the vulnerability that shows up precisely because your authorization for the endpoint itself is working correctly.
"But My ORM Has Validation"
This is the line I hear most often, and it's usually true and irrelevant at the same time. Validation checks that a field has the right type — is role a string, is accountBalance a number. It says nothing about whether the field should have been writable by this caller in the first place. A Joi schema that validates role: Joi.string() will happily validate "admin" as a perfectly well-formed string, then pass it straight through to your Object.assign. Type-checking and authorization are different concerns, and mass assignment lives entirely in the gap between them.
Every major ORM has been bitten by this at the framework level, not just at the app level. Rails shipped attr_accessible/strong_parameters specifically because mass assignment was such a recurring disaster in real apps. Laravel has $fillable and $guarded on every Eloquent model for the same reason. Django REST Framework serializers require you to explicitly list fields. These aren't optional nice-to-haves bolted on afterward — they exist because "bind the whole request body to the model" was the original default in most of these frameworks, and it kept quietly wrecking people.
The Fix Is an Allowlist, Not a Denylist
The instinct a lot of people reach for is "just block the dangerous fields":
const { role, isVerified, accountBalance, ...safeUpdates } = req.body;
Object.assign(user, safeUpdates);
Don't do this. It works today and breaks the day someone adds a new sensitive column and forgets to add it to the exclusion list — and nobody remembers to update a denylist, because forgetting is invisible until it's exploited. Flip it around and name only what's actually allowed to change:
const ALLOWED_FIELDS = ['displayName', 'bio', 'avatarUrl'];
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const user = await User.findById(req.params.id);
if (user.id !== req.user.id) return res.status(404).end();
for (const field of ALLOWED_FIELDS) {
if (field in req.body) user[field] = req.body[field];
}
await user.save();
res.json(user);
});
New sensitive column added to the table next quarter? It's excluded by default, because it was never on the allowlist to begin with. That's the entire philosophical shift: a denylist trusts you to remember every dangerous thing that could ever be added; an allowlist only trusts you to remember what's currently safe, and treats everything unknown as unsafe until proven otherwise. It's the same reasoning behind default-deny firewall rules, just applied one layer up the stack, and it's the difference between a bug that gets found in a pentest report and one that gets found in a breach disclosure.
At Cubet, we ended up baking this into our API code review checklist as a single explicit question for every new write endpoint: "if I diffed the request body against the DB schema, is there anything on that model that would be genuinely bad to let a normal caller set?" It sounds almost too simple to be useful, but asking it out loud catches the Object.assign(user, req.body) pattern before it ships about every single time — because the bug only survives when nobody actually looks.
The One-Line Takeaway
If your update endpoint can be described as "take the request body and merge it onto the model," you don't have an update endpoint — you have a public write API for every column in that table, gated only by whatever the attacker happens to guess the field is called. Name your fields. Every time.
Found a mass assignment bug lurking in your own API, or think allowlists are overkill for internal tools? Come argue with me:
- 🐦 Twitter/X: @anuragh_kp
- 💼 LinkedIn: anuraghkp
- 🐙 GitHub: kpanuragh