You log in to a notes app as a regular user, change one number in the URL, and suddenly you are reading another user’s confidential pentest report. Browse to /admin and the entire user table — passwords included — is on your screen. That is Broken Access Control in action, and according to OWASP it is the number-one risk in web applications. This article walks you through exploiting IDOR and forced browsing in a Flask lab, then fixing both with ownership checks and a role decorator.

What changed in OWASP A01:2025

Broken Access Control held the top spot in 2021 and keeps it in the 2025 revision. The category now maps 40 CWEs (up from 34), absorbing weaknesses that were previously split across other categories — most notably CWE-918 Server-Side Request Forgery (SSRF), which had its own Top Ten entry in 2021. The rationale is straightforward: SSRF is fundamentally an access-control failure where an attacker tricks the server into making requests it should not be authorized to make.

The numbers from the OWASP dataset reinforce why A01 sits at the top:

Metric Value
CWEs mapped 40
Applications with at least one occurrence 100%
Max incidence rate 20.15%
Average incidence rate 3.74%
Total occurrences 1,839,701
CVEs mapped 32,654
Average weighted exploit score 7.04

Every tested application exhibited some form of broken access control. An exploit score of 7.04 means these bugs are reliably exploitable without exotic tooling.

The vulnerable app: SecureNotes

The lab is a minimal Flask notes application with three seeded users: alice (regular), bob (regular), and admin. Each user has private notes. The app has two critical access-control flaws:

  1. IDOR on /notes/<id> — the route fetches any note by its primary key without checking ownership.
  2. Forced browsing to /admin — the admin panel has no authentication or role check at all.

Here is the vulnerable note-detail route:

@app.route("/notes/<int:note_id>")
def note_detail(note_id):
    if not session.get("user_id"):
        return redirect(url_for("login"))
    conn = get_db()
    note = conn.execute(
        "SELECT notes.*, users.username FROM notes "
        "JOIN users ON notes.user_id=users.id WHERE notes.id=?",
        (note_id,),
    ).fetchone()
    conn.close()
    if not note:
        flash("Note not found")
        return redirect(url_for("notes_list"))
    # BUG: no ownership check — any logged-in user can view any note
    return render_template_string(detail_page, title=note["title"])

And the unprotected admin route:

@app.route("/admin")
def admin_panel():
    # BUG: no authentication or role check
    conn = get_db()
    users = conn.execute("SELECT * FROM users").fetchall()
    notes = conn.execute("SELECT notes.*, users.username FROM notes ...").fetchall()
    conn.close()
    return render_template_string(admin_page, title="Admin Panel")

Exploiting the IDOR

