Securing Against Insecure Direct Object References

Insecure Direct Object References occur when applications expose internal object identifiers without proper authorization, allowing attackers to access other users' data by manipulating IDs.

Introduction

Your application uses auto-incrementing IDs for user profiles at /api/users/1234/profile. A curious user notices their profile ID is 1234 and changes it to 1233. They're viewing another user's private information. Minutes later, a script iterates through all user IDs, downloading the entire user database.

Insecure Direct Object References (IDOR) occur when applications expose references to internal objects without verifying the requesting user is authorized to access them. Unlike authentication ("who are you?"), IDOR is an authorization failure ("are you allowed to do this?"). This guide explores IDOR patterns and proper authorization enforcement.

Understanding the Risk

Direct Database ID Exposure:

@app.route('/api/orders/<int:order_id>')
def get_order(order_id):
    order = Order.query.get(order_id)  # No authorization check
    return jsonify(order.to_dict())

Common Attack Vectors:

  • Horizontal escalation: GET /api/invoices/1002 (another customer's invoice)
  • Vertical escalation: GET /api/users/1/profile (admin profile)
  • Multi-tenant bypass: GET /api/tenants/124/reports (competitor's data)

UUIDs Don't Fix IDOR

Switching from sequential IDs to UUIDs is security through obscurity—not a fix:

# Still vulnerable - UUID doesn't prevent IDOR
@app.route('/api/documents/<uuid:doc_id>')
def get_document(doc_id):
    document = Document.query.get(doc_id)  # No authorization check
    return send_file(document.filepath)

Attackers can obtain valid UUIDs through leaked URLs, referrer headers, or other vulnerabilities.

Prevention Best Practices

Implement Authorization on Every Request

@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.get_or_404(order_id)
    
    if order.user_id != g.current_user.id:
        abort(403)
    
    return jsonify(order.to_dict())

Use Relationship-Based Queries

@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id,
        user_id=g.current_user.id  # Automatically scoped
    ).first_or_404()
    
    return jsonify(order.to_dict())

Use Authorization Libraries

Python (Flask-Principal):

from flask_principal import Permission, UserNeed
 
def owner_permission(resource):
    return Permission(UserNeed(resource.owner_id))
 
@app.route('/api/projects/<int:project_id>')
@login_required
def get_project(project_id):
    project = Project.query.get_or_404(project_id)
    
    if not owner_permission(project).can():
        abort(403)
    
    return jsonify(project.to_dict())

Node.js (CASL):

const { defineAbility } = require('@casl/ability');
 
function defineAbilityFor(user) {
  return defineAbility((can) => {
    can('read', 'Order', { userId: user.id });
    if (user.role === 'admin') {
      can('manage', 'all');
    }
  });
}
 
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await Order.findById(req.params.orderId);
  const ability = defineAbilityFor(req.user);
  
  if (!ability.can('read', order)) {
    return res.status(403).json({ error: 'Access denied' });
  }
  res.json(order);
});

Validate Multi-Tenant Access

@app.route('/api/reports/<int:report_id>')
@login_required
def get_report(report_id):
    report = Report.query.filter_by(
        id=report_id,
        tenant_id=g.current_user.tenant_id  # Tenant isolation
    ).first_or_404()
    
    return jsonify(report.to_dict())

Log Authorization Failures

@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.get_or_404(order_id)
    
    if order.user_id != g.current_user.id:
        security_logger.warning(
            f"IDOR attempt: User {g.current_user.id} "
            f"tried to access order {order_id}"
        )
        abort(403)
    
    return jsonify(order.to_dict())

Why Traditional Pentesting Falls Short

IDOR vulnerabilities hide across countless API endpoints. Each requires testing with multiple user contexts—same role, different roles, unauthenticated. The vulnerabilities are logic-based; automated scanners typically miss them because responses appear normal.

How AI-Powered Testing Solves It

RedVeil's AI agents maintain authenticated sessions across multiple user accounts, systematically testing access control by attempting to access resources belonging to other users. When IDOR vulnerabilities are found, RedVeil demonstrates what data is accessible with evidence of unauthorized access.

Conclusion

IDOR represents a fundamental authorization failure. The vulnerability is pervasive because authorization checks must be implemented on every endpoint.

Effective defense requires systematic authorization enforcement: query through user relationships, implement permission checks at the resource level, and use authorization frameworks. UUIDs provide defense-in-depth but are not a substitute for proper authorization.

AI-powered penetration testing from RedVeil identifies IDOR vulnerabilities, testing access control with multiple user contexts to find authorization gaps.

Protect your users' data from unauthorized access—test with RedVeil today.

Ready to run your own test?

Start your first RedVeil pentest in minutes.