TL;DR
- AI editors add auth middleware but skip ownership checks on resource endpoints
- Any authenticated user can read or modify another user's data (CWE-639)
- Fix: one line comparing req.user.id against resource.ownerId before returning data
I've been reviewing vibe-coded side projects lately. Clean code, solid tests, reasonable error handling. Then I check the API routes.
Every endpoint hits the database correctly. None of them verify whether the authenticated user actually owns the resource they're requesting. You can fetch any user's data with a valid JWT and the right resource ID. The right resource ID you can guess, enumerate, or pull from a list endpoint that leaks IDs.
This is IDOR -- Insecure Direct Object Reference (CWE-639). It sits at the top of OWASP's Broken Access Control category, the most common vulnerability class in web apps. AI code generators reproduce it constantly.
The Code AI Writes
Ask Cursor or Claude Code to build a "get user profile" endpoint:
// GET /api/users/:id -- what Cursor generates
app.get('/api/users/:id', authenticate, async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Server error' });
}
});
The authenticate middleware is there. JWT validation runs. The route looks protected. But any user with a valid token can request any other user's profile by guessing or iterating IDs. MongoDB ObjectIDs are not secret. Integer IDs definitely aren't.
Same pattern on update and delete routes. The AI generates CRUD logic correctly every time. It skips the ownership check every time.
Why This Keeps Happening
AI models train on tutorials and Stack Overflow answers. Those sources teach authentication (is this user logged in?) but rarely show resource-level authorization (does this user own this specific record?).
The auth middleware satisfies the "is this endpoint protected" pattern the model recognizes. The ownership check requires understanding the relationship between the authenticated user's ID and the record being fetched. That relationship is business logic, implicit in the data model, not spelled out in any prompt. The model doesn't infer it.
Every route gets the visible security feature. Every route skips the invisible one.
The Fix
// GET /api/users/:id -- with ownership check
app.get('/api/users/:id', authenticate, async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
// This is the line AI-generated code always skips
if (user._id.toString() !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Server error' });
}
});
One comparison. One early return. That's the entire fix.
For admin routes that need cross-user access, gate it explicitly:
if (user._id.toString() !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
To audit an existing codebase fast, grep for route params without a corresponding user ID check:
# Flags routes with URL params that may be missing ownership checks
grep -rn "req.params\." src/routes/ | grep -v "req.user"
Not exhaustive, but it surfaces the obvious misses in under ten seconds.
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 pre-commit hook with semgrep and a custom IDOR rule will catch most of what's in this post. The important thing is catching it early, whatever tool you use.













