TL;DR
- AI editors generate API endpoints with authentication but no ownership checks
- A logged-in user can fetch any other user's data by changing one number in the URL
- Fix: always validate
req.user.id === resource.userIdbefore returning data
I reviewed a side project last month. Node/Express backend, Cursor-generated, looked clean. Real auth middleware. JWT tokens. Login worked fine. Then I changed the user ID in the URL to a different number. Different user's data came back. No error. No log entry.
The dev had no idea. Cursor flagged nothing. The endpoint had authentication -- just no authorization at the resource level.
This is IDOR: Insecure Direct Object Reference (CWE-639). It is not exotic. It is the most boring, most common authorization failure in AI-generated APIs, and it almost never gets caught before production.
The Pattern AI Keeps Generating
Ask any AI editor to write a "get user profile" endpoint and you get something like this:
// CWE-639: any authenticated user can fetch any profile
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
The authenticate middleware runs. The token is verified. The user is "logged in." But the endpoint returns any user's data for any valid ID. User 1 can fetch user 2's profile. User 2 can fetch user 100's orders. Every record in the database is one URL change away from exposure.
Why does AI generate this? Training data. Stack Overflow answers, GitHub tutorials, and "getting started" guides skip ownership checks in example code. The model learned from those examples. It reproduces the gap every time.
Why Standard Scanners Miss It
Authentication and authorization are different. Authentication: who are you? Authorization: what are you allowed to touch?
AI-generated code handles authentication correctly almost every time -- middleware, JWT validation, session checks, all generated properly. The gap is always at the resource level.
SAST tools look for SQL injection, XSS, missing crypto. They do not check business logic: "should this specific user be reading this specific record?" IDOR stays in the OWASP Top 10 year after year precisely because static analysis cannot catch it.
The Fix
Two parts: check ownership, return 403 when the check fails.
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
// ownership check -- the line AI always omits
if (user._id.toString() !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(user);
});
Return 403, not 404. Returning 404 hides whether the record exists -- but it also makes your API silently lie. For admin routes where any authenticated user should access any record, add a role check instead of removing the ownership check entirely.
Python/FastAPI version:
@app.get("/api/users/{user_id}")
async def get_user(user_id: int, current_user: User = Depends(get_current_user)):
user = await User.get(user_id)
if not user:
raise HTTPException(status_code=404)
if user.id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return user
Order matters. Fetch the resource first so you can compare IDs. Check ownership before returning.
Finding Existing IDOR Bugs in a Codebase
Grep for route handlers with dynamic parameters and no ownership check logic:
# Express -- routes that use :id params but never reference req.user
grep -rn "req.params.id" ./src --include="*.js" | grep -v "req.user"
# FastAPI -- route handlers with path params but no current_user comparison
grep -rn "async def get_" ./app --include="*.py" | grep -v "current_user.id"
These greps are rough. Any match is a candidate for manual review, not a confirmed bug. But on a 20-file backend, this takes 10 seconds and surfaces the ones worth looking at.
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 gitleaks will catch most of what's in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)