Here's a pattern I keep running into in AI-scaffolded update endpoints: the code looks completely ordinary. Nothing about it reads as risky.
The whole endpoint is one line most people never look twice at:
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
res.json(user);
});
requireAuth checks that someone is logged in. It never checks what they're allowed to send. req.body goes straight into the update, whatever shape it happens to be.
Where this breaks
The intended use is updating your own name, bio, or avatar URL. Normal, boring, exactly what AI writes for you in a second when you ask for "let users edit their profile."
Nothing stops the client from sending fields nobody meant to expose:
curl -X PATCH https://yourapp.com/api/users/YOUR_OWN_ID \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Same As Before","role":"admin"}'
If role is a column on that user row, that request just promoted the caller to admin. Nothing crashed. The response looks exactly like a normal profile update, because as far as the database is concerned, it was one.
I shipped a version of this myself, and I didn't catch it right away. Every tool I ran on it was happy — linter clean, tests green, and an AI reviewer signed off. None of them asked what the client was allowed to put in that body.
Why this slips past both AI and manual review
The AI wired up exactly what was asked: read the request body and write it straight into the record. Nobody told it that role, is_admin, credits, or plan_id are different in kind from name and bio. It has no way to know a column is privileged unless something tells it so.
Manual testing has the same blind spot, because you're the one writing every request by hand. You check that your own update works, and you have no reason to try sending a field you were never supposed to touch.
Check your own project in two minutes
Search your codebase for anywhere a request body gets passed into a write call without being filtered first:
grep -rn "findByIdAndUpdate(req\|\.update(req\.body\|\.create(req\.body" .
For every hit, ask: does the model behind this call have any column a normal user shouldn't be able to set themselves? Role, admin flags, credit balances, subscription tier, ownership fields — anything like that sitting in the same table as the profile fields users are supposed to edit is a mass assignment waiting to happen.
The fix
Never pass req.body into a write call directly. Pick only the fields you meant to allow:
app.patch('/api/users/:id', requireAuth, async (req, res) => {
const { name, bio, avatarUrl } = req.body;
const user = await User.findByIdAndUpdate(
req.params.id,
{ name, bio, avatarUrl },
{ new: true }
);
res.json(user);
});
Now role in the request body just gets ignored, no matter who sends it. If you're using a schema validator like Zod, .pick() or .strict() on the update shape does the same thing with less to maintain by hand — and it fails loudly on an unexpected field instead of silently dropping it.
If a privileged field genuinely needs to change through an API, give it its own endpoint with its own authorization check, separate from the one a regular user hits to edit their bio.
The habit that catches this
Every time a request body flows into a write, it's worth asking one question before shipping: what's allowed in here, and did I decide that on purpose, or did the tool decide it for me? Most of the checks I run don't ask that at all. It's the one I've started asking by hand.
I put together a small tool that checks a repo for this pattern and a few related ones automatically. If you want me to run it against yours for free, drop a comment.
Top comments (2)
This landed at exactly the right moment — just finished building real per-company accounts with an is_admin flag sitting in the same table as the fields a company can legitimately edit. Your post is now the reason I'm holding the production push until every new write route is confirmed to whitelist fields explicitly rather than forwarding the body, instead of assuming the RLS work already covers it. It doesn't — our writes go through a service-role client, which bypasses RLS entirely, so this is exactly the gap that would matter here if it existed. Checking now before anything ships. Appreciate the timing more than you know.
Explicit whitelist over forwarding the body is exactly the right instinct - and doubly true once a service-role client is in the write path, since RLS never even sees that request either way. Nice to catch it while it's still cheap to fix, before anything's live.