IDOR (Insecure Direct Object Reference) is consistently one of the most commonly found real-world vulnerabilities, and consistently one of the easiest to miss in code review - it doesn't look wrong. The code runs, returns the right shape of data, and passes every functional test. The only thing missing is a check that the object being returned actually belongs to the person asking for it. This is a practical, go-through-it checklist rather than an explainer - if you want the conceptual background first, see our What Is IDOR? guide.
1. Every Route That Takes an ID Needs an Ownership Check
Not just the routes that feel sensitive. Order history, uploaded files, draft posts, notification preferences, exported reports - anything keyed by an ID that a client supplies (in the URL, a query parameter, a request body, or a cookie) needs an explicit check that the authenticated user is allowed to access that specific record, not just that they're logged in.
// Missing check - any authenticated user, any ID
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});
// Present - scoped to the requester
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({ _id: req.params.id, userId: req.user.id });
if (!invoice) return res.status(404).end();
res.json(invoice);
});
2. Check Write Operations, Not Just Reads
Teams that remember to scope GET requests often forget that PUT, PATCH, and DELETE need the identical check - an IDOR on a delete or update endpoint is more damaging than one on a read endpoint, and it's protected by the exact same missing line of code.
3. Don't Trust IDs Passed Through Hidden Form Fields or Request Bodies
A user ID or account ID sent as a hidden form field, and used server-side to decide whose data to modify, is exactly as attacker-controlled as one in the URL - anyone can edit form data before submitting. Derive identity from the authenticated session, never from a value the client also sent you.
// Vulnerable: trusts a client-supplied user_id to decide whose profile to update
app.post('/api/profile/update', requireAuth, (req, res) => {
updateProfile(req.body.user_id, req.body.changes); // attacker sets user_id to anyone
});
// Fixed: identity comes from the verified session, never from the request body
app.post('/api/profile/update', requireAuth, (req, res) => {
updateProfile(req.user.id, req.body.changes);
});
4. Use Non-Sequential, Non-Guessable IDs Where It's Cheap To
This is not a substitute for the ownership check above - an attacker who obtains one valid UUID through a leak or a referral link is still fully blocked by a real authorization check, and sequential IDs behind a real check are still safe. But unguessable identifiers (UUIDs instead of auto-increment integers) remove the trivial "try id=1, id=2, id=3" enumeration path as a first line of defense, buying time and reducing blast radius if an authorization check is ever missed elsewhere.
5. Test With a Second Real Account, Not Just Your Own
The single most effective IDOR test costs nothing and takes minutes: create two accounts, generate a resource as account A, then try to access, edit, and delete that exact resource while authenticated as account B. Automated functional tests almost never do this because they're written from the perspective of one user acting on their own data - which is precisely the case that was never broken.
6. Apply the Same Checks to Admin and Internal Tools
Internal dashboards and admin panels get less security scrutiny because "only staff can access them" - but broken object-level authorization inside an admin tool still lets any staff account (or anyone who compromises one staff account) reach every customer's data, not just their assigned scope. Role-scoped admin panels (support agents seeing only their assigned tickets, for example) need the identical ownership-check discipline as customer-facing routes.
7. Log and Alert on Authorization Failures, Not Just Successes
A spike in 403/404 responses to sequentially-incrementing IDs from one account is a strong signal of active IDOR probing - most applications log successful requests in detail and authorization failures barely at all, which means this exact attack pattern is often invisible until after the fact. Logging denied authorization attempts (not just errors) makes it visible while it's happening.
8. Re-Check on Every Request, Not Just on First Load
A resource can change ownership, get shared, or get revoked between when a page loads and when a subsequent action fires - checking authorization only when a page first renders and then trusting client-side state for every action after that (a delete button that stays visible because the page hasn't refreshed) reopens the same gap dynamically.
Broken Access Control at a Glance
IDOR is one specific, very common instance of the broader OWASP A01 category - see our Broken Access Control guide for the full range of access-control failures beyond object references specifically (missing function-level checks, CORS misconfiguration, privilege escalation via parameter tampering).
Verifying This From the Outside
Shieldome's scanner tests authenticated routes for object-level authorization gaps by comparing responses across accounts and probing ID patterns for exposed resources. Run a free scan to see where your application's own checklist gaps are.