IDOR & Access Control Code Review Guide
Table of Contents
Introduction
Broken Access Control climbed to the #1 position in the OWASP Top 10 (2021), overtaking injection vulnerabilities. Insecure Direct Object Reference (IDOR) is the most common manifestation—an attacker simply changes a parameter value (an ID, filename, or key) in a request to access resources belonging to other users.
Unlike injection attacks that require crafting malicious payloads, IDOR exploits are trivially simple: change /api/invoices/1001 to /api/invoices/1002. If the server returns another user's invoice, you have an IDOR. This simplicity makes it one of the most exploited vulnerabilities in bug bounty programs and real-world breaches.
Why IDOR Is So Dangerous
IDOR vulnerabilities are easy to discover, trivial to exploit, and often lead to mass data exfiltration. Automated scanners frequently miss them because the responses look identical to legitimate ones—only the data changes. This means IDOR is primarily caught during code review and manual testing.
IDOR & Access Control Vulnerability Types
- • Access other users' data
- • Enumerable resource IDs
- • Missing ownership checks
- • Same privilege level
- • Access admin endpoints
- • Modify role/permissions
- • Missing role-based checks
- • Higher privilege level
- • Excessive data in responses
- • Unfiltered query results
- • Debug info exposed
- • Predictable URLs
Impact: IDOR and broken access control were the #1 vulnerability in the OWASP Top 10 (2021). They lead to mass data breaches, unauthorized modifications, and complete account takeover.
Why did Broken Access Control move to #1 in the OWASP Top 10?
Understanding IDOR
An Insecure Direct Object Reference occurs when an application uses user-supplied input to directly access objects (database records, files, etc.) without verifying the user is authorized to access that specific object. The "direct" reference is the key—the application exposes internal implementation details like database IDs.
The Simplest IDOR — No Authorization Check
1// VULNERABLE: No ownership check at all
2app.get('/api/users/:id/profile', async (req, res) => {
3 const user = await User.findById(req.params.id);
4 if (!user) return res.status(404).json({ error: 'Not found' });
5
6 // Returns ANY user's profile, including email, phone, address
7 res.json(user);
8});
9
10// Attacker simply iterates:
11// GET /api/users/1/profile → gets user 1's data
12// GET /api/users/2/profile → gets user 2's data
13// GET /api/users/3/profile → gets user 3's data
14// ... scrapes entire user databaseSecure: Ownership Verification
1// SECURE: Verify the requesting user owns the resource
2app.get('/api/users/:id/profile', authenticate, async (req, res) => {
3 // Check that the authenticated user is requesting their own profile
4 if (req.user.id !== parseInt(req.params.id)) {
5 return res.status(403).json({ error: 'Forbidden' });
6 }
7
8 const user = await User.findById(req.params.id);
9 if (!user) return res.status(404).json({ error: 'Not found' });
10
11 res.json(filterSensitiveFields(user));
12});
13
14// EVEN BETTER: Don't use user-supplied ID at all
15app.get('/api/profile', authenticate, async (req, res) => {
16 // Use the authenticated user's ID from the session/token
17 const user = await User.findById(req.user.id);
18 res.json(filterSensitiveFields(user));
19});Code Review Pattern
During code review, look for any route handler that takes a resource ID as a parameter and then queries the database with it. If there is no check comparing the resource owner to the authenticated user, it is almost certainly vulnerable to IDOR.
Common IDOR Locations
| Location | Example | Risk |
|---|---|---|
| URL path params | /api/orders/12345 | Direct resource access |
| Query strings | /api/docs?id=12345 | Easy to modify |
| Request body | {"invoice_id": 12345} | Hidden but exploitable |
| HTTP headers | X-User-Id: 12345 | Custom headers are untrusted |
| File references | /uploads/user_12345/report.pdf | File path traversal + IDOR |
| Cookies | user_id=12345 (unsigned) | Client-controlled values |
Which approach best prevents IDOR on a 'view my profile' endpoint?
Horizontal IDOR
Horizontal IDOR occurs when a user can access resources belonging to another user at the same privilege level. This is the most common form of IDOR and often leads to mass data exposure.
Vulnerable: Order History Without Ownership Check
1// VULNERABLE: User can view anyone's orders
2app.get('/api/orders/:orderId', authenticate, async (req, res) => {
3 const order = await Order.findById(req.params.orderId);
4 if (!order) return res.status(404).json({ error: 'Not found' });
5
6 // BUG: Checks authentication but NOT authorization
7 // Any logged-in user can view any order
8 res.json(order);
9});
10
11// VULNERABLE: Relying on client-side filtering
12app.get('/api/orders', authenticate, async (req, res) => {
13 // Fetches ALL orders, relies on frontend to filter
14 const orders = await Order.find({});
15 res.json(orders);
16 // The client-side code filters by user, but the API returns everything
17});
18
19// VULNERABLE: User ID in the query but not validated
20app.get('/api/orders', authenticate, async (req, res) => {
21 const userId = req.query.userId; // Attacker changes this!
22 const orders = await Order.find({ userId });
23 res.json(orders);
24});Secure: Server-Side Ownership Enforcement
1// SECURE: Filter by authenticated user at the database level
2app.get('/api/orders', authenticate, async (req, res) => {
3 // Always use the authenticated user's ID from the session
4 const orders = await Order.find({ userId: req.user.id });
5 res.json(orders);
6});
7
8// SECURE: Ownership check for individual resources
9app.get('/api/orders/:orderId', authenticate, async (req, res) => {
10 const order = await Order.findOne({
11 _id: req.params.orderId,
12 userId: req.user.id, // Compound query ensures ownership
13 });
14
15 if (!order) {
16 // Return 404, not 403, to avoid revealing resource existence
17 return res.status(404).json({ error: 'Not found' });
18 }
19
20 res.json(order);
21});404 vs 403 — Information Leakage
When a user requests a resource they don't own, return 404 Not Found rather than 403 Forbidden. A 403 confirms the resource exists, which helps attackers enumerate valid IDs. A 404 reveals nothing about whether the resource exists.
Vulnerable: Document Download IDOR
1# VULNERABLE: Flask document download
2@app.route('/api/documents/<int:doc_id>/download')
3@login_required
4def download_document(doc_id):
5 doc = Document.query.get_or_404(doc_id)
6 # No ownership check! Any user can download any document
7 return send_file(doc.file_path)
8
9# SECURE: Ownership verification
10@app.route('/api/documents/<int:doc_id>/download')
11@login_required
12def download_document(doc_id):
13 doc = Document.query.filter_by(
14 id=doc_id,
15 owner_id=current_user.id # Enforce ownership
16 ).first()
17
18 if not doc:
19 abort(404)
20
21 return send_file(doc.file_path)Why should you return 404 instead of 403 when a user tries to access another user's resource?
Vertical Privilege Escalation
Vertical privilege escalation occurs when a regular user can perform actions reserved for administrators or other higher-privileged roles. This typically happens when admin endpoints lack proper role-based authorization checks.
Vulnerable: Admin Endpoints Without Role Checks
1// VULNERABLE: Admin panel accessible to any authenticated user
2app.get('/api/admin/users', authenticate, async (req, res) => {
3 // Only checks authentication, not authorization!
4 const users = await User.find({});
5 res.json(users);
6});
7
8// VULNERABLE: Role check only on the frontend
9// Frontend hides the "Admin" menu item for non-admins,
10// but the API is still accessible
11app.delete('/api/admin/users/:id', authenticate, async (req, res) => {
12 await User.findByIdAndDelete(req.params.id);
13 res.json({ message: 'User deleted' });
14});
15
16// VULNERABLE: Role stored in client-controlled JWT without verification
17app.put('/api/admin/settings', (req, res) => {
18 const token = jwt.decode(req.headers.authorization);
19 // jwt.decode does NOT verify! Attacker can forge the token
20 if (token.role === 'admin') {
21 updateSettings(req.body);
22 }
23});
24
25// VULNERABLE: Role check via user-supplied parameter
26app.post('/api/admin/action', authenticate, async (req, res) => {
27 if (req.body.isAdmin) { // Attacker just sends isAdmin: true
28 performAdminAction();
29 }
30});Secure: Role-Based Access Control
1// Middleware: Verify role from server-side session/database
2function requireRole(...roles) {
3 return async (req, res, next) => {
4 // Load role from database, NOT from request
5 const user = await User.findById(req.user.id).select('role');
6
7 if (!user || !roles.includes(user.role)) {
8 return res.status(404).json({ error: 'Not found' });
9 }
10
11 req.userRole = user.role;
12 next();
13 };
14}
15
16// SECURE: Admin endpoints with role enforcement
17app.get('/api/admin/users',
18 authenticate,
19 requireRole('admin', 'super_admin'),
20 async (req, res) => {
21 const users = await User.find({});
22 res.json(users);
23 }
24);
25
26// SECURE: Action logging for audit trail
27app.delete('/api/admin/users/:id',
28 authenticate,
29 requireRole('admin'),
30 async (req, res) => {
31 await AuditLog.create({
32 action: 'DELETE_USER',
33 targetUserId: req.params.id,
34 performedBy: req.user.id,
35 timestamp: new Date(),
36 ip: req.ip,
37 });
38
39 await User.findByIdAndDelete(req.params.id);
40 res.json({ message: 'User deleted' });
41 }
42);Correct Access Control Flow
- Never rely on client-side role checks — Hide UI elements for UX, but enforce all permissions server-side
- Always verify roles from the database — Don't trust JWT claims or request parameters for authorization decisions
- Use deny-by-default — Block access unless explicitly granted, rather than allowing access unless explicitly blocked
- Log privilege-sensitive operations — Create audit trails for all admin actions to detect abuse
- Implement separation of duties — No single role should have unrestricted access to all operations
What is the most secure way to check a user's role for authorization?
API Access Control Flaws
Modern applications expose many API endpoints, and each one is a potential access control failure point. REST APIs, GraphQL resolvers, and internal microservice endpoints all need consistent authorization enforcement.
Vulnerable: REST API Without Consistent Authorization
1// VULNERABLE: Inconsistent access control across HTTP methods
2// GET is protected, but PUT and DELETE are not!
3app.get('/api/posts/:id', authenticate, authorizeOwner, getPost);
4app.put('/api/posts/:id', authenticate, updatePost); // Missing authorizeOwner!
5app.delete('/api/posts/:id', authenticate, deletePost); // Missing authorizeOwner!
6
7// VULNERABLE: Bulk endpoint bypasses single-item checks
8app.get('/api/invoices/:id', authenticate, checkOwnership, getInvoice);
9// The bulk endpoint has NO authorization — returns ALL invoices
10app.get('/api/invoices/export', authenticate, exportAllInvoices);
11
12// VULNERABLE: GraphQL resolver without field-level authorization
13const resolvers = {
14 Query: {
15 user: async (_, { id }) => {
16 // Returns ALL fields including sensitive ones
17 return await User.findById(id);
18 },
19 },
20 User: {
21 // No field-level resolvers to restrict access
22 // email, ssn, salary all returned to any requester
23 },
24};Secure: Consistent API Authorization Pattern
1// SECURE: Policy-based authorization middleware
2const policies = {
3 'posts:read': (user, resource) => true, // Public read
4 'posts:update': (user, resource) => resource.authorId === user.id,
5 'posts:delete': (user, resource) =>
6 resource.authorId === user.id || user.role === 'admin',
7};
8
9function authorize(action) {
10 return async (req, res, next) => {
11 const resource = await getResource(req);
12 const policy = policies[action];
13
14 if (!policy || !policy(req.user, resource)) {
15 return res.status(404).json({ error: 'Not found' });
16 }
17
18 req.resource = resource;
19 next();
20 };
21}
22
23// Consistent enforcement on all methods
24app.get('/api/posts/:id', authenticate, authorize('posts:read'), getPost);
25app.put('/api/posts/:id', authenticate, authorize('posts:update'), updatePost);
26app.delete('/api/posts/:id', authenticate, authorize('posts:delete'), deletePost);
27
28// SECURE: GraphQL with field-level authorization
29const resolvers = {
30 Query: {
31 user: async (_, { id }, context) => {
32 const user = await User.findById(id);
33 if (!user) return null;
34 return user;
35 },
36 },
37 User: {
38 email: (user, _, context) => {
39 if (context.user.id === user.id || context.user.role === 'admin') {
40 return user.email;
41 }
42 return null; // Mask for unauthorized viewers
43 },
44 ssn: (user, _, context) => {
45 if (context.user.role === 'admin') {
46 return user.ssn;
47 }
48 return null;
49 },
50 },
51};API Access Control Checklist
| Check | Description | Risk if Missing |
|---|---|---|
| Authentication | Is the user logged in? | Anonymous access to protected data |
| Authorization | Does the user have the right role? | Vertical privilege escalation |
| Ownership | Does the user own this resource? | Horizontal IDOR |
| Field filtering | Are sensitive fields stripped? | Data leakage |
| Rate limiting | Are bulk operations throttled? | Mass enumeration |
| Audit logging | Are access attempts logged? | No breach detection |
An API protects GET /api/invoices/:id with authorization, but the POST /api/invoices/export endpoint has no check. What type of vulnerability is this?
Prevention Techniques
Preventing IDOR and access control flaws requires a defense-in-depth approach. No single technique is sufficient—you need multiple layers working together.
Pattern 1: Indirect Object References
1// Instead of exposing database IDs, use per-session indirect references
2
3// Map internal IDs to session-specific random tokens
4function createIndirectReference(userId, resourceId) {
5 const token = crypto.randomUUID();
6 // Store mapping: token → resourceId, scoped to this user's session
7 sessionReferenceMap.set(`${userId}:${token}`, resourceId);
8 return token;
9}
10
11function resolveIndirectReference(userId, token) {
12 return sessionReferenceMap.get(`${userId}:${token}`);
13}
14
15// API returns indirect references
16app.get('/api/documents', authenticate, async (req, res) => {
17 const docs = await Document.find({ ownerId: req.user.id });
18
19 const safeResponse = docs.map(doc => ({
20 ref: createIndirectReference(req.user.id, doc.id),
21 title: doc.title,
22 createdAt: doc.createdAt,
23 }));
24
25 res.json(safeResponse);
26});
27
28// API resolves indirect reference — can only access own resources
29app.get('/api/documents/:ref', authenticate, async (req, res) => {
30 const docId = resolveIndirectReference(req.user.id, req.params.ref);
31 if (!docId) return res.status(404).json({ error: 'Not found' });
32
33 const doc = await Document.findById(docId);
34 res.json(doc);
35});Pattern 2: Centralized Authorization Middleware
1// Define a reusable authorization layer
2interface AuthorizationRule {
3 resource: string;
4 action: string;
5 condition: (user: User, resource: any) => boolean;
6}
7
8const rules: AuthorizationRule[] = [
9 {
10 resource: 'order',
11 action: 'read',
12 condition: (user, order) =>
13 order.customerId === user.id || user.role === 'admin',
14 },
15 {
16 resource: 'order',
17 action: 'cancel',
18 condition: (user, order) =>
19 order.customerId === user.id && order.status === 'pending',
20 },
21 {
22 resource: 'order',
23 action: 'refund',
24 condition: (user, _order) =>
25 ['admin', 'support'].includes(user.role),
26 },
27];
28
29function checkAuthorization(
30 user: User,
31 resource: string,
32 action: string,
33 target: any
34): boolean {
35 const rule = rules.find(
36 r => r.resource === resource && r.action === action
37 );
38
39 if (!rule) return false; // Deny by default
40 return rule.condition(user, target);
41}Pattern 3: Database-Level Enforcement (Row-Level Security)
1-- PostgreSQL Row-Level Security (RLS)
2-- Authorization is enforced at the database level, not application level
3
4ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
5
6-- Users can only see their own orders
7CREATE POLICY orders_select_policy ON orders
8 FOR SELECT
9 USING (customer_id = current_setting('app.current_user_id')::int);
10
11-- Users can only update their own pending orders
12CREATE POLICY orders_update_policy ON orders
13 FOR UPDATE
14 USING (
15 customer_id = current_setting('app.current_user_id')::int
16 AND status = 'pending'
17 );
18
19-- Admins can see all orders
20CREATE POLICY admin_orders_policy ON orders
21 FOR ALL
22 USING (current_setting('app.current_user_role') = 'admin');
23
24-- Application sets context before queries
25-- SET LOCAL app.current_user_id = '42';
26-- SET LOCAL app.current_user_role = 'user';
27-- SELECT * FROM orders; -- Only returns user 42's orders- Deny by default — Every resource should be inaccessible unless a rule explicitly grants access
- Use indirect references — Map session-specific tokens to internal IDs instead of exposing database keys
- Enforce at the data layer — Include ownership filters in every database query, not just the API layer
- Centralize authorization logic — Use middleware or policy engines instead of ad-hoc checks scattered across handlers
- Test with multiple accounts — Every endpoint should be tested by swapping authentication tokens between two unprivileged accounts
- Implement row-level security — Use database-level policies as a safety net below the application layer
What is an 'indirect object reference' and why does it help prevent IDOR?
Testing Tools
Testing for IDOR requires both automated enumeration and manual analysis. Use these tools to systematically check access control across your API endpoints.
Manual IDOR Testing with curl
1# Step 1: Login as User A and get their token
2TOKEN_A=$(curl -s -X POST https://target.com/api/login \
3 -H "Content-Type: application/json" \
4 -d '{"email":"usera@test.com","password":"pass"}' \
5 | jq -r '.token')
6
7# Step 2: Login as User B and get their token
8TOKEN_B=$(curl -s -X POST https://target.com/api/login \
9 -H "Content-Type: application/json" \
10 -d '{"email":"userb@test.com","password":"pass"}' \
11 | jq -r '.token')
12
13# Step 3: Get User A's order ID
14ORDER_ID=$(curl -s https://target.com/api/orders \
15 -H "Authorization: Bearer $TOKEN_A" \
16 | jq -r '.[0].id')
17
18# Step 4: Try to access User A's order with User B's token
19# If this returns 200 with data, you have an IDOR
20curl -s https://target.com/api/orders/$ORDER_ID \
21 -H "Authorization: Bearer $TOKEN_B"
22
23# Step 5: Try modifying User A's resources with User B's token
24curl -s -X PUT https://target.com/api/orders/$ORDER_ID \
25 -H "Authorization: Bearer $TOKEN_B" \
26 -H "Content-Type: application/json" \
27 -d '{"status":"cancelled"}'Conclusion
IDOR and broken access control are deceptively simple vulnerabilities with devastating impact. They are the #1 web application security risk because they are easy to introduce, hard for automated tools to detect, and trivial for attackers to exploit once found.
Key Takeaways
Remember: 1) Never trust user-supplied IDs without ownership verification, 2) Derive resource ownership from the authenticated session, not request parameters, 3) Enforce authorization at both API and database layers, 4) Return 404 (not 403) for unauthorized resources, 5) Use allowlists to prevent mass assignment, 6) Test every endpoint with two unprivileged accounts, 7) Log and monitor access patterns for anomaly detection.
The most effective defense is a systematic approach: centralized authorization middleware, database-level enforcement via row-level security, and comprehensive integration tests that verify access control across all endpoints and HTTP methods. Code review is your best tool—look for any database query that uses a user-supplied ID without an ownership filter.