September 13, 2026
The Field You Never Put on the Form
How βupdate the user with whatever they sentβ turns a profile page into an admin panel

By Fuzzyy Duck
7 min read
How "update the user with whatever they sent" turns a profile page into an admin panel
The ticket says: "Let users edit their name and email."
You build the form. Two inputs. You wire up the endpoint:
app.patch('/api/profile', requireLogin, async (req, res) => {
const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
res.json(user);
});app.patch('/api/profile', requireLogin, async (req, res) => {
const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
res.json(user);
});Clean. No loop, no repetition, and when the product team adds a "job title" field next month you won't have to touch the backend at all. That last part is why everyone writes it this way.
Then someone opens DevTools, watches the request, and replays it with two extra keys:
{ "name": "Alex", "email": "alex@corp.io", "role": "admin", "creditBalance": 99999 }{ "name": "Alex", "email": "alex@corp.io", "role": "admin", "creditBalance": 99999 }Your model has a role column. Your model has a creditBalance column. req.body had them, so the update set them.
That's mass assignment. It's the same shape as the IDOR from the last post the client supplied something, the server trusted it except here the client isn't picking which record to touch. They're picking which columns. [Free link]
What it actually is
Mass assignment happens when your code binds a whole request body to a data model without deciding, in advance, which fields the client is allowed to set.
The formal name is CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes. In the OWASP API Security Top 10 it used to have its own entry; in 2023 it was folded into API3: Broken Object Property Level Authorization, right alongside IDOR's category. That merge is a good instinct. They're the same failure at two zoom levels object versus property.
The part worth sitting with: nothing malfunctioned. findByIdAndUpdate did precisely what its documentation says. No exception, no warning, no 500 in your logs. The framework feature that saves you twenty lines of boilerplate is the same feature that hands over your privilege column.
The misconceptions
"The field isn't in the form, so it can't be sent." The form defines what your UI submits, not what your endpoint accepts. Those are unrelated.
"It's not in the API docs." Attackers don't read your docs. They read your JSON responses which usually return the full model, including the field names they're about to try setting.
"We validate the input." Most validation checks that the fields you expect are well-formed. It says nothing about fields you didn't expect. A Joi or Zod schema without .strict() will happily pass extra keys straight through.
"It's a Rails problem." Rails got famous for it in 2012 when a researcher used a mass assignment hole to add his public key to the rails/rails repo and push a commit to it a spectacular, very public demonstration. Rails responded by making strong parameters mandatory. But every framework with request-to-model binding has this: Spring, ASP.NET Core, Laravel, Django ModelForms, Mongoose, Sequelize. Rails just got the headline.
Where it sneaks in
The model grew. The endpoint was written when users had four columns and none of them mattered. Two years later it has nineteen, including is_verified, plan_tier, and internal_notes. Nobody revisited the endpoint, because the endpoint didn't change β the model did.
Spread operators. { ...existing, ...req.body } is the same bug with nicer syntax.
Nested objects. You allow-list the top level, then let a nested settings or metadata object through wholesale. If anything privileged lives in there, you've reopened the hole.
"Just make it generic." A single PATCH /api/:resource/:id handler that binds request bodies to whatever model matches. Very DRY. Very dangerous.
Create endpoints, forgotten. People allow-list on update and leave POST /api/users binding the full body, so you can't promote yourself β you can just register as an admin.
Reused DTOs. The same object serves the admin API and the public API. Admins legitimately set role, so role is in the DTO, so the public endpoint accepts it too.
What it costs
The damage depends entirely on what your model happens to contain which is why it's hard to reason about and easy to underestimate.
role,isAdmin,permissionsβ privilege escalation, and every access control decision downstream is now wrong.creditBalance,discount,price,statusβ direct financial loss. In a payments or commerce system, a client-settableamountorpaidfield is money walking out the door.emailVerified,mfaEnabled,passwordResetTokenβ authentication bypass without ever touching the auth code.orgId,tenantIdβ the user reassigns their own record into someone else's tenant. Now you have a mass assignment bug and an IDOR.
It's also silent in exactly the way IDOR is. Valid session, 200 response, no anomaly for your WAF to catch. You find out during an audit, or you don't find out.
The fix
The instinct most people have is a block-list: strip the dangerous keys before binding.
// Don't do this.
delete req.body.role;
delete req.body.isAdmin;
const user = await User.findByIdAndUpdate(req.user.id, req.body);// Don't do this.
delete req.body.role;
delete req.body.isAdmin;
const user = await User.findByIdAndUpdate(req.user.id, req.body);This fails on the next migration. Someone adds plan_tier in six months and doesn't know this list exists. Block-lists require you to predict every dangerous field forever, including the ones that don't exist yet. You will lose that bet.
Allow-list instead:
router.patch('/api/profile', requireLogin, async (req, res) => {
// Deny by default. Only these keys can ever reach the model.
// A column added next sprint is safe until someone deliberately adds it here.
const schema = z.object({
name: z.string().trim().min(1).max(80),
email: z.string().email(),
}).strict(); // .strict() rejects unknown keys instead of ignoring them
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid input' });
const user = await prisma.user.update({
where: { id: req.user.id }, // identity from the session, not the body
data: parsed.data, // a NEW object, not the request body
select: { id: true, name: true, email: true }, // and don't leak on the way out
});
res.json(user);
});router.patch('/api/profile', requireLogin, async (req, res) => {
// Deny by default. Only these keys can ever reach the model.
// A column added next sprint is safe until someone deliberately adds it here.
const schema = z.object({
name: z.string().trim().min(1).max(80),
email: z.string().email(),
}).strict(); // .strict() rejects unknown keys instead of ignoring them
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid input' });
const user = await prisma.user.update({
where: { id: req.user.id }, // identity from the session, not the body
data: parsed.data, // a NEW object, not the request body
select: { id: true, name: true, email: true }, // and don't leak on the way out
});
res.json(user);
});Three things are doing the work:
The allow-list is explicit and lives in code. Not a convention, not a comment. A reviewer can read it and see the complete set of fields this endpoint can write.
.strict() rather than silent stripping. Both are safe, but strict mode turns a probe into a 400 and a log line. Silent stripping means an attacker gets a cheerful 200 and no idea whether it worked β and so do you. If you have clients that legitimately send extra fields, strip silently but log it.
parsed.data is a new object. The request body never reaches the ORM. There's no path where a stray key survives.
For privileged fields that someone is allowed to set, don't add them to this schema build a separate endpoint with its own authorization. PATCH /api/admin/users/:id/role is more code and much easier to reason about than one endpoint with conditional field permissions. Rule of thumb: if a field requires a different permission to write, it belongs behind a different endpoint.
Why this keeps happening
Mass assignment isn't a mistake in the usual sense. It's a default that optimises for the wrong thing.
Every modern framework ships request-to-model binding because it removes boilerplate, and removing boilerplate is genuinely good. The trade is invisible: you're coupling your public API surface to your database schema. Add a column, and you've silently extended your API. No code changed, no PR touched the endpoint, no reviewer saw anything.
That's the part that defeats code review. The vulnerable line is shorter and cleaner than the safe one. Reviewers are trained to like it. And the security-relevant change usually happens in a different file, in a different sprint, in a migration nobody thought to review from a security angle.
The structural fix is to make the boundary explicit and permanent:
- Never bind request bodies to persistence models. Bind to a DTO or a validated schema. The mapping between them is where authorization lives.
- Make strict parsing the default in your validation setup, so an unknown field fails loudly unless someone opts out.
- Add a schema review step to migrations. When a column is added, the question "which endpoint can now write this?" should get asked once, deliberately.
- Separate read and write shapes. The object you return and the object you accept are different types with different rules, even when they look identical today.
Framework notes
- Rails β strong parameters:
params.require(:user).permit(:name, :email). Mandatory since Rails 4, but check your legacy models for lingeringattr_accessible. - Laravel β prefer
$fillable(allow-list) over$guarded(block-list), and pass$request->validated()rather than$request->all(). - Django β set
fields = ['name', 'email']on ModelForms and DRF serializers. Neverfields = '__all__', and mark computed or privileged fieldsread_only. - Spring Boot β bind to a request DTO, not the JPA entity. If you must bind to an entity,
@JsonIgnoreprivileged fields and configureFAIL_ON_UNKNOWN_PROPERTIES. - ASP.NET Core β use a view model per endpoint, or
[Bind(nameof(User.Name), nameof(User.Email))].[BindNever]on sensitive properties is a good belt-and-braces layer. - Mongoose / Sequelize β nothing protects you by default. Filter the payload before it reaches the model;
strict: trueon the schema only blocks fields that aren't in the schema at all, androleis in your schema.
The test that catches it
Same pattern as the IDOR test, and just as cheap: send a legitimate request with one illegitimate field bolted on, then assert the database didn't move.
it('ignores privileged fields on profile update', async () => {
await request(app)
.patch('/api/profile')
.set('Cookie', sessionFor(user))
.send({ name: 'Alex', role: 'admin', creditBalance: 99999 })
.expect(400); // strict schema rejects it
const after = await User.findById(user.id);
expect(after.role).toBe('user'); // the important assertion
expect(after.creditBalance).toBe(0);
});it('ignores privileged fields on profile update', async () => {
await request(app)
.patch('/api/profile')
.set('Cookie', sessionFor(user))
.send({ name: 'Alex', role: 'admin', creditBalance: 99999 })
.expect(400); // strict schema rejects it
const after = await User.findById(user.id);
expect(after.role).toBe('user'); // the important assertion
expect(after.creditBalance).toBe(0);
});The status code assertion is optional. The database assertion is the test. Plenty of endpoints return a tidy 200 while having quietly written the field.
If you want the version that keeps working after you leave: write a test that reflects over your model's fields and fails if any of them isn't either in an endpoint's allow-list or explicitly marked as never-writable. New column, failing build, five-second decision.
Common mistakes
Mistake Why it's dangerous Better approach update(id, req.body) Every column becomes writable Bind a validated allow-list { ...user, ...req.body } Same bug, nicer syntax Construct the update object explicitly Block-listing sensitive fields Breaks on the next migration Allow-list; deny by default Validation without .strict() Unknown keys pass through untouched Reject or strip unknown keys Allow-listing only the top level Nested objects slip through whole Validate nested shapes too One DTO for admin and public APIs Public inherits admin's writable fields Separate shapes per audience Fixing update, forgetting create Register directly as an admin Same discipline on every write path Letting the body carry userId / orgId Reassign records to another tenant Identity from the session, always fields = '__all__' Ships every future column too Enumerate fields explicitly Returning the full model Hands over the field names to try next Explicit response shape Generic PATCH /:resource/:id One bug, every table Per-resource handlers
Ask this in code review
- What is the complete list of fields this endpoint can write? Can you point at it?
- What happens to a key that isn't in that list rejected, stripped, or written?
- If someone adds a column to this model next month, does it become writable automatically?
- Does the create path have the same protection as the update path?
Question 3 is the one that matters, because it's the only one about the future β and mass assignment is almost always a bug introduced by a migration, not by the endpoint.
Takeaways
- Mass assignment is authorization on fields, the same way IDOR is authorization on objects.
- The request body is user input. Binding it to a model makes every column part of your public API.
- Allow-list, never block-list. Block-lists assume you can predict every field you'll ever add.
- Use strict parsing so unexpected keys fail loudly instead of passing quietly.
- Never bind directly to a persistence model. A DTO is where the boundary lives.
- Fields needing a different permission belong behind a different endpoint, not a conditional.
- Cover create endpoints, not just update self-registering as an admin is the same bug.
- Test the database state, not the status code.
Go and grep for req.body, params.all(), $request->all(), and __all__. For each hit, ask what your model contains today that it didn't contain when that line was written.