Authorization & IDOR
Checking that someone is logged in is the easy half. The half that gets skipped is the one that matters: is this user allowed to touch this specific object? Without that second check, an ID in a URL is a key to everyone’s data.
The mistake in one sentence: an endpoint checks that someone is logged in, then performs a destructive or data-revealing action on any object by ID, without checking whether the logged-in user actually owns, is assigned to, or has permission over that specific object.
Paste into your AI chat
Copy the entire guide as markdown to use as context in Cursor, Claude, ChatGPT, or any AI tool.
Why AI tools generate this
One layer of authorization scaffolds beautifully. The other one has to be reasoned about every time.
Authorization has two layers, and AI scaffolding tends to generate only the first one convincingly. Authentication asks “is this a real, logged-in user?” It’s easy to scaffold because it’s a single reusable check that looks identical on every route, so a model reproduces it faithfully everywhere. Object-level access control asks “is this user allowed to touch this specific object?” That answer is different on every route (ownership? team membership? assignment? role?), so it means actually reasoning about the data model each time. That’s the step that gets skipped when the immediate goal is “make the delete button work.”
The result is a codebase where auth looks consistent, every route carrying the same getUser() call at the top, which paradoxically makes the missing check harder to spot in review. The route has security code. It’s just not the right kind.
Copy-paste drift makes it worse. Once one route correctly implements assignment-scoped access control, a very similar sibling route gets scaffolded by analogy and the assignment check doesn’t make the copy, because the AI (or the developer skimming its output) sees “auth check present” and moves on. One repo in this series had a working, correct assignment-check pattern sitting right next to three routes that never called it.
The three ways this goes wrong
1. DELETE by ID with only an auth check
The handler confirms someone is logged in, then deletes whatever ID it was handed. Nothing checks whose object it is.
2. The same gap on GET and PATCH, silently
A GET /api/libraries/[id] with no ownership check hands any authenticated user another tenant’s private record. PATCH lets them write to it.
3. A cache hit that skips authorization entirely
The cache key is built from the object ID alone, and the ownership check only runs on a miss. A hit serves another user’s data.
Why it’s dangerous
One missing line turns an ID in a URL into a key to everyone’s data.
Cross-tenant data exposure
Any authenticated user, including your cheapest and lowest-trust account tier, can read or modify data belonging to other users, teams, or customers, just by changing an ID in the URL.
Destructive actions at scale
A DELETE with no ownership check is scriptable: iterate IDs 1 through N and delete everything. This is the highest-impact version of the bug, because it’s data loss rather than a read leak.
It’s invisible in normal QA
Every manual test (“can I delete my own thing? yes”) passes. The bug only appears when someone touches an ID that isn’t theirs, deliberately or through a stale bookmark or a shared link.
Enumerable IDs make it worse
Sequential integer IDs or predictable slugs turn this from “you’d have to guess a UUID” into “you can just increment a counter.”
How to check your own app
Run these against your codebase (adjust paths for your stack).
# 1. Find every dynamic-ID route (the classic IDOR shape)
grep -rln "params\.id\|params\[.id.\]\|req\.params\.id\|:id" --include="*.ts" --include="*.tsx" app/api server/src/routes
# 2. For each one, check whether the handler does anything with the ID besides
# an auth check before querying/mutating by that ID. Look for a SECOND
# check — ownership, assignment, or role — after the auth check:
grep -A 20 "getUser(req)\|requireAuth" <route-file> | grep -E "owner|assign|role|can\(|permission"
# If you don't see a second check referencing the object owner/assignment,
# that route is very likely IDOR-vulnerable.
# 3. Specifically flag DELETE and PATCH handlers with only one guard clause —
# these are the highest-impact version of the bug
grep -B5 "\.delete(\|\.destroy(\|admin\.from.*delete" --include="*.ts" -r app/api server/src/routes | grep -B5 "delete" | grep -c "getUser\|requireAuth"
# 4. Look for a cache layer keyed on an object ID without the user ID folded
# into the key — this is the same bug one level down the stack
grep -rn "cache\.\(get\|set\)(" --include="*.ts" . | grep -v "userId\|user_id"Then do it by hand for the routes that matter most. Pick your 5 most sensitive object types (whatever holds customer data, holds billing info, or is destructive) and write down, in plain English, what proves this specific user is allowed to touch this specific object. When the answer is “nothing, we just checked they’re logged in,” you’ve found the bug.
The fix
Centralise object-level access checks into one helper, and make every route that takes an :id call it. The specific rule depends on your data model, but the shape is always the same: look up the ownership or assignment relationship, and 403 when it doesn’t include this user. Separately from, and after, the authentication check.
Authenticate the user, then authorize them against this exact object.
Shared, reused everywhere. A fix in one place fixes every route.
User ID in the key, ownership checked before the read.
One shared access-control helper
Look up the ownership or assignment relationship and throw if it doesn’t include this user. Separate from, and after, the authentication check.
// lib/access-control.ts — one place, reused everywhere
export async function assertObjectAccess(
userId: string,
userRole: string,
objectId: string,
action: 'view' | 'edit' | 'delete'
): Promise<void> {
// Elevated roles bypass the assignment check but NOT the permission check
if (!roleCan(userRole, action)) {
throw new ForbiddenError(`Role ${userRole} cannot ${action}`);
}
// Roles that are scoped to specific objects (not global admins) must have
// an explicit assignment row — this is the check that was missing above
if (isScopedRole(userRole)) {
const assignment = await db.userAssignments.findFirst({
where: { userId, objectId },
});
if (!assignment) {
throw new ForbiddenError('Not assigned to this object');
}
}
}The fixed route
Authenticate, then authorize this specific object, then act. Same happy path, now closed to everyone else’s IDs.
// app/api/documents/[id]/route.ts — fixed
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const user = await getUser(req);
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
try {
await assertObjectAccess(user.id, user.role, params.id, 'delete');
} catch (e) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
await db.documents.delete({ where: { id: params.id } });
return Response.json({ success: true });
}Scope the cache to the requester
Fold the user ID into every cache key that could hold user-specific content, and check ownership before the cache read, never after.
// Broken: cache key doesn't include userId, and the ownership check
// only runs on a cache MISS — a cache HIT skips authorization entirely
const cached = await redis.get(`thumbnail:${videoId}`);
if (cached) return cached; // <-- returns another user's signed URL if they share a videoId
// Fixed: scope the key to the requester, and check ownership before any cache read
async function getThumbnail(userId: string, videoId: string) {
await assertObjectAccess(userId, role, videoId, 'view'); // check FIRST
const cacheKey = `thumbnail:${userId}:${videoId}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
// ...generate, cache under the scoped key, return
}Checklist
Verify each item before you ship a route that takes an object ID.
- Every route that takes an object ID (:id, [id]) has an authorization check in addition to an authentication check: ownership, team/assignment membership, or role, verified against the database.
- DELETE and PATCH handlers are audited first. They’re the highest-impact version of this bug.
- Object-level access checks live in one shared helper or middleware rather than being copy-pasted per route, so a fix in one place fixes it everywhere.
- Any cache keyed on an object ID also includes the requesting user’s ID in the key, or isn’t used for anything that varies per user.
- Cache reads happen after an authorization check, never before.
- You’ve picked your 5 most sensitive object types and can articulate, for each, exactly what proves a given user may touch a given object.
- IDs for sensitive objects aren’t sequential or guessable where that matters. This is defence in depth, not a substitute for the access check above.
Prompt your AI assistant
Paste this to have your AI tool audit, not fix, your object-level access control.
Audit this repository for IDOR (Insecure Direct Object Reference) and missing
object-level authorization.
For every API route that accepts an object ID (a URL path param like [id] or
:id, or an ID field in the request body), check:
1. Does the handler verify the requesting user is authenticated? (First gate.)
2. SEPARATELY, does it verify the requesting user actually owns, is assigned
to, or has a role-based right over THIS SPECIFIC object — via a database
lookup (an ownership column, an assignment/junction table, or an
equivalent check) — before reading, writing, or deleting it? Flag any
route that has gate 1 but not gate 2.
3. Prioritize DELETE and PATCH/PUT handlers — these are the highest-impact
version of this bug.
4. Check any caching layer (Redis, in-memory, CDN) for cache keys built only
from an object ID without the requesting user's ID folded in, and confirm
the authorization check happens before the cache is read, not after.
5. Check whether object-level access logic is duplicated across many similar
routes (e.g. several `[id]/route.ts` files) versus centralized in one
shared helper — duplication is how these gaps slip in silently when a new
similar route is added later.
For each finding: file and line, a concrete exploit scenario (what would an
authenticated-but-unauthorized user be able to do), and a fix that reuses any
existing correct access-control pattern already present elsewhere in this
codebase. Do not fix anything yet — just report.Want the full guide as context for your AI coding assistant?