Skip to content

CWE-566: Authorization Bypass Through User-Controlled SQL Primary 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.

CWE-566 names SQL specifically, and the Sequelize example below is the literal case. Mongoose is here because the weakness is identical when _id is the primary key - the fix is the same composite filter, and scanners commonly report findById under this CWE. Where the identifier is not a database key at all (a filename, a path, an object-store key), the finding belongs under CWE-639 or CWE-22 instead.

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: a 403 reserved for the unauthorized case 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);
    if (!invoice) return res.sendStatus(404);

    // 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.

Bulk Operation Over IDs From the Request

// VULNERABLE - the ids come from the request body and nothing scopes them to the caller
router.post('/orders/archive', authenticate, async (req, res) => {
    await Order.updateMany(
        { _id: { $in: req.body.ids } },     // no userId filter
        { $set: { archived: true } }
    );
    res.sendStatus(204);
});

// Attack: POST /orders/archive  {"ids":["507f...012","507f...013"]}
// with other users' order IDs - all of them are archived

Why this is vulnerable:

  • $in applies the update to every document whose _id is in the list, and the list is attacker-supplied. One request reaches as many records as the attacker can name, where the single-resource route reaches one.
  • It is the route most likely to be missed, because it usually arrives later than the read and write routes it sits beside, and it returns nothing an attacker or a tester can read - a 204 looks the same whether it archived the caller's orders or everyone's.

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 } can only match a document that belongs to the requesting user. If the document exists but belongs to someone else, the query returns null and the handler returns 404, telling the caller nothing 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
    }

    // SECURE - assign named fields. `doc.set(req.body)` would let the request
    // body write every schema path, `ownerId` included - a caller could hand
    // their own document to another account, and the ownership check above
    // would keep passing because it ran against the pre-update value
    doc.title = req.body.title;
    doc.content = req.body.content;
    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.
  • Assigning named fields keeps the ownership column out of the caller's reach. Mongoose's doc.set(obj) writes any path the schema declares, so a body carrying ownerId reassigns the document; the same applies to findByIdAndUpdate(id, { $set: req.body }) and to Sequelize's instance.set(req.body) without a fields allowlist.

Sequelize Composite Delete

The examples above are Mongoose; this one is Sequelize over a SQL table, which is the literal case CWE-566 names. The shape of the fix does not change - the owner moves into the WHERE clause rather than being checked beside it.

