OWASP Top 10 2025

#1 Broken Access Control

The Keys to the Kingdom Are Hanging on the Door

In 2023, a security researcher found they could access any Optus customer's data by simply changing a number in an API request. No authentication bypass. No SQL injection. Just incrementing an ID from 1001 to 1002. That single flaw exposed 10 million customer records.

This is Broken Access Control — and it's been sitting at #1 on the OWASP Top 10 since 2021. In 2025, it's still there. Still devastating. Still everywhere.

What Is Broken Access Control?

Access control enforces who can do what. When it's broken, users can act outside their intended permissions — viewing other users' data, modifying records they shouldn't touch, or escalating to admin functionality.

It's not about getting in (that's authentication). It's about what you can do once you're in (authorization).

Common patterns we see:

  • IDOR (Insecure Direct Object References) — Changing /api/user/123 to /api/user/124
  • Privilege escalation — Regular user accessing /admin/dashboard
  • Missing function-level access control — Deleting resources without ownership checks
  • Metadata manipulation — Tampering with JWTs or hidden form fields

The Attacker's View: Finding and Exploiting BAC

As pentesters, Broken Access Control is often our highest-value target. Here's the methodology:

Step 1: Map the Application with Two Accounts

Create two accounts with different privilege levels (e.g., user_a and user_b, or user and admin). Log every request both accounts can make.

Step 2: Hunt for IDORs in Burp Suite

Capture a request that fetches user-specific data:

GET /api/v1/orders/7841 HTTP/1.1
Host: target.com
Authorization: Bearer eyJhbG...user_a_token

Now, swap the token to user_b's session and replay. Does it return user_a's order? That's an IDOR.

Burp Tip: Use the Autorize extension. It automatically replays every request with a lower-privileged session and flags discrepancies.

Step 3: Force Browse to Privileged Endpoints

Try accessing admin routes directly:

GET /admin/users HTTP/1.1
Authorization: Bearer eyJhbG...regular_user_token

No redirect to login? You've got missing function-level access control.

Step 4: Tamper with State-Changing Requests

Check if you can modify resources you don't own:

DELETE /api/v1/posts/9921 HTTP/1.1
Authorization: Bearer eyJhbG...attacker_token

If post 9921 belongs to someone else but gets deleted — that's a critical finding.

Real-World Damage: The Optus Breach (2022)

The Optus breach wasn't sophisticated. An unauthenticated API endpoint exposed customer data via sequential IDs. Attackers scraped names, dates of birth, passport numbers, and driver's licenses for 9.8 million Australians.

Cost to Optus:

  • $140 million AUD in remediation
  • Class action lawsuits
  • CEO resignation
  • Regulatory investigation

The fix? A few lines of authorization code.

Try It Yourself: Vulnerable Flask App

Save this as vulnerable_app.py and run it locally:

from flask import Flask, request, jsonify

app = Flask(__name__)

# Simulated database
users = {
    "alice": {"id": 1, "email": "alice@example.com", "role": "user"},
    "bob": {"id": 2, "email": "bob@example.com", "role": "admin"},
}

orders = {
    101: {"user_id": 1, "item": "Laptop", "total": 1200},
    102: {"user_id": 2, "item": "Phone", "total": 800},
}

@app.route("/api/orders/<int:order_id>")
def get_order(order_id):
    # BUG: No check if the requesting user owns this order!
    order = orders.get(order_id)
    if order:
        return jsonify(order)
    return jsonify({"error": "Not found"}), 404

@app.route("/admin/users")
def admin_users():
    # BUG: No check if the user is actually an admin!
    return jsonify(users)

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Exploit it:

# As any user, access another user's order (IDOR)
curl http://localhost:5000/api/orders/102

# As any user, access the admin endpoint
curl http://localhost:5000/admin/users

Both requests succeed when they shouldn't.

Fix It: Secure Code Patterns

For IDORs — Always validate ownership:

@app.route("/api/orders/<int:order_id>")
def get_order(order_id):
    current_user_id = get_current_user_id()  # From session/JWT
    order = orders.get(order_id)

    if not order:
        return jsonify({"error": "Not found"}), 404

    if order["user_id"] != current_user_id:
        return jsonify({"error": "Forbidden"}), 403

    return jsonify(order)

For privileged endpoints — Check roles explicitly:

@app.route("/admin/users")
def admin_users():
    current_user = get_current_user()

    if current_user["role"] != "admin":
        return jsonify({"error": "Forbidden"}), 403

    return jsonify(users)

General principles:

  • Deny by default — require explicit permission grants
  • Validate on every request (never trust client-side checks)
  • Use indirect references (UUIDs) instead of sequential IDs where possible
  • Log and alert on access control failures

Key Takeaway

Broken Access Control isn't glamorous. There's no fancy payload or zero-day exploit. It's just... checking whether someone should be allowed to do something. And yet it's the #1 vulnerability year after year because developers assume the UI will enforce the rules.

As pentesters, our job is to prove that assumption wrong.

Next: We tackle Injection — because user input should never be trusted.