← All lessons

This endpoint has auth. It still lets a user hand themselves admin.

2026-07-23 · Vollos Lens

There's an auth check. The tests are green. It still lets a signed-in user turn themselves into an admin.

The pattern shows up constantly in AI-scaffolded update endpoints: the code looks completely ordinary. Nothing about it reads as risky. The whole bug lives in one line most people never look at twice.

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 boring: update your name, bio, or avatar. 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 row, that request just promoted the caller to admin. Nothing crashed. The response looks like a normal profile update, because as far as the database is concerned, it was one.

We shipped a version of this ourselves, and didn't catch it right away. Every tool we 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 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 flows 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 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. A schema validator's .pick() or .strict() 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 it ships: what's allowed in here, and did we decide that on purpose, or did the tool decide it for us? Most of the checks that run on a repo skip that question entirely, and a passing pipeline has no way of flagging a question nobody thought to ask it.

Want a second pair of eyes on the inside parts — the policies, the routes, the views? Read a full sample review or send us your repo.