// SECURE - composite WHERE clause prevents IDOR on delete
router.delete('/invoices/:id', authenticate, async (req, res) => {
    const deletedCount = await SqlInvoice.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:

  • 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, and both cases return 404, so the response cannot be used to tell them apart. Measured on Sequelize 6.37.8, deleting another account's row through this filter returns 0 and leaves the row in place.

Bulk Operation Scoped to the Owner

// SECURE - the owner is part of the same statement, and the whole batch is one transaction
router.post('/orders/archive', authenticate, async (req, res, next) => {
    const ids = req.body.ids;

    try {
        await SqlOrder.sequelize.transaction(async (tx) => {
            const [affectedCount] = await SqlOrder.update(
                { archived: true },
                { where: { id: ids, ownerId: req.user.id }, transaction: tx }
            );

            // Fewer rows matched than IDs requested: at least one belongs to
            // someone else or does not exist. Throwing rolls the batch back.
            if (affectedCount !== ids.length) {
                throw Object.assign(new Error('not found'), { status: 404 });
            }
        });
        res.sendStatus(204);
    } catch (err) {
        if (err.status === 404) return res.sendStatus(404);
        next(err);
    }
});

Why this works:

  • Passing an array to where: { id: ids } compiles to IN (...), and the ownerId term is ANDed onto the same WHERE clause, so an ID the caller does not own matches no row. Measured on Sequelize 6.37.8: asking to archive two orders where one belongs to another account returns an affectedCount of 1, and that account's row is unchanged.
  • Comparing the count against the number of IDs requested is what turns a silent partial write into a rejected request. Without it the caller gets a 204 and never learns that half the batch did nothing.
  • The transaction is what makes the check worth making. By the time the count comes back, the caller's own rows have already been updated - rolling back is the only way the endpoint is all-or-nothing.
  • The Mongoose form of the same fix is updateMany({ _id: { $in: ids }, userId: req.user.id }, ...): the owner goes in the filter document rather than being checked afterwards, for the same reason.

Framework-Specific Guidance

Prisma

// VULNERABLE - primary key only; the owner never reaches the query
const order = await prisma.order.findUnique({
    where: { id: Number(req.params.id) },
});
// SECURE - the owner column goes into the same where clause
const order = await prisma.order.findUnique({
    where: { id: Number(req.params.id), ownerId: req.user.id },
});
if (!order) return res.sendStatus(404);

Why this works:

  • findUnique accepts non-unique columns alongside the unique one and ANDs them into the lookup. Measured on Prisma 6.19.3 against SQLite: findUnique({ where: { id, ownerId } }) returns the record for its owner and null for anyone else. The older advice to reach for findFirst here is a workaround for clients before Prisma 5.0, which rejected anything but the unique field - if your client does reject ownerId, that is what you are on, and findFirst({ where: { id, ownerId } }) is the equivalent.
  • Writes take the same composite where. Measured on the same version, update and delete with a cross-owner filter throw PrismaClientKnownRequestError with code P2025, and the row is untouched - so catch P2025 and return 404 rather than letting it surface as a 500 that says "record not found" to the caller. Where a count reads better than an exception, updateMany/deleteMany with the same filter return { count: 0 } instead of throwing.
  • Nothing here depends on the ID being unguessable. It is the WHERE clause doing the work, which is why this survives the ID format changing later.

NestJS

// SECURE - the owner is a required argument, so no caller can omit it
@Injectable()
export class OrdersService {
    async findForOwner(id, ownerId) {
        return this.prisma.order.findUnique({ where: { id, ownerId } });
    }
}

@Controller('orders')
export class OrdersController {
    @Get(':id')
    async findOne(@Param('id', ParseIntPipe) id, @Req() req) {
        const order = await this.orders.findForOwner(id, req.user.id);
        if (!order) throw new NotFoundException();
        return order;
    }
}

Why this works:

  • A guard is the wrong place for the primary fix, and the reason is reachability. @UseGuards() runs for the HTTP handlers it decorates and nothing else: a queue consumer, a scheduled job, a CLI command or another service calling OrdersService directly reaches the same data with no guard in the path. A service method that cannot be called without an owner ID carries the check to all of them.
  • Where a guard is still wanted - for a policy that is genuinely about the request rather than the record - write it for the transport it will run under. Measured on NestJS 11.2.3 with @nestjs/graphql 13.4.5: inside a resolver context.getType() is graphql and context.switchToHttp().getRequest() returns undefined, while GqlExecutionContext.create(context).getContext().req returns the request. A guard written for REST and reused on a resolver therefore reads its user off undefined - it throws rather than denying, and a thrown guard is a 500 that some error handlers turn into a pass.

GraphQL Resolvers

// VULNERABLE - the REST route is scoped, the resolver for the same model is not
const resolvers = {
    Query: {
        order: (_parent, { id }, ctx) => ctx.prisma.order.findUnique({ where: { id } }),
    },
    Order: {
        // Field resolvers are a second entry point: this one loads a related
        // record that no ownership filter has ever been applied to
        customer: (order, _args, ctx) =>
            ctx.prisma.customer.findUnique({ where: { id: order.customerId } }),
    },
};

Why this is vulnerable:

  • A GraphQL endpoint is one route to Express, so per-route ownership middleware written for /orders/:id never runs for it. The resolver is a separate entry point to the same table and needs its own scoping - findUnique({ where: { id, ownerId: ctx.user.id } }). Measured on the NestJS versions above with a guard on the controller and none on the resolver: GET /orders/2 returns 403, and the same record through { order(id:2) { ownerId title } } returns 200 with the row.
  • Field resolvers and DataLoader batches are the half that is missed even after the top-level resolver is fixed. Order.customer runs after the parent was authorized, on the assumption that reaching the parent implies reaching its children, and a batch loader keyed on IDs collects them across every parent in the query. Scope each loader to the caller, or resolve related records through the same owner-filtered service the root resolver uses.
  • The general rule this page opens with is what makes GraphQL survivable: if the ownership filter lives in the data-access layer rather than in the handler, every entry point inherits it - REST route, resolver, field resolver, background job.

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.
  • Malicious input, bulk route: send a list mixing your own IDs with another user's. Assert the response is 404 and that your own records are also unchanged - a 204 with your rows updated and theirs skipped is the partial write the transaction is there to prevent.
  • Malicious input, every entry point: repeat the cross-user IDs against the GraphQL query, each field resolver that returns a related record, and any admin or batch route that reads the same model. A fix applied in the REST handler leaves all of them open.
  • Assert on the GraphQL body, not the status code. Measured on NestJS 11.2.3, a guard denying a resolver still answers HTTP 200, with errors[0].extensions.code of FORBIDDEN and data.order of null - so a test asserting 403 fails against a working guard, and one asserting 200 passes against a broken one. Assert that data.order is null and that no field of the other user's record appears anywhere in the response.

Common Pitfalls

  • Adding the ownership filter to findOne({ _id, userId }) for the primary read route, but a .populate() call on a referenced field (an order's customer, a document's sharedWith list) pulls in a related document that isn't itself scoped, exposing another user's linked record through the populated response.
  • Attaching an ownership-check middleware with router.use(checkOwnership) below the handlers it is meant to cover, or on only one of several router instances - Express middleware applies only to routes registered after it on the router it's attached to, so a route defined above it, or mounted on a separate router, skips the check silently.
  • Fixing findOneAndUpdate({ _id, userId }, ...) for the single-resource update path, then adding a bulk updateMany/bulkWrite operation or a GraphQL mutation resolver for a batch feature that queries the same model without reusing the same filter.

Additional Resources