October 31, 2026
I Changed One Number in a URL and Read Another Customer’s PAN, Aadhaar and Card PIN
Horizontal IDOR in SwarnaVault, a demo gold-loan app generated with AI assistance, which I then tested manually.

By Vishnu VT
4 min read
Scope note: SwarnaVault is a Flask demo application developed with AI assistance and run only in my local lab. I performed the testing, analysis and fix myself. All data is fictitious, and session tokens are redacted in the screenshots.
Why I used a demo app
Lab platforms are great, but they tell you where the bug is. I used AI to generate an app that looks like a real finance product (profiles, accounts, balances, KYC fields) and then tested it the way I would test a real target: no hints, just Burp Suite and a methodology. AI-generated code usually works, but it often skips authorization checks, which makes it a realistic thing to test.
Background
Broken Access Control is ranked #1 in the OWASP Top 10. It happens when an application fails to enforce what an authenticated user is allowed to access.
IDOR (Insecure Direct Object Reference) is a common form of it. The application fetches a resource using an identifier the user supplies, without checking that the user is allowed to see that resource.
- Horizontal privilege escalation: accessing another user's data at the same privilege level (customer to customer). This post.
- Vertical privilege escalation: a low-privilege user reaching admin functionality. Next post.
The finding
- Vulnerability: Horizontal IDOR (Broken Access Control)
- Endpoint: GET /profile?user_id=
- Severity: High (arguably Critical, see "Severity" below)
- Tools: Burp Suite (Proxy, Repeater)
Discovery
I logged in as a standard customer, ravi.kumar (Account #100245, role: User).
The profile page loaded from GET /profile?user_id=1. Whenever an object ID appears in a URL, body or cookie, the first question is: does the server check that this ID belongs to me?
Exploitation
Step 1: I captured the request and sent it to Burp Repeater. The request carries my own session, with user_id=1.
Step 2: I changed user_id from 1 to 2, keeping the same session.
Step 3: The server returned 200 OK with another customer's profile (priya.nair, Account #100246), while the page header still showed the logged-in user as ravi.kumar.
Step 4: user_id=3 returned a third customer (suresh.menon, Account #100247). IDs are sequential, so the whole customer base can be enumerated.
Data exposed per customer: email, account number, PAN, Aadhaar, card PIN and balance.
Impact
- Any authenticated user can read every other customer's KYC and financial data.
- Sequential IDs let an attacker enumerate all customers with a simple loop (for example Burp Intruder with a numeric payload).
- In a real product this means identity theft, targeted phishing and fraud, plus regulatory exposure for leaked KYC data.
Root cause
The application trusted a client-controlled parameter and performed no server-side check to confirm that the requested record belonged to the logged-in user.
@app.route("/profile")
def profile():
user = current_user()
if not user:
return redirect(url_for("login"))
# VULNERABLE: IDOR / Broken Access Control
user_id = request.args.get("user_id")
db = get_db()
if user_id is None:
return redirect(url_for("profile", user_id=user["id"]))
target = db.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
target = user
return render_template(
"profile.html",
user=user,
target=target
)@app.route("/profile")
def profile():
user = current_user()
if not user:
return redirect(url_for("login"))
# VULNERABLE: IDOR / Broken Access Control
user_id = request.args.get("user_id")
db = get_db()
if user_id is None:
return redirect(url_for("profile", user_id=user["id"]))
target = db.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
target = user
return render_template(
"profile.html",
user=user,
target=target
)Fix
- Derive the user's identity from the server-side session, not from request parameters. In the fixed code below, user_id is still accepted only so that admins can view other profiles; for everyone else it must match the session user.
- Enforce object-level authorization on every request and deny by default (403 Forbidden).
- Use non-sequential identifiers such as UUIDs as defense in depth, not as a replacement for authorization checks.
- Log and alert on one account requesting many different object IDs.
@app.route("/profile")
def profile():
user = current_user()
if not user:
return redirect(url_for("login"))
user_id = request.args.get("user_id")
# Default to the authenticated user's own ID
if user_id is None:
user_id = user["id"]
# Enforce ownership authorization
if str(user_id) != str(user["id"]):
if user["role"] != "admin":
abort(403)
db = get_db()
target = db.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
abort(404)
return render_template(
"profile.html",
user=user,
target=target
)@app.route("/profile")
def profile():
user = current_user()
if not user:
return redirect(url_for("login"))
user_id = request.args.get("user_id")
# Default to the authenticated user's own ID
if user_id is None:
user_id = user["id"]
# Enforce ownership authorization
if str(user_id) != str(user["id"]):
if user["role"] != "admin":
abort(403)
db = get_db()
target = db.execute(
"SELECT * FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
abort(404)
return render_template(
"profile.html",
user=user,
target=target
)Other issues I noticed
- Card PIN is displayed in plain text. A PIN should never be stored or shown.
- PAN and Aadhaar are shown in full. Mask them in the UI (for example XXXX XXXX 7788) so one bug exposes less.
- Flask debug mode was on. The Werkzeug debugger is a serious risk if it is ever reachable outside a local lab.
Severity
I rated this High. With full KYC data, a card PIN and trivial enumeration, many programs would rate it Critical. The rating depends on data sensitivity and exploitability, so I justify it rather than just stating it. For reference, the CVSS 3.1 base score is 6.5 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N). CVSS alone undersells this bug, because it does not weigh how sensitive the leaked data is or how easily every record can be pulled.
How to test for this
- Look for IDs in URL parameters, POST bodies, JSON, cookies and headers.
- Swap your ID for another account's ID (use two test accounts).
- Try other HTTP methods on the same endpoint.
- Test numeric IDs, UUIDs and encoded IDs.
- Automate with Burp Intruder or the Autorize extension.
What I learned
This exercise taught me that a single missing server-side authorization check can expose sensitive customer information, even when authentication is properly implemented. I also learned that AI-generated applications should never be assumed secure just because they work as expected — manual security testing with tools like Burp Suite is essential to identify vulnerabilities such as IDOR.
Next in the series: vertical privilege escalation.