CWE-566: Authorization Bypass Through User-Controlled Key - JavaScript/Node.js
Overview
CWE-566 (Insecure Direct Object Reference / IDOR) in Node.js REST APIs occurs when a route parameter or request body value - such as req.params.id - is used directly in a database query without verifying that the authenticated user has permission to access that specific resource. The resource ID is the "user-controlled key" that bypasses authorization.
The typical pattern is Model.findById(req.params.id) in an Express handler where the handler's only guard is authentication (if (!req.user) return 401). A logged-in user can substitute any other resource's ID and retrieve or modify data belonging to other users.
Primary Defence: Always add an ownership filter to the database query: Model.findOne({ _id: req.params.id, userId: req.user.id }). Return 404 for both not-found and unauthorized - do not return 403, as that confirms the resource exists.
Common Vulnerable Patterns
findById Without Ownership Check
const express = require('express');
const router = express.Router();
// VULNERABLE - authenticated user can access any order by guessing IDs
router.get('/orders/:id', authenticate, async (req, res) => {
const order = await Order.findById(req.params.id); // no ownership filter
if (!order) return res.sendStatus(404);
res.json(order); // Returns any user's order data
});
// Attack: authenticated user requests GET /orders/507f1f77bcf86cd799439012
// (an order belonging to another user) - succeeds
Why this is vulnerable:
findByIdretrieves the record by primary key regardless of who owns it. Any authenticated user who can guess or enumerate order IDs can read all orders in the system.
Update Without Ownership Verification
// VULNERABLE - user can update any document by providing its ID
router.put('/documents/:id', authenticate, async (req, res) => {
const doc = await Document.findByIdAndUpdate(
req.params.id,
{ $set: req.body },
{ new: true }
);
if (!doc) return res.sendStatus(404);
res.json(doc);
});
Why this is vulnerable:
findByIdAndUpdatewith only the resource ID in the filter allows any authenticated user to modify any document. The update is performed before any ownership check can be done.
Ownership Check Using Request Body
// VULNERABLE - ownerId taken from request body (attacker-controlled)
router.delete('/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
// WRONG: req.body.userId comes from the attacker
if (invoice.userId.toString() !== req.body.userId) {
return res.sendStatus(403);
}
await invoice.deleteOne();
res.sendStatus(204);
});
Why this is vulnerable:
- An attacker can supply their own ID in the request body while still targeting another user's invoice ID. The check passes because both sides are attacker-controlled.
Secure Patterns
Composite Query (Recommended)
// SECURE: userId filter enforces ownership at the database layer
router.get('/orders/:id', authenticate, async (req, res) => {
const order = await Order.findOne({
_id: req.params.id,
userId: req.user.id, // req.user populated by authenticate middleware from verified token
});
// SECURE: 404 for both not-found and unauthorized - doesn't reveal existence
if (!order) return res.sendStatus(404);
res.json(order);
});
Why this works:
- The composite query
{ _id, userId }ensures that the matched document must belong to the requesting user. If the document exists but belongs to someone else, the query returnsnulland the handler returns 404, giving no information to the attacker about other users' resources.
Ownership Check from Server-Side Token
// SECURE: ownership verification using req.user.id (from verified JWT, not request body)
router.put('/documents/:id', authenticate, async (req, res) => {
const doc = await Document.findById(req.params.id);
if (!doc) return res.sendStatus(404);
// SECURE: compare against server-verified identity, not request data
if (doc.ownerId.toString() !== req.user.id) {
return res.sendStatus(404); // 404, not 403 - don't confirm existence
}
doc.set(req.body);
await doc.save();
res.json(doc);
});
Why this works:
req.user.idis populated by theauthenticatemiddleware, which extracts and verifies the JWT. The user cannot modifyreq.user- any tampering invalidates the signature. Ownership is verified against this server-controlled identity.
Sequelize Composite Delete
// SECURE: composite WHERE clause prevents IDOR on delete
router.delete('/invoices/:id', authenticate, async (req, res) => {
const deletedCount = await Invoice.destroy({
where: {
id: req.params.id,
ownerId: req.user.id, // Only delete if owned by current user
},
});
if (deletedCount === 0) return res.sendStatus(404);
res.sendStatus(204);
});
Why this works:
Invoice.destroy({ where: { id, ownerId } })deletes the record only if both the ID and the owner match. AdeletedCountof 0 means either the record doesn't exist or belongs to someone else - both cases return 404.
Remediation Steps
Locate the Finding
- Source:
req.params.id,req.query.id,req.body.id- any user-supplied resource identifier - Sink:
Model.findById(),Model.findByPk(),Model.findOne({ id }),Model.update(),Model.destroy()without ownership filter
Apply the Fix
- PRIORITY 1: Replace
findById(req.params.id)withfindOne({ _id: req.params.id, userId: req.user.id }) - PRIORITY 2: Review all UPDATE/DELETE routes and add the same ownership filter to the query
- PRIORITY 3: Return 404 (not 403) when the resource is not owned by the requesting user
Verify the Fix
- Authenticate as User A and request User B's resource ID; confirm 404 is returned
- Confirm that User A can still access their own resources normally
- Rescan with the security scanner to confirm the finding is resolved
Check for Similar Issues
Search for: findById(req.params, findByPk(req.params, findOne({ id: req.params, update(req.params.id without a userId filter
Testing
- Normal input: authenticate as a user and confirm they can read, update, and delete resources they own.
- Boundary input: test missing IDs, malformed IDs, deleted records, and records owned by inactive users.
- Malicious input: authenticate as User A and submit User B's resource IDs in route, query, and body fields; verify each request returns the same not-found behavior.