Tech 7 min read

IDOR in AI-generated APIs and 3 patterns Cursor misses

IkesanContents

When I looked into NVIDIA NIM’s free OpenAI-compatible API, the focus was on how to call models from OpenClaw or Cursor. This time it’s the other side of the coin: when you have an AI editor generate an API, is that API actually safe in a multi-user environment?

DEV Community published AI-Generated APIs Have an IDOR Problem: 3 Patterns Cursor Misses. The claim is straightforward: Cursor and Claude Code are good at inserting authentication middleware like authenticateToken, but they tend to miss checking whether the id in /api/documents/:id actually belongs to the logged-in user.

This is a classic vulnerability called IDOR (Insecure Direct Object Reference). In the OWASP API Security Top 10, it sits at API1:2023 as BOLA (Broken Object Level Authorization). The issue is not “is the user logged in?” but “is the user allowed to operate on this specific object?”---and the server doesn’t verify it.

Authenticated yet reading someone else’s data

The original article shows an example like this:

app.get('/api/documents/:id', authenticateToken, async (req, res) => {
  const doc = await Document.findById(req.params.id);
  res.json(doc);
});

With authenticateToken in place, this looks protected at first glance. But when someone passes another user’s document ID into req.params.id, there’s no comparison between doc.userId and req.user.id. As long as you’re logged in, you can guess or enumerate IDs to read other people’s data.

What makes this bug tricky is that tests pass easily. “Log in and fetch my own document” succeeds. Unless you also write “log in and fail to fetch someone else’s document,” the generated code just works.

flowchart TD
    A["Logged-in user<br/>user_id = alice"] --> B["GET /api/documents/bob_doc_id"]
    B --> C["authenticateToken passes"]
    C --> D["Document.findById(id)"]
    D --> E["Returns bob's document"]

Patterns Cursor tends to miss

The original article identifies three patterns:

PatternTypical generated codeWhat happens
No ownership checkfindById(req.params.id)Can read another user’s records
Unscoped deletefindByIdAndDelete(req.params.id)Can delete another user’s posts or documents
Missing child-resource scopingComment.find({ postId })Can extract comments attached to private posts

The third one is subtly dangerous. Even if the parent post has an ownership check, fetching child resources via /api/posts/:id/comments with only a postId filter leaks information through that endpoint.

app.get('/api/posts/:id/comments', authenticateToken, async (req, res) => {
  const comments = await Comment.find({ postId: req.params.id });
  res.json(comments);
});

For a public blog this might not matter. But for private notes, internal documents, invoices, chat, or project management tools, it’s a different story. “Comments are lighter data than the post body” isn’t the right framing---comment lists still expose author names, timestamps, internal context, and attachment IDs.

IDOR is easy to miss when you only look at CWE-862

The original article frames this as CWE-862 (Missing Authorization). MITRE’s CWE-862 covers the weakness of not performing an authorization check on a subject attempting to access a resource or operation.

In practice, looking at it through the lens of OWASP’s BOLA is more actionable for API review. BOLA zeroes in on the problem of “the API trusts the object ID it receives.” Any API that pulls from the database using an ID from a path parameter, query string, request body, or header is in scope.

AspectWhat it checks
AuthenticationWho is the requester?
Function-level authorizationCan this user call this API function?
Object-level authorizationCan this user read this one record?
Property-level authorizationCan this user see this field?

AI editors handle the top two well. Middleware like authenticateToken, requireAdmin, and requireRole("manager") is straightforward as code. But “does this invoice_id belong to the current tenant?” requires reading the domain model.

The fix: scope ownership into the DB query

The original article’s approach is not “fetch first, then check” but “scope the DB query by owner from the start.”

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

if (!doc) {
  return res.status(404).json({ error: 'Not found' });
}

This way, even if someone passes another user’s ID, the database returns nothing. Returning 404 also avoids leaking whether that ID exists at all. Returning 403 is another option, but it tells the requester “this ID exists, you just don’t have access.”

Same idea for deletes:

const deleted = await Post.findOneAndDelete({
  _id: req.params.id,
  userId: req.user.id,
});

if (!deleted) {
  return res.status(404).json({ error: 'Not found' });
}

For child resources, either check the parent’s owner first, or include tenant/owner filtering in the JOIN or lookup. In MongoDB, keep postId and ownerId in the same collection. In an RDB, add posts.user_id = current_user.id to the JOIN condition. The point is to stop fetching comments by postId alone.

The rules you give the AI editor can be short

The original article recommends telling Cursor and Claude Code’s rules files to use owner-scoped findOne instead of bare findById. Concrete banned and required patterns work better than abstract “make it secure.”

For Node/Express/Mongoose, a rule this short is enough:

API route that reads, updates, deletes, or lists user-owned resources must scope database queries by the current user or tenant.

Do not use findById(req.params.id) for user-owned resources.
Use findOne({ _id: req.params.id, userId: req.user.id }) or tenant-scoped equivalents.
Return 404 when the scoped query returns no record.
Add a negative test where user A cannot access user B's resource.

For tenant-based SaaS, replace userId with tenantId or organizationId. If you have RBAC, don’t stop at role alone---check “does this role apply to this resource within this organization?” Leave that vague and you get incidents where a role named admin leaks across tenants.

Switching models doesn’t eliminate IDOR

IDOR doesn’t go away when you swap models or endpoints. Whether it’s Claude, Cursor Composer, or a GPT-based model, if the generated API returns another user’s record via findById, it’s the same vulnerability. The more agents sit at the center of the IDE---like Cursor 3---the more often humans need to review the gap between “the generated code runs” and “the permission boundary is correct.”

The input validation gaps found in the security scan of 50 MCP servers have a similar structure. Thin API wrappers and tool servers written by AI tend to be the shortest code that passes the happy-path demo. Where multi-user, multi-tenant, destructive operations, and private resources intersect, read the generated code separately right after generation.

There aren’t many places to check:

API shapeWhat to verify
GET /resources/:idScoped by userId / tenantId, not just _id
PATCH /resources/:idUpdate target fetched within owner scope
DELETE /resources/:idDelete condition includes owner scope
GET /parents/:id/childrenParent’s owner verified before fetching children
List endpointsNot just find({})

If you’re delegating to an AI editor, always have it add a test for “user A cannot read/update/delete user B’s resource.” An API without that one test isn’t trustworthy, no matter how clean the auth middleware looks.