TL;DR
- AI-generated API endpoints authenticate users but routinely forget ownership checks
- Any logged-in user can read, edit, or delete another user's data by guessing IDs
- One
ifstatement is the difference between a secure API and a data breach
I was doing a code review for a friend's SaaS a few weeks back. Invoice management app, small team, paying customers. The auth was solid. JWT tokens, proper middleware, refresh tokens done right. I was actually impressed until I hit the invoice endpoints.
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});
No ownership check. Any authenticated user could hit /api/invoices/1, /api/invoices/2, all the way up. Customer data, completely exposed to any other customer who figured out the pattern. The app had been live for three months.
The dev built the whole backend with Cursor. Every endpoint had authentication middleware. None of them had authorization.
What IDOR Actually Looks Like
This is IDOR - Insecure Direct Object Reference (CWE-639). The name sounds academic. What it means in practice: your API trusts that any logged-in user should be able to access any resource if they know the ID.
Here's the pattern AI generates consistently:
// GET a document
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);
});
// Update a document
app.put('/api/documents/:id', authenticate, async (req, res) => {
const doc = await Document.findById(req.params.id);
await doc.updateOne(req.body);
res.json({ success: true });
});
// Delete a document
app.delete('/api/documents/:id', authenticate, async (req, res) => {
await Document.findByIdAndDelete(req.params.id);
res.json({ success: true });
});
Authentication passes. The resource exists. Request goes through. What's missing is one check: does req.user.id match doc.userId?
MongoDB ObjectIDs aren't sequential but they're not secret. Auto-increment integer IDs are trivially enumerable. Either way, an attacker with a valid session token can walk through IDs and pull data that isn't theirs.
Why AI Keeps Missing This
The training data explanation is pretty simple. Most tutorial code on the internet - Stack Overflow answers, blog posts, course projects - demonstrates authentication but not authorization. "Here's how to protect a route with a JWT" is a complete tutorial. "Here's how to verify this user owns this specific document" is left as an exercise or skipped entirely.
LLMs learn patterns. The pattern for a protected endpoint is: middleware + fetch by ID + return data. The ownership check doesn't appear consistently enough in training data to become part of the default output.
I've reviewed around 20 AI-generated backends in the past few months. All of them had authentication. About four had proper ownership checks on every endpoint.
The Fix
One line of code. Sometimes two.
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' });
// This is the line AI forgets
if (doc.userId.toString() !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(doc);
});
For shared resources where multiple users can access the same document:
const hasAccess = doc.userId.toString() === req.user.id ||
doc.sharedWith.includes(req.user.id);
if (!hasAccess) return res.status(403).json({ error: 'Forbidden' });
The 404 vs 403 question is worth considering. Returning 404 for unauthorized resources leaks less about what exists in your system. Returning 403 is more transparent about what happened. Pick based on your threat model.
If you have many endpoints, pull the check into middleware so it happens once and every new route gets it automatically:
const requireOwnership = (Model) => async (req, res, next) => {
const doc = await Model.findById(req.params.id);
if (!doc) return res.status(404).json({ error: 'Not found' });
if (doc.userId.toString() !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = doc;
next();
};
// Usage
app.get('/api/documents/:id', authenticate, requireOwnership(Document), (req, res) => {
res.json(req.resource);
});
Quick audit trick: search your codebase for findById, findByPk, or findOne where the filter is just an ID from req.params. Any result without a .userId condition or ownership check nearby is a candidate for review.
I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags missing ownership checks in AI-generated endpoints. That said, the grep approach above will get you most of the way there without any tooling. The important thing is checking before you ship, whatever method you use.
Top comments (0)