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.

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:
| Test | What it tries | What should happen |
|---|---|---|
| Registration | Sends role: 'super_admin' to /register | The account is created with role: 'user' |
| Message reveal | A third user tries to reveal a message in someone else’s chat | 403 Forbidden |
| Emergency contacts | A different user tries to read and delete someone’s contact | 404 Not Found |
| Post edit | A non-author tries to edit a post | 403 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
- Never pass
req.bodystraight intocreate(),update()orObject.assign(). Pick the fields you allow and validate them. - Never trust an owner ID from the client. Set
userId,authorIdandownerIdfrom the logged-in user. - Put the owner in the query (
findOne({ _id, userId })), and return 404 so IDs can’t be guessed. - Check IDs inside the body too, not only the one in the URL.
- Check every entry point, including socket events.
- 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.
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”