This technical document explores the Insecure Direct Object Reference (IDOR) vulnerability, a critical issue in web application security classified under Broken Access Control in the OWASP Top 10. Through a hands-on example using Python and the Flask framework, we demonstrate how IDOR can arise from improper authorization checks, allowing unauthorized access to sensitive data. The document follows a "build → break → fix" methodology: constructing a vulnerable application, exploiting the flaw, and implementing remediation strategies. Additional details on underlying concepts, real-world implications, and best practices are provided to enhance understanding for developers and security practitioners.
Key concepts covered include authentication vs. authorization, URL parameter manipulation, and secure design patterns. Code examples are in Python 3.x with Flask 2.x, assuming a basic SQLite database for user storage.
Introduction
Web applications often handle sensitive user data, such as personal profiles, financial records, or medical information. While authentication mechanisms (e.g., login systems) verify user identity, authorization ensures that users can only access resources they are permitted to view or modify. IDOR occurs when an application exposes direct references to internal objects (e.g., database IDs) without proper authorization validation, allowing attackers to manipulate inputs and access unauthorized data.
This vulnerability has led to significant breaches, including data leaks affecting millions of users. According to OWASP, Broken Access Control remains the top web application security risk as of 2021 (and reaffirmed in subsequent updates). In this document, we build a simple Student Portal application to illustrate IDOR, exploit it, and apply fixes.
Application Architecture and Implementation
Overview
The Student Portal is a minimal web application built with Flask, a lightweight Python web framework. It includes:
- User registration and login endpoints.
- Session-based authentication using Flask's built-in session management.
- A profile page displaying user-specific data: name, email, and phone number.
- A backend database (SQLite) for storing user records.
The application uses Flask's routing system to handle HTTP requests and Jinja2 for templating. Sessions are secured with a secret key, and passwords are hashed using Werkzeug's security utilities (e.g., generate_password_hash and check_password_hash).
Key Code Components
Database Setup
We use SQLite for simplicity. The users table schema is as follows:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL
);
A helper function to retrieve a user by ID:
import sqlite3
def get_user_by_id(user_id):
conn = sqlite3.connect('database.db')
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
conn.close()
if user:
return {
'id': user[0],
'username': user[1],
'name': user[3],
'email': user[4],
'phone': user[5]
}
return None
Login and Session Management
The login route authenticates users and sets a session variable:
from flask import Flask, request, session, redirect, render_template
from werkzeug.security import check_password_hash, generate_password_hash
app = Flask(__name__)
app.secret_key = 'your_secret_key' # Replace with a secure random key
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
conn = sqlite3.connect('database.db')
cursor = conn.cursor()
cursor.execute("SELECT id, password_hash FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if user and check_password_hash(user[1], password):
session['user_id'] = user[0]
return redirect('/profile')
return 'Invalid credentials'
return render_template('login.html')
Upon successful login, users are redirected to /profile, which initially includes a query parameter for the user ID.
Vulnerable Profile Endpoint
The initial implementation of the profile route:
@app.route("/profile")
def profile():
if "user_id" not in session:
return redirect("/login")
uid = request.args.get("id")
if not uid:
return "Missing ID", 400
try:
uid = int(uid)
except ValueError:
return "Invalid ID", 400
user = get_user_by_id(uid)
if not user:
return "User not found", 404
return render_template("profile.html", user=user)
The profile.html template displays the data:
<!DOCTYPE html>
<html>
<head><title>Profile</title></head>
<body>
<h1>Welcome, {{ user.name }}</h1>
<p>Email: {{ user.email }}</p>
<p>Phone: {{ user.phone }}</p>
</body>
</html>
At this stage, the application appears secure: sessions enforce login, and data is fetched from the database. However, it lacks authorization checks.
The Vulnerability: Insecure Direct Object Reference (IDOR)
Description
IDOR arises when an application uses user-supplied input (e.g., URL parameters, form fields) to directly reference internal objects without verifying the user's permission to access them. In this case:
- The
idparameter in/profile?id=<number>directly maps to a database row ID. - The server trusts the parameter without comparing it to the authenticated user's session ID.
This violates the principle of least privilege and allows horizontal privilege escalation (accessing peer users' data) or vertical escalation (if admin IDs are guessable).
Root Cause Analysis
-
Authentication vs. Authorization Gap: The route checks for a valid session (authentication) but not whether the requested
idmatches the session'suser_id(authorization). - Predictable References: Database IDs are sequential and low-entropy (e.g., 1, 2, 3), making them easy to enumerate.
-
Input Trust: Flask's
request.args.getdirectly passes user input to the database query without sanitization beyond type conversion. - No Rate Limiting or Monitoring: The application lacks defenses against enumeration attacks, such as API rate limits or anomaly detection.
In terms of OWASP, this falls under A01:2021 – Broken Access Control, where direct object references are explicitly called out as a common flaw.
Exploitation
Attack Scenario
-
Setup Users:
- Register "alice" (ID=1, email=alice@example.com, phone=123-456-7890).
- Register "bob" (ID=2, email=bob@example.com, phone=987-654-3210).
- Register "charlie" (ID=3, email=charlie@example.com, phone=555-555-5555).
-
Login as Alice:
- Authenticate and redirect to
/profile?id=1. - View Alice's data.
- Authenticate and redirect to
-
Manipulate URL:
- Change to
/profile?id=2. - The server fetches and displays Bob's data without challenge.
- Repeat for
/profile?id=3to access Charlie's data.
- Change to
This requires no tools beyond a web browser. The attack vector is the URL query parameter, exploiting the server's blind trust.
Potential Impact
- Data Exposure: Leak of personally identifiable information (PII), leading to privacy violations under regulations like GDPR or CCPA.
-
Escalation: If IDs are enumerable, an attacker could script a loop to dump all user data (e.g., using Python's
requestslibrary). - Chained Attacks: Combine with other flaws, like XSS, to steal sessions and automate exploitation.
Real-world examples:
- In 2019, a major U.S. bank exposed customer accounts via IDOR in their online portal, allowing balance and transaction views.
- Educational platforms like Canvas have patched IDOR issues where students could access others' grades.
- Healthcare apps have leaked patient records, as seen in breaches reported by HHS OCR.
- Ticketmaster incidents involved unauthorized ticket transfers via manipulable IDs.
Remediation Strategies
Basic Fix: Add Authorization Check
Modify the route to enforce ownership:
@app.route("/profile")
def profile():
if "user_id" not in session:
return redirect("/login")
uid = request.args.get("id")
if not uid:
return "Missing ID", 400
try:
uid = int(uid)
except ValueError:
return "Invalid ID", 400
if uid != session["user_id"]:
return "403 Forbidden", 403
user = get_user_by_id(uid)
if not user:
return "User not found", 404
return render_template("profile.html", user=user)
This blocks unauthorized IDs with a 403 response.
Recommended Design: Indirect References
Eliminate user-controlled IDs entirely:
@app.route("/profile")
def profile():
if "user_id" not in session:
return redirect("/login")
user = get_user_by_id(session["user_id"])
if not user:
return "User not found", 404
return render_template("profile.html", user=user)
- Benefits: The client cannot influence the fetched record; the server uses the trusted session data.
- For multi-user views (e.g., admin panels), use role-based access control (RBAC) with libraries like Flask-Login or Flask-Security.
Advanced Best Practices
-
Use GUIDs or Hashed IDs: Replace sequential IDs with UUIDs (e.g., via Python's
uuidmodule) to reduce enumerability. - Implement Access Control Lists (ACLs): For complex apps, use frameworks like Flask-Principal for fine-grained permissions.
- Input Validation and Sanitization: Always validate parameters (e.g., type, range) and escape outputs.
- Logging and Monitoring: Log access attempts and alert on suspicious patterns (e.g., rapid ID increments).
- API Security: For REST APIs, apply similar checks in controllers; use JWT claims for authorization.
- Testing: Incorporate security scans with tools like OWASP ZAP or Burp Suite to detect IDOR during development.
- Compliance: Ensure alignment with standards like NIST SP 800-53 (Access Control family).
Lessons Learned and Future Considerations
IDOR underscores that security extends beyond authentication. Developers must question assumptions about user behavior and input trustworthiness. In this exercise, the flaw stemmed from oversimplified logic, highlighting the need for threat modeling (e.g., using STRIDE) during design.
Future explorations could include token-based authentication (e.g., JWT in Flask-JWT-Extended), where similar authorization pitfalls exist if claims are not validated. Modern frameworks like FastAPI or Django offer built-in protections, but custom code always requires vigilance.
By addressing IDOR, applications become more resilient, reducing the risk of data breaches and enhancing user trust.
References
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- Flask Documentation: https://flask.palletsprojects.com/
- CWE-639: Insecure Direct Object Reference (MITRE)
This document serves as a self-contained guide; for production use, consult security experts and perform audits.




Top comments (0)