TL;DR
- AI-generated CRUD endpoints routinely skip ownership checks (CWE-639)
- Any authenticated user can read, modify, or delete another user's data
- Fix: one ownership check before every data access, no exceptions
I've been reviewing AI-assisted codebases for a while now, and one pattern keeps showing up more than any other. Not SQL injection, not hardcoded secrets -- those get caught. The one that slips through is IDOR: Insecure Direct Object Reference.
Here's what it looks like. You ask Cursor to build a task detail endpoint. It generates this:
// ❌ CWE-639: No ownership check
app.get('/api/tasks/:id', authenticate, async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) return res.status(404).json({ error: 'Not found' });
res.json(task);
});
The authenticate middleware runs. JWT is validated. The user is "authenticated." But the handler fetches whatever ID was in the URL and returns it -- no check that task.userId === req.user.id.
An attacker with a valid account can iterate IDs: /api/tasks/1, /api/tasks/2, /api/tasks/3. They pull every record in the database. The same flaw shows up on write routes:
// ❌ CWE-639: Anyone can overwrite anyone's data
app.put('/api/tasks/:id', authenticate, async (req, res) => {
const task = await Task.findByIdAndUpdate(req.params.id, req.body, { new: true });
res.json(task);
});
Any authenticated user can update any task. Delete any record. IDOR has been in the OWASP Top 10 for a reason.
Why AI Keeps Getting This Wrong
Most authentication examples online show JWT validation and middleware setup. The ownership check is domain-specific -- it depends on the data model -- so it rarely appears in generic tutorials or StackOverflow answers.
AI models learn those patterns. They know to add authenticate middleware. They don't consistently learn that authentication (who you are) and authorization (what you can access) are separate concerns requiring separate checks.
Authentication: are you logged in?
Authorization: is this yours?
Cursor handles the first. It skips the second.
The Fix
One ownership check per data operation:
// ✅ Verify ownership after fetching
app.get('/api/tasks/:id', authenticate, async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) return res.status(404).json({ error: 'Not found' });
if (task.userId.toString() !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(task);
});
Return 403, not 404, on access denial. 404 makes the resource appear nonexistent -- useful in some threat models, confusing in most production apps.
For write operations, enforce ownership directly in the database query -- atomic, no race condition:
// ✅ Ownership enforced at query level
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, userId: req.user.id },
req.body,
{ new: true }
);
if (!task) return res.status(404).json({ error: 'Not found or forbidden' });
The pattern applies beyond tasks. Files, comments, profiles, settings -- any resource tied to a user needs an ownership check on every read, write, and delete. One check. Every time. No exceptions.
I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags missing ownership checks before I move on. That said, even a basic pre-commit hook with semgrep rules for CWE-639 will catch most of what's in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)