Skip to content

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:

  • findById retrieves 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:

  • findByIdAndUpdate with 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

// 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 returns null and 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.id is populated by the authenticate middleware, which extracts and verifies the JWT. The user cannot modify req.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. A deletedCount of 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) with findOne({ _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.

Additional Resources