Express API Security: IDOR and Mass Assignment Explained

Most API security bugs don’t come from clever hackers. They come from a route that quietly trusts the request.

I ran into this while building the backend for a meetup app with Node.js, Express and MongoDB. The app handles private chats, emergency contacts and safety timers, so a mistake here isn’t just embarrassing. It could expose someone’s data.

Two bugs are especially easy to ship by accident. They have scary names, but the ideas are simple:

  • IDOR (Insecure Direct Object Reference): a user asks for something that isn’t theirs, and your API hands it over.
  • Mass assignment: a user sends fields you never meant to allow, and your API saves them.

Both are listed in the OWASP API Security Top 10. This post shows what each one looks like, and how I handled it in real routes.

Why login isn’t enough

Here’s the trap. You add authentication middleware to a route, and it feels safe:

typescript

router.put('/api/posts/:id', authenticate, async (req, res) => {
  // ...
});

The authenticate step proves one thing: the caller has a valid token. It tells you who they are.

It says nothing about which post they’re allowed to touch, or which fields they’re allowed to change. That part is your job, in every route.

Express API Security: IDOR and Mass Assignment Explained

Part 1: Stopping IDOR

What the bug looks like

typescript

// Vulnerable: any logged-in user can edit any post
const post = await Post.findById(req.params.id);
await post.updateOne(req.body);

Anyone with an account can change :id to another post’s ID and edit it. They don’t need to break anything. They only need to try.

I used four patterns to close this off, depending on the kind of data.

Pattern 1: Put the owner in the query

For data that belongs to exactly one person, like emergency contacts or safety timers, I ask the database for the record and the owner in the same query:

typescript

const contact = await EmergencyContact.findOne({
  _id: req.params.id,
  userId: user._id, // comes from the verified token
});

if (!contact) {
  res.status(404).json({ error: 'Emergency contact not found or unauthorized.' });
  return;
}

The timers route does the same thing:

typescript

const timer = await MeetingTimer.findOne({ _id: req.params.id, userId: user._id });

Notice the response is a 404, not a 403. If I said “forbidden,” an attacker would learn that the ID exists and belongs to someone else. A 404 says nothing either way, so they can’t tell a real ID from a made-up one.

Pattern 2: Check ownership explicitly

Posts and comments are different. Other people can see them, but only the author can edit them. So I load the record, then compare:

typescript

if (post.authorId.toString() !== user._id.toString()) {
  res.status(403).json({ error: 'Access denied. You can only edit your own posts.' });
  return;
}

Comments use the same check. Here a 403 is fine, because the post is visible to everyone anyway, so there’s nothing to hide.

Pattern 3: Check through the parent

A chat message doesn’t store who’s allowed to read it. The chat does. So when someone asks to reveal a blurred sensitive message, I check the chat’s participants:

typescript

const msg = await Message.findById(req.params.id);

const chat = await Chat.findById(msg.chatId);
if (!chat || !chat.participants.some((p) => p.toString() === user._id.toString())) {
  res.status(403).json({ error: 'Access denied. You are not a participant in this conversation.' });
  return;
}

The lesson: ask where access is really decided. It’s often one level up from the object in the URL.

Pattern 4: Check the things a request points to

This one is sneaky. When someone publishes a post with a photo, the request refers to an uploaded file. What if they point to a file that another user uploaded?

typescript

const asset = await MediaAsset.findOne({ storageKey: item.storageKey });
if (asset.uploaderId.toString() !== user._id.toString()) {
  res.status(403).json({ error: 'Unauthorized. You cannot attach media uploaded by another user.' });
  return;
}

Any ID inside a request body is just as untrusted as the ID in the URL.

Part 2: Stopping mass assignment

What the bug looks like

Express and Mongoose make this shortcut very tempting:

typescript

// Vulnerable
const user = await User.create(req.body);

Now imagine the request contains { "name": "Sam", "role": "super_admin", "isVerified": true }. If your model has those fields, they get saved. A visitor just made themselves an admin.

Fix 1: Pick fields on purpose

On the post-edit route, I take only the fields I allow and check each one:

typescript

const { caption, visibility } = req.body;