Log in as alice (alice / alice123). The dashboard shows her two notes: Project Kickoff (note #1) and API Keys — DO NOT SHARE (note #2).

Alice's notes dashboard showing notes #1 and #2

Now change the URL from /notes/1 to /notes/3. The app happily returns bob’s confidential Pentest Findings note — the one containing details about SQL injection and XSS vulnerabilities found during an engagement.

Alice viewing bob's confidential note #3 via IDOR

Notice the metadata: Author: bob. Alice should never see this. A real attacker would script a loop from /notes/1 through /notes/9999 and harvest every note in the database.

Exploiting forced browsing to /admin

Without even being logged in, browse to http://127.0.0.1:5000/admin. The admin panel renders with full access: every user’s ID, username, role, and plaintext password, plus every note in the system.

Admin panel exposed without authentication — all users and passwords visible

This is CWE-425 (Direct Request / Forced Browsing). The route exists, the URL is guessable, and nothing enforces authorization.

Fixing the vulnerabilities

Ownership check on notes

The fix adds a single comparison after fetching the note. If the requesting user does not own it, the request is denied with a 403 and the attempt is logged:

@app.route("/notes/<int:note_id>")
@login_required
def note_detail(note_id):
    conn = get_db()
    note = conn.execute(
        "SELECT notes.*, users.username FROM notes "
        "JOIN users ON notes.user_id=users.id WHERE notes.id=?",
        (note_id,),
    ).fetchone()
    conn.close()
    if not note:
        flash("Note not found")
        return redirect(url_for("notes_list"))

    if note["user_id"] != session["user_id"]:
        sec_logger.warning(
            "IDOR ATTEMPT: user=%s tried to access note #%d owned by user_id=%d from %s",
            session.get("username"), note_id, note["user_id"], request.remote_addr,
        )
        return render_template_string(denied_page, title="Access Denied"), 403

    return render_template_string(detail_page, title=note["title"])

Role-required decorator on /admin

A reusable decorator checks the user’s role before allowing access to the admin panel. Any mismatch triggers a 403 and a security log entry:

def role_required(role):
    def decorator(f):
        @wraps(f)
        def decorated(*args, **kwargs):
            if not session.get("user_id"):
                return redirect(url_for("login"))
            if session.get("role") != role:
                sec_logger.warning(
                    "ROLE VIOLATION: user=%s (role=%s) tried to access %s (requires %s) from %s",
                    session.get("username"), session.get("role"),
                    request.path, role, request.remote_addr,
                )
                return render_template_string(denied_page, title="Access Denied"), 403
            return f(*args, **kwargs)
        return decorated
    return decorator

@app.route("/admin")
@role_required("admin")
def admin_panel():
    # Only reachable by users with role == "admin"
    ...

After applying the fix, alice’s attempt to access note #3 is blocked:

Fixed version — access denied when alice tries to view bob's note

And her attempt to reach /admin is blocked the same way:

Fixed version — admin panel blocked for non-admin users

Detection and logging

Blocking unauthorized access is necessary but not sufficient. You also need to know when someone is probing your application. The fixed version logs every denied request with structured fields:

Security logs showing IDOR attempts and role violations in the terminal

Key logging patterns to implement:

In production, send these logs to a SIEM (Splunk, Elastic, etc.) and create alerts for patterns like more than five access-denied events from one user within a minute.

SSRF: why it belongs in A01

OWASP moved SSRF (CWE-918) into Broken Access Control because the root cause is the same: the application performs an action it should not be authorized to perform. In SSRF, the server itself becomes the attacker’s proxy.

Consider a Flask endpoint that fetches a URL provided by the user:

import requests

@app.route("/fetch")
def fetch_url():
    url = request.args.get("url")
    # No validation — the server will fetch anything
    resp = requests.get(url)
    return resp.text

An attacker calls /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ and the server dutifully returns its own cloud IAM credentials. The fix is the same principle as the IDOR fix — validate and restrict what the application is authorized to access:

from urllib.parse import urlparse

ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}

@app.route("/fetch")
@login_required
def fetch_url():
    url = request.args.get("url", "")
    parsed = urlparse(url)
    if parsed.hostname not in ALLOWED_HOSTS:
        sec_logger.warning("SSRF ATTEMPT: user=%s tried to fetch %s", session.get("username"), url)
        abort(403)
    if parsed.scheme not in ("http", "https"):
        abort(400)
    resp = requests.get(url, timeout=5)
    return resp.text

Allowlist the hosts the server may contact, block internal IP ranges, and log every blocked request.

Key takeaways

Lab: download and run it

Download the lab (zip)

What is inside:

idor-to-admin-takeover/
├── vulnerable_app.py   # Vulnerable version (IDOR + unprotected /admin)
├── fixed_app.py        # Fixed version (ownership checks + role decorator + logging)
├── run.sh              # Linux / macOS launcher
├── run.bat             # Windows launcher
├── requirements.txt    # Python dependencies (flask>=3.0)
└── README.md

Run it:

# 1. Install dependencies
pip install -r requirements.txt

# 2. Start the vulnerable version
./run.sh vuln        # Linux/macOS
run.bat vuln         # Windows

# 3. Open http://127.0.0.1:5000 — log in as alice (alice / alice123)
# 4. Change the URL to /notes/3 — you can read bob's confidential note (IDOR)
# 5. Browse to /admin — full admin panel with no auth check (forced browsing)

# 6. Stop with Ctrl+C, then start the fixed version
./run.sh fixed       # Linux/macOS
run.bat fixed        # Windows

# 7. Try /notes/3 again as alice — Access Denied (403)
# 8. Try /admin as alice — Access Denied (403)
# 9. Check the terminal — IDOR and role-violation attempts are logged

Clean up: press Ctrl+C to stop the server. Delete the SQLite databases for a fresh start:

rm -f notes_vuln.db notes_fixed.db

The lab listens on 127.0.0.1 only. No real credentials or third-party targets are used.

References