Blog/Web Security
Web SecurityAugust 10, 2026 · 6 min read

Mass Assignment: How role=admin Ends Up in Your Update

A profile-edit endpoint takes the submitted form and saves it. The code is one clean line, and that's exactly the problem:

app.patch('/api/me', async (req, res) => {
  const user = await User.findById(req.session.userId);
  Object.assign(user, req.body);   // bind everything the client sent
  await user.save();
  res.json(user);
});

The form on the page only has "name" and "email." But the User model also has role, isAdmin, and emailVerified. An attacker sends {"name":"Al","role":"admin","isAdmin":true} straight to the API — no form required — and Object.assign faithfully copies all three onto the user. They just promoted themselves. This is mass assignment (a.k.a. over-posting): the code trusted that the request body would contain only the fields the form displayed, and it never does.

Why it happens and what it costs

The vulnerability is born from a convenience feature. Every major framework offers a "bind the whole body onto the object" shortcut — Object.assign(model, body), Model.update(req.body), User(**request.data), user.update_attributes(params) — because writing field-by-field assignment is tedious. That shortcut binds whatever keys arrive, including ones no legitimate client would send.

The impact depends on which hidden fields exist on the model:

  • Privilege escalationrole, isAdmin, permissions, groupId
  • Trust/verification bypassemailVerified, kycStatus, approved
  • Financial tamperingbalance, credits, discountRate, priceOverride
  • Ownership hijackinguserId, ownerId, tenantId to reassign a record to yourself or read across tenants

None of these appear in the UI, which is exactly why they get missed — the developer reasons about the form, but the attacker talks to the API.

The fix: allowlist the fields a client may write

Never bind the whole body. Decide explicitly which fields each role is permitted to set, and copy only those. This is the same principle as parameterized queries and safe template rendering: separate what the client controls from what the server controls.

const { z } = require('zod');

// Define exactly what a normal user may change. Anything else in the
// body is ignored — role, isAdmin, balance can't ride along.
const ProfileUpdate = z.object({
name:  z.string().min(1).max(80),
email: z.string().email(),
bio:   z.string().max(500).optional(),
}).strict();  // .strict() also REJECTS unknown keys outright

app.patch('/api/me', async (req, res) => {
const parsed = ProfileUpdate.safeParse(req.body);
if (!parsed.success) return res.status(400).send('Invalid input');

const user = await User.findById(req.session.userId);
// Assign only the allowlisted, validated fields.
Object.assign(user, parsed.data);
await user.save();
res.json({ name: user.name, email: user.email, bio: user.bio });
});

// Privileged fields get their own guarded endpoint with an authz check:
app.patch('/api/admin/users/:id/role', requireAdmin, async (req, res) => {
/* only here can 'role' be written, and only by an admin */
});

The principles

  • Allowlist, never blocklist. List the fields a client may write and ignore everything else. A blocklist of "dangerous fields" always misses the one you add next quarter.
  • Separate privileged writes. Fields that govern authorization or money get dedicated endpoints protected by an explicit authorization check — never editable through the general update path.
  • Mark server-owned fields read-only at the model/serializer layer, so even a careless handler can't bind them.
  • Return a narrow view. Respond with only the fields you meant to expose, so the response can't leak the existence of sensitive columns.

The one-liner: decide server-side exactly which fields the client may set, and bind only those — the request body is a suggestion, not an instruction.

Try it in the mass assignment simulation: add role: admin to a profile update and escalate, then switch to an allowlisted serializer and watch the extra field get dropped. It's a close cousin of IDOR / broken object-level authorization — both are the client reaching fields or records it shouldn't.

Share this post

Frequently Asked Questions

Related posts

Race Conditions in Web Apps: When Two Requests Beat Your Check

Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.

Aug 11, 2026 · 7 min read

Insecure Deserialization: How Loading Data Becomes Running Code

Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.

Aug 9, 2026 · 7 min read

WebSocket Security: Why the Same-Origin Rules Don't Apply

WebSockets skip the protections you rely on for normal requests — CORS doesn't apply and cookies are sent automatically, enabling cross-site hijacking. Here's how to validate origins, authenticate connections, and secure real-time apps.

Aug 2, 2026 · 6 min read