if (caption !== undefined) {
  if (typeof caption !== 'string' || caption.length > 2200) {
    return res.status(400).json({ error: 'Caption must be a string up to 2,200 characters.' });
  }
  post.caption = caption.trim();
}

if (visibility !== undefined) {
  const allowed = ['public', 'followers', 'connections', 'private'];
  if (!allowed.includes(visibility)) {
    return res.status(400).json({ error: 'Invalid visibility value.' });
  }
  post.visibility = visibility;
}

Anything else in the request is ignored. The checks also catch wrong types and values that are too long.

Fix 2: Let the server set the important fields

On registration, the role is hard-coded:

typescript

const user = await User.create({
  name: name.trim(),
  username: cleanUsername,
  email: cleanEmail,
  passwordHash,
  phone: phone.trim(),
  role: 'user', // even if the request says "super_admin"
  avatarHue: Math.floor(Math.random() * 360),
});

Creating a community works the same way. The owner, the status and the counters are all decided by the server:

typescript

const community = await Community.create({
  name: safeName,
  slug: uniqueSlug,
  description: safeDescription,
  ownerId: user._id,   // from the token, not from the request
  status: 'active',
  memberCount: 1,
  postCount: 0,
});

The client supplies words. The server decides everything that carries power.

Fix 3: Control what goes back out

The reverse problem is leaking fields you didn’t mean to send, like passwordHash or a reset token. I send responses through small mapper functions such as formatPostDto() and formatCommunityDto(), or use a safe-fields list when querying users. Only what’s on the list leaves the server.

Proving it with tests

A fix you haven’t tested is only a hope. I wrote negative tests, which try to do the wrong thing and check that it fails:

TestWhat it triesWhat should happen
RegistrationSends role: 'super_admin' to /registerThe account is created with role: 'user'
Message revealA third user tries to reveal a message in someone else’s chat403 Forbidden
Emergency contactsA different user tries to read and delete someone’s contact404 Not Found
Post editA non-author tries to edit a post403 Forbidden

Writing these also helps you think like an attacker, which is how I found the gap below.

A gap I found while writing this

My chat.join socket event checks that the user belongs to the chat. But the typing.start, typing.stop and chat.read events didn’t.

typescript

socket.on('typing.start', async (data) => {
  socket.to(`chat:${data.chatId}`).emit('typing', {
    chatId: data.chatId,
    userId: userIdStr,
    typing: true,
  });
});

Someone who knew a chat ID could trigger events in a chat they weren’t part of. The chat.read event even writes to the database. It’s the same IDOR problem, in a place I hadn’t looked.

The fix is a small helper that every handler calls first:

typescript

async function isMember(chatId: string, userId: any): Promise<boolean> {
  if (!mongoose.Types.ObjectId.isValid(chatId)) return false;
  return !!(await Chat.exists({ _id: chatId, participants: userId }));
}

socket.on('typing.start', async (data) => {
  if (!(await isMember(data.chatId, user._id))) return;
  // ...emit as before
});

Getting one route right doesn’t protect the others. Check every way in, including sockets.

A checklist for every route

  1. Never pass req.body straight into create(), update() or Object.assign(). Pick the fields you allow and validate them.
  2. Never trust an owner ID from the client. Set userId, authorId and ownerId from the logged-in user.
  3. Put the owner in the query (findOne({ _id, userId })), and return 404 so IDs can’t be guessed.
  4. Check IDs inside the body too, not only the one in the URL.
  5. Check every entry point, including socket events.
  6. Write tests that try to break the rules.

What to take away

  • Logging in proves who someone is. It doesn’t prove they own what they’re asking for.
  • IDOR is about which object. Mass assignment is about which fields.
  • Let the server decide roles, owners, status and counters.
  • A 404 can be safer than a 403 when the existence of a record is private.
  • Test the attacks, not only the happy path.

Related posts: JWT Authorization with User, Moderator and Admin Roles in Node.js, and How I Built Real-Time Chat in React with Socket.IO.

Implementing JWT Authorization with User Moderator and Admin Roles in Node.js · knowabteverything.com

How I Detect Phone Numbers and Payment Details in Chat Messages · knowabteverything.com

How I Built Real-Time Chat in React with Socket.IO · knowabteverything.com

1 thought on “Express API Security: IDOR and Mass Assignment Explained”

Leave a Comment