Every framework has a feature that feels like magic the first time you use it, and then feels like a loaded gun the first time you understand it. For Rails it was attr_accessible (RIP). For Laravel it's $fillable/$guarded. For a raw Express + Mongoose setup it's Model.updateOne(req.body). For .NET it's model binding straight onto an EF entity. They all do the same seductive thing: take a blob of JSON and slap it directly onto your database model, field by field, with zero opinions about which fields the client should actually be allowed to touch.
This is mass assignment. And it is, without a doubt, one of the most quietly dangerous patterns in API development — precisely because it doesn't look like a vulnerability. It looks like clean, idiomatic code.
The Setup That Looks Completely Reasonable
Here's a "profile update" endpoint that thousands of tutorials have taught developers to write:
// PATCH /api/users/me
app.patch('/api/users/me', authenticate, async (req, res) => {
const user = await User.findByIdAndUpdate(
req.user.id,
req.body, // 🚨 whatever the client sent, goes straight in
{ new: true }
);
res.json(user);
});
The intent is: users can update their name, bio, and avatarUrl. The reality is: users can update any field on the User model, because req.body is just a JSON object and Mongoose (or your ORM of choice) doesn't know which keys are "meant" to be editable by a stranger over HTTP versus which ones are internal bookkeeping.
So if your User schema also happens to have role, isVerified, accountBalance, or isAdmin, congratulations — you've built a self-service privilege escalation endpoint. The attacker doesn't need SQL injection, doesn't need to steal a token, doesn't need anything fancy. They just need to read your API docs (or your frontend's network tab) and add a field:
curl -X PATCH https://api.example.com/api/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Totally Normal User", "role": "admin"}'
If your endpoint doesn't explicitly filter what it accepts, that request just worked. This is exactly the bug class behind the infamous 2012 GitHub incident, where a researcher used Rails mass assignment to add his SSH key to the Rails organization itself — by PATCHing a public key field that was never meant to be client-writable.
It's Not Just "role" Fields
The obvious example is always privilege escalation, because it's the scariest and easiest to explain. But mass assignment shows up in subtler, arguably more common ways:
- An e-commerce API where
PATCH /cart/items/:idaccepts apricefield the frontend never sends but the schema allows — congratulations, free stuff. - A "update my order" endpoint where
statusis client-writable, letting a customer mark their own order asrefundedorshipped. - A multi-tenant SaaS app where
organizationIdis a field on the resource and an attacker reassigns their own record to a different tenant, or worse, updates someone else's record to point into their tenant. - Timestamps like
createdAtortrialEndsAtbecoming writable, letting a free-tier user quietly extend their own trial forever.
None of these require guessing a secret. They require knowing the shape of your data model, which — thanks to GraphQL introspection, verbose error messages, or just an OpenAPI spec — is usually not a secret at all.
The Fix Isn't Complicated, It's Just Easy to Skip
The fix is always some version of "explicitly declare what's allowed, and reject everything else." The exact mechanism differs by stack:
Explicit allowlisting at the DTO/validation layer (works everywhere, framework-agnostic):
const ALLOWED_FIELDS = ['name', 'bio', 'avatarUrl'];
app.patch('/api/users/me', authenticate, async (req, res) => {
const updates = Object.fromEntries(
Object.entries(req.body).filter(([key]) => ALLOWED_FIELDS.includes(key))
);
const user = await User.findByIdAndUpdate(req.user.id, updates, { new: true });
res.json(user);
});
Framework-native protection, when it exists — use it instead of rolling your own. Laravel's Eloquent has had this built in for years:
class User extends Model
{
protected $fillable = ['name', 'bio', 'avatar_url']; // only these survive mass assignment
// OR, inverse approach:
protected $guarded = ['role', 'is_admin', 'account_balance'];
}
Schema validation as a gate, using something like Zod or Joi, so invalid/unexpected keys are rejected before they ever reach your ORM:
const UpdateProfileSchema = z.object({
name: z.string().max(100).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
}).strict(); // .strict() rejects unknown keys instead of silently dropping them
const parsed = UpdateProfileSchema.parse(req.body); // throws on role, isAdmin, etc.
That .strict() call matters more than it looks — without it, most validators just strip unknown keys silently, which is safe but can also hide bugs where your frontend and backend have drifted. Failing loudly during development, and stripping silently in production, is a reasonable middle ground.
A Pattern I Push Back On in Code Review
At Cubet, one of the recurring review comments I leave on PRs is some version of "don't pass req.body directly into an update call." It's such a natural thing to write — it's less code, it's DRY, it "just works" in the happy path — that even experienced engineers reach for it under deadline pressure. I've started treating any .update(req.body) or .create(req.body) pattern as an automatic review flag, the same way I'd flag string-concatenated SQL. It's not that the developer did something exotic wrong; it's that the convenient path and the safe path silently diverged, and nothing in the code makes that divergence visible.
The rule of thumb I give juniors: if you can't point to the exact list of fields your endpoint accepts, you don't actually know what your endpoint does. "It updates the user" is not a specification. "It updates name, bio, and avatarUrl, and rejects everything else" is.
Why This Keeps Happening
Mass assignment thrives because it sits at the intersection of two things developers are trained to value: fewer lines of code, and trusting your own type system. req.body looks like a User because your ORM happily accepts it as one. Nothing about the syntax warns you that req.body is attacker-controlled and User is a database row with fields that should never leave the server unfiltered in the wrong direction.
The mitigation, boiled down to one sentence: treat every inbound field as guilty until proven allowlisted. Not blocklisted — blocklists rot the moment someone adds a new sensitive column and forgets to update the exclusion list. Allowlists fail safe. Blocklists fail open.
Found a mass assignment bug in the wild, or have a war story about a $fillable array that someone forgot to update? I'd love to hear it — find me on X/Twitter or LinkedIn.