Mass Assignment: When Your API Tries Too Hard to Be Helpful 🎁
Here's a story that should make every backend developer mildly uncomfortable.
A user signs up on your platform. They send a perfectly normal registration request. Except they also sneak in one extra field — "isAdmin": true. Your API, eager to please, happily stores it. Congratulations: your new user is now an admin, and they didn't need to hack anything. They just asked.
That's mass assignment. It's one of those vulnerabilities that sounds embarrassingly simple, because it is embarrassingly simple — and yet it keeps biting teams in production.
What Is Mass Assignment, Exactly?
Mass assignment happens when your API takes a user-supplied object and directly binds all of its fields to a model, without checking which fields should actually be writable by users.
Every modern ORM has a convenience feature that does this. Rails has params.permit. Laravel has $model->fill(). Mongoose has new User(req.body). They're all doing the same thing: taking a blob of key-value pairs and shoving them straight into your data model.
This is incredibly useful — until someone starts sending fields you didn't intend to expose.
// The classic vulnerable pattern — Node.js / Mongoose
app.post('/api/users/register', async (req, res) => {
// req.body = { name: "Alice", email: "[email protected]", isAdmin: true }
const user = new User(req.body); // <-- blindly trusts everything
await user.save();
res.json(user);
});
The attacker didn't exploit a logic flaw. They didn't bypass authentication. They just sent a JSON object with an extra key and your code did the rest. The ORM didn't warn you. The database didn't push back. It just... worked.
The Fields That Get You
It's rarely just isAdmin. The fun fields attackers look for are:
role— changing"user"to"admin"or"moderator"balanceorcredits— giving themselves free moneyemailVerified: true— skipping your verification flow entirelysubscriptionTier: "enterprise"— unlocking paid features for freeownerId— reassigning objects to themselves (this overlaps with BOLA, but mass assignment is the door)passwordResetToken— overwriting a reset token to hijack accounts
At Cubet, we were doing a security review on an internal project and found a /profile/update endpoint that accepted req.body wholesale and passed it straight to user.updateMany(). The profile page only showed name and bio fields, so nobody had noticed that the API would also happily accept a role field. It had been sitting there for eight months.
The UI tells you what fields exist. The API shouldn't assume that's all a client will send.
What the Fix Looks Like
The fix is always some form of explicit allowlisting — you declare exactly which fields a user is permitted to set, and you only bind those.
// Safe version: explicit allowlist
app.post('/api/users/register', async (req, res) => {
const { name, email, password } = req.body; // destructure only what you need
const user = new User({ name, email, password }); // isAdmin never touched
await user.save();
res.json({ id: user._id, name: user.name, email: user.email });
});
In Express/Mongoose this usually means destructuring. In Laravel it means $request->only(['name', 'email']) or using $fillable on your model. In Rails it means strong parameters with an explicit .permit() list.
The same logic applies on update endpoints — arguably even more important there, because they're the ones that get called with existing authenticated context. Users feel more "trusted" on an update endpoint, which is exactly when they might slip in a role change.
// Safe update endpoint
app.patch('/api/users/:id', authenticate, async (req, res) => {
const ALLOWED_FIELDS = ['name', 'bio', 'avatarUrl'];
const updates = Object.fromEntries(
Object.entries(req.body).filter(([key]) => ALLOWED_FIELDS.includes(key))
);
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No valid fields provided' });
}
const user = await User.findByIdAndUpdate(req.params.id, updates, { new: true });
res.json(user);
});
Yes, it's more verbose. Yes, every time you add a new editable field you have to update the allowlist. That friction is the point — it forces you to make a conscious decision about what's user-writable.
A Note on "Just Use TypeScript"
TypeScript helps but doesn't save you. If your request handler is typed as Partial<UserDocument>, TypeScript will happily accept isAdmin because it's a valid key on UserDocument. The type safety you get from TypeScript is a compile-time guarantee that fields are the right type, not a runtime guarantee that they're permitted. You still need the explicit allowlist.
Quick Checklist
Before you ship any endpoint that touches a model:
- Every field in your update/create handler is explicitly listed, not
...req.body - Admin-only fields (
role,isAdmin,subscriptionTier) are never in the user-facing allowlist - Your model's
$fillable/Mass Assignmentconfig matches what you actually want exposed - Response bodies don't echo back fields the user shouldn't know about (like
passwordHash) - You've grep'd your codebase for
new Model(req.body)andmodel.fill($request->all())
That last one is worth running right now. Seriously, open a terminal.
grep -r "req\.body" src/ --include="*.js" --include="*.ts" | grep -v "\.only\|\.permit\|const {"
Adjust the pattern for your stack. The hits you're looking for are places where req.body flows directly into an ORM call without being filtered first.
The Bigger Picture
Mass assignment lands on the OWASP API Security Top 10 as API6:2023 (Unrestricted Access to Sensitive Business Flows) and has close cousins in API3:2023 (Broken Object Property Level Authorization). It's fundamentally a trust boundary problem: your API trusts the shape of user input too much.
The mental model I find useful: treat every field in an incoming request body as potentially hostile, even on authenticated endpoints. Authentication tells you who is sending the request. It doesn't tell you that everything they're sending is something they're allowed to change.
Your API should be helpful — but not that helpful.
Found a sneaky mass assignment hole in your codebase after reading this? I'd love to hear about it. Hit me up on Twitter/X or connect on LinkedIn — war stories welcome, no actual credentials required.