IDOR and Broken Access Control: Detection and Prevention

Insecure Direct Object References and other Broken Access Control patterns are the most common source of account takeover bugs. Here is how to find and fix them.

Broken Access Control is the top-ranked category in the OWASP Top 10 2021 (A01:2021) for good reason: it is the most frequently found vulnerability in real applications. Insecure Direct Object References (IDOR) are the most common subtype, but the category includes privilege escalation, missing function-level access control, and forced browsing. This article covers how these bugs work, how to find them, and the architectural patterns that prevent them.

What IDOR Looks Like

An IDOR occurs when an application uses a user-controllable value to directly reference an internal resource without verifying that the requesting user is authorised to access it.

// Vulnerable Express endpoint
app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  res.json(invoice); // No ownership check
});

User A is logged in and their invoice ID is 1042. They change the URL to /api/invoices/1043 and get User B’s invoice. No special tooling required.

This is not a theoretical attack. IDOR vulnerabilities have leaked medical records, financial data, private messages, and user PII in public disclosures at HackerOne and Bugcrowd. The bug is structural: the backend trusts that the client will only request authorised resources.

The Fix: Always Verify Ownership

Every read, write, and delete of a user-owned resource must check that the authenticated user owns it.

// Safe: scope query to authenticated user
app.get('/api/invoices/:id', authenticate, async (req, res) => {
  const invoice = await db.invoices.findOne({
    where: { id: req.params.id, userId: req.user.id } // ownership enforced at DB layer
  });

  if (!invoice) {
    return res.status(404).json({ error: 'Not found' });
  }

  res.json(invoice);
});

Returning 404 (not 403) when access is denied prevents enumeration: the attacker cannot distinguish “exists but forbidden” from “does not exist”.

In Python with SQLAlchemy:

@app.route('/api/invoices/<int:invoice_id>')
@login_required
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id  # ownership check in query
    ).first_or_404()
    return jsonify(invoice.to_dict())

In Java/Spring:

@GetMapping("/api/invoices/{id}")
public ResponseEntity<Invoice> getInvoice(
    @PathVariable Long id,
    @AuthenticationPrincipal UserDetails userDetails
) {
    return invoiceRepository
        .findByIdAndOwnerUsername(id, userDetails.getUsername())
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

The pattern is the same in every language: include the authenticated user’s identity in the data access query. Do not fetch first and authorise after.

Indirect Reference Maps

An alternative approach replaces sequential integer IDs with UUIDs or opaque tokens in the API layer. This does not fix broken access control, but it eliminates the enumeration vector that makes IDOR trivially exploitable.

// Instead of /api/invoices/1042 /api/invoices/1043 /api/invoices/1044...
// Use UUIDs: /api/invoices/f47ac10b-58cc-4372-a567-0e02b2c3d479

const { v4: uuidv4 } = require('uuid');

// On creation
const invoice = await db.invoices.create({
  userId: req.user.id,
  publicId: uuidv4(), // exposed in API
  // ...
});

// On retrieval  --  still check ownership
app.get('/api/invoices/:publicId', authenticate, async (req, res) => {
  const invoice = await db.invoices.findOne({
    where: { publicId: req.params.publicId, userId: req.user.id }
  });
  // ...
});

UUIDs reduce exploitability but do not replace authorisation checks. An attacker who finds one UUID (via a leak, referrer header, or shared link) can still access that resource if ownership is not verified.

Privilege Escalation: Horizontal vs Vertical

IDOR is typically horizontal escalation: User A accessing User B’s data at the same privilege level.

Vertical escalation means a lower-privilege user accessing functionality reserved for higher-privilege users. Common patterns:

// Vulnerable: role sent by client
app.post('/api/users', async (req, res) => {
  const { name, email, role } = req.body; // Never trust client-supplied role
  await db.users.create({ name, email, role });
});

An attacker submits { "name": "...", "email": "...", "role": "admin" } and gets an admin account.

The fix is to never derive privilege from client-supplied input:

// Safe: role determined server-side only
app.post('/api/users', requireAdmin, async (req, res) => {
  const { name, email, role } = req.body;
  const safeRole = ['viewer', 'editor'].includes(role) ? role : 'viewer';
  await db.users.create({ name, email, role: safeRole });
});

Mass Assignment

Mass assignment is related: an ORM auto-maps request body fields to model attributes, and the attacker includes privileged fields they should not be able to set.

// Vulnerable: Mongoose example
app.put('/api/users/:id', authenticate, async (req, res) => {
  await User.findByIdAndUpdate(req.params.id, req.body); // updates any field in body
});
// Attacker sends: { "isAdmin": true, "balance": 999999 }

Fix by explicitly listing which fields are updatable:

// Safe: explicit field allowlist
app.put('/api/users/:id', authenticate, async (req, res) => {
  const { displayName, bio, avatarUrl } = req.body;
  await User.findByIdAndUpdate(req.user.id, { displayName, bio, avatarUrl });
  // Note: also enforces user can only update their own profile
});

In Python, use a serialiser with fields or read_only_fields to control what can be written:

class UserUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['display_name', 'bio', 'avatar_url']  # explicit allowlist
        read_only_fields = ['is_admin', 'balance', 'email']

Function-Level Access Control

Some endpoints are gated only by obscurity. An admin panel at /admin/users with no access check is accessible to any authenticated (or unauthenticated) user who knows the path.

Enforce access control at the routing layer, not just in templates:

// Middleware approach: apply to all /admin/* routes
const requireAdmin = (req, res, next) => {
  if (!req.user || req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
};

app.use('/admin', requireAdmin); // Applies to every /admin route

Do not rely on hiding links in the UI. A user who opens DevTools can find every API endpoint your JavaScript calls.

Testing for IDOR

Manual testing steps:

  1. Create two accounts (User A and User B) at the same privilege level.
  2. As User A, create resources (orders, messages, settings, documents).
  3. Note all resource IDs returned in responses, URLs, and redirects.
  4. Authenticate as User B and attempt to access User A’s resource IDs directly.
  5. Check all HTTP methods: GET, PUT, PATCH, DELETE. A resource may be read-protected but not delete-protected.

Automated scanning with tools like Burp Suite’s Autorize extension or nuclei can help, but manual two-account testing catches the most cases because access logic varies per endpoint.

The simplest test that developers can run themselves: in your test suite, assert that accessing another user’s resource returns 404 or 403, not 200.

// Jest test asserting IDOR protection
it('cannot access another user\'s invoice', async () => {
  const userA = await createAuthenticatedUser();
  const userB = await createAuthenticatedUser();
  const invoice = await createInvoice({ userId: userA.id });

  const res = await request(app)
    .get(`/api/invoices/${invoice.id}`)
    .set('Authorization', `Bearer ${userB.token}`);

  expect(res.status).toBe(404); // Not 200
});

Summary

Broken Access Control bugs share a root cause: the server trusts the client to only request resources it should have access to. The fix is always the same: verify ownership and permission on the server, for every request, at the data access layer. Client-side hiding, UUIDs, and security-by-obscurity are not access controls.