DEV Community

Cover image for IDOR in AI-Generated APIs: The Ownership Check Cursor Always Skips
Charles Kern
Charles Kern

Posted on

IDOR in AI-Generated APIs: The Ownership Check Cursor Always Skips

TL;DR

  • AI tools generate authenticated routes but routinely skip ownership validation -- any logged-in user can access any resource by ID
  • This is CWE-639 (IDOR / Broken Access Control) and it's the most common bug class I find in Cursor-generated APIs
  • One check after every findById call fixes the entire pattern

I reviewed a friend's side project last month. Solid app -- JWT auth, protected routes, refresh token rotation. Then I ran a quick test: logged in as User A, grabbed a document ID from the URL, opened a private tab as User B, and requested the same endpoint.

User A's data came back clean.

He'd built the whole backend in Cursor. The AI had done a genuinely good job with authentication -- middleware, token validation, all wired up correctly. But every single resource endpoint had the same gap: it checked whether you were logged in. It never checked whether the resource belonged to you.

That's CWE-639. Authorization Bypass Through User-Controlled Key. OWASP Top 10, A01: Broken Access Control. And AI code generators reproduce it at scale.

The Vulnerable Pattern

Here's what Cursor generates for a document endpoint:

// CWE-639: authenticated but no ownership check
app.get('/api/documents/:id', authenticate, async (req, res) => {
  const doc = await Document.findById(req.params.id);
  if (!doc) return res.status(404).json({ error: 'Not found' });
  res.json(doc);
});
Enter fullscreen mode Exit fullscreen mode

Authentication passes. The route looks protected. But swap :id for any valid document ID in the database and you get the data -- regardless of who owns it. Change the number. Get the record. That's IDOR.

The same pattern shows up in PUT and DELETE routes. Cursor wires up authenticate correctly every time. It skips the one line that makes the route actually private.

I've seen this pattern in payment record endpoints, private message threads, medical note APIs. The auth middleware is there. The access control is not.

Why This Keeps Happening

The reason is boring. AI models train on tutorials, StackOverflow answers, and open-source repos. Most of that code is written to teach how authentication works -- JWT validation, session middleware, token refresh. It demonstrates the concept correctly.

What tutorial code almost never models: the ownership check after the fetch. That's assumed to be obvious. It's left as an exercise. The post is about JWTs, not about who owns the document.

The model learned the template. It didn't learn the gap.

The Fix

One check. After every resource fetch:

// Fixed: ownership validated after fetch
app.get('/api/documents/:id', authenticate, async (req, res) => {
  const doc = await Document.findById(req.params.id);
  if (!doc || doc.userId.toString() !== req.user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  res.json(doc);
});
Enter fullscreen mode Exit fullscreen mode

Two notes on this:

Return 403, not 404. Returning 404 when "the document exists but isn't yours" leaks less about what IDs exist. Some teams prefer it. Either way, the ownership check is what matters.

For larger codebases, a policy layer (CASL, Casbin, or a simple assertOwnership(doc, req.user) helper) is cleaner than repeating this inline everywhere. But even the raw version above eliminates the bug class entirely.

A quick semgrep rule or grep for findById without an ownership check in the same function scope will surface every unprotected endpoint in a codebase. Takes about 15 seconds to run.

I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags these patterns before I move on. That said, even a basic semgrep rule targeting findById without ownership assertions will catch most of what's in this post. The important thing is catching it before it ships, whatever tool you use.

Top comments (0)