Mass Assignment: The One-Line API Bug Hiding in Your Update Endpoint A developer has detailed how a single-line mass assignment flaw in a PATCH /api/users/me endpoint lets any authenticated user promote themselves to admin by sending a role field in the request body, a vulnerability tracked as OWASP API3:2023 and CWE-915. The writeup demonstrates the exploit and recommends allowlisting bindable fields or using a strict schema validator such as zod to reject unknown keys with a 400, plus explicit response DTOs and a regression test asserting users cannot change their own role. This article was created with the help of AI and reviewed, tested and edited by me before publishing. Here's an endpoint that looks completely fine in code review: js app.patch '/api/users/me', requireAuth, async req, res = { const user = await User.findByIdAndUpdate req.user.id, req.body, { new: true } ; res.json user ; } ; It's authenticated. It only updates the logged-in user. It's also vulnerable, because req.body goes straight into the database. If your User model has a role field, any user can send this: PATCH /api/users/me Content-Type: application/json { "displayName": "Ana", "role": "admin" } and promote themselves. That's mass assignment : the client sets object properties it was never meant to touch. OWASP folds it into API3:2023, Broken Object Property Level Authorization https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/ , and MITRE tracks it as CWE-915 https://cwe.mitre.org/data/definitions/915.html . Let's find it, prove it, and fix it. Frameworks make binding request data to models easy, which is great until the model grows. The endpoint above might have been safe on day one, when User only had displayName and email . Then someone added role , isVerified , credits or tenantId , and the update endpoint silently started accepting them too. Typical sensitive fields to watch for: role , isAdmin , permissions isVerified , emailConfirmed , status balance , credits , plan , discount ownerId , tenantId , orgId GET /api/users/me that returns GET or a behaviour change, like suddenly reaching an admin route proves the write happened. POST /api/projects with an extra { "profile": { "verified": true } } slips past filters that only check top-level keys. Only do this on your own apps or targets you're authorised to test. The OWASP Mass Assignment Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/Mass Assignment Cheat Sheet.html recommends allowlisting bindable fields over blocklisting dangerous ones, because a blocklist breaks the moment a new sensitive field is added. js const pick = obj, keys = Object.fromEntries keys.filter k = k in obj .map k = k, obj k ; const USER EDITABLE = 'displayName', 'bio', 'avatarUrl' ; app.patch '/api/users/me', requireAuth, async req, res = { const updates = pick req.body, USER EDITABLE ; const user = await User.findByIdAndUpdate req.user.id, updates, { new: true } ; res.json toPublicUser user ; } ; A schema validator that rejects unknown keys turns silent acceptance into a clear 400 . With zod https://zod.dev/ : js import { z } from 'zod'; const UpdateMe = z.object { displayName: z.string .min 1 .max 80 .optional , bio: z.string .max 500 .optional , } .strict ; // unknown keys like "role" fail validation app.patch '/api/users/me', requireAuth, async req, res = { const parsed = UpdateMe.safeParse req.body ; if parsed.success return res.status 400 .json { error: 'Invalid fields' } ; const user = await User.findByIdAndUpdate req.user.id, parsed.data, { new: true } ; res.json toPublicUser user ; } ; Rejecting is better than silently dropping: clients find out quickly, and an attempted role change shows up in your logs. API3 covers both directions. The toPublicUser function above matters because returning the raw model leaks internal fields password hashes, internal flags and hands attackers the list of fields to try. Use explicit response DTOs, not res.json dbObject . The bug comes back whenever someone adds a field, so make it a regression test: js test 'users cannot change their own role', async = { const agent = await loginAs 'regular-user' ; await agent.patch '/api/users/me' .send { role: 'admin' } .expect 400 ; const me = await agent.get '/api/users/me' .expect 200 ; expect me.body.role .toBeUndefined ; // not exposed at all } ; Add one of these for every sensitive field and every endpoint that writes to the model, including create endpoints and admin-only ones can a support role set role: "owner" ? . req.body to an ORM update or create 400 , not silently ignored Mass assignment is rarely clever. It's a convenience left in place after the model changed, which is exactly why it's worth a test that never forgets. Anusha Dirisala is the founder of Pentrova pentrova.ai , a self-serve AI penetration testing platform for web apps and APIs, based in Hyderabad.