Broken Access Control: The Most Common OWASP Top 10 Vulnerability
AI generated
OWASP
0x00
Security · OWASP Top 10 · Access Control · Magento 2
Broken Access Control
the most common OWASP Top 10 vulnerability

Broken Access Control has ranked number 1 in the OWASP Top 10 since 2021 and affects most tested web applications. This article explains IDOR, missing function-level access control, and privilege escalation via parameter tampering with concrete examples, then shows how to implement clean access control in Magento using ACL patterns and the principle of least privilege.

16 min. read IDOR · Privilege Escalation · ACL · Least Privilege OWASP Top 10 2021 · Magento 2.4.8

1. Why Broken Access Control ranks number 1 in the OWASP Top 10

Broken Access Control jumped from rank 5 to rank 1 in the 2021 OWASP Top 10 report, based on data from more than a hundred thousand tested applications. The reason isn't that this vulnerability class is more spectacular than, say, injection, but that it shows up in some form in the vast majority of tested applications. Access control logic is spread across dozens of controllers, API endpoints, and service methods, and every single one of them has to be implemented correctly. One forgotten check is enough to create an exploitable gap.

Automated scanners reliably detect SQL injection or XSS by pattern-matching the response, but they frequently fail on access control because a broken authorization looks technically like a correct response: the server returns status 200 with valid data, just data that doesn't belong to the requesting user. The consequences range from data leaks of other people's orders and invoices to full account takeover, if a gap can even be used to obtain admin rights. That combination of high prevalence, weak automated detectability, and potentially critical impact is exactly what explains the top spot in the current OWASP ranking.

2. IDOR: understanding and preventing insecure direct object references

An Insecure Direct Object Reference (IDOR) occurs when an application exposes an internal object identifier, such as a database ID, an invoice number, or a file name, directly in a URL or API parameter, and relies on users only knowing their own IDs. A logged-in customer requests /customer/order/view/order_id/1042 and sees their own order. If they manually change the ID to 1043, they get shown another customer's order without any further check, as long as the server only verifies authentication and not ownership of the object. This is the classic horizontal case of Broken Access Control.

The obvious but insufficient reaction is to replace IDs with UUIDs to make them harder to guess. That only shifts the problem, since it's still security through obscurity: once a UUID becomes known, for instance through a shared link or a referrer leak, access remains unprotected. The only solid solution is a server-side authorization check on every single object access: does the requested resource actually belong to the currently authenticated user or their permission group? This check has to be enforced at the repository or service layer, not only in the controller, so it can't accidentally be bypassed.


<?php
declare(strict_types=1);

namespace Mironsoft\SecurityDemo\Controller\Order;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Framework\Exception\NotFoundException;

/**
 * VULNERABLE: loads any order by ID without checking ownership (IDOR).
 * An authenticated customer can change order_id in the URL and read
 * another customer's order data.
 */
final class ViewVulnerable implements HttpGetActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly OrderRepositoryInterface $orderRepository
    ) {
    }

    public function execute()
    {
        $orderId = (int) $this->request->getParam('order_id');
        // No ownership check: any authenticated user can read any order.
        return $this->orderRepository->get($orderId);
    }
}

/**
 * FIXED: authorization is enforced on every single object access,
 * not only once at login. Ownership is verified server side.
 */
final class ViewFixed implements HttpGetActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly CustomerSession $customerSession
    ) {
    }

    public function execute()
    {
        $orderId = (int) $this->request->getParam('order_id');
        $order = $this->orderRepository->get($orderId);

        // Explicit ownership check: deny by default unless the order
        // actually belongs to the current customer.
        if ((int) $order->getCustomerId() !== (int) $this->customerSession->getCustomerId()) {
            throw new NotFoundException(__('The requested order does not exist.'));
        }

        return $order;
    }
}

3. Missing function-level access control

Authentication answers the question of who a user is. Authorization answers the separate question of what that user is allowed to do, and this second check has to run again on every single action, not just once at login. A common pattern in grown codebases: an admin menu entry is hidden in the frontend if the user lacks the matching role, but the underlying controller endpoint remains reachable without protection. Anyone who knows or guesses the URL can call the action directly, entirely bypassing the hidden menu. Hiding UI elements is a usability decision, not a security control.

This becomes especially critical with REST and GraphQL APIs, since there's no UI there that could accidentally cover anything up. A DELETE endpoint for products or customer accounts must independently verify server side whether the calling role is allowed to perform that action, regardless of whether a frontend button even exists for it. In Magento this means concretely: every admin controller must set the ADMIN_RESOURCE constant and let it be enforced through the framework's ACL check, instead of relying on menu.xml or JavaScript visibility. If this step is missing on just a single new controller, all the rest of the hardening becomes pointless.


<?php
declare(strict_types=1);

namespace Mironsoft\SecurityDemo\Controller\Adminhtml\Report;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Controller\ResultFactory;

/**
 * Admin controller enforcing function-level access control via
 * the Magento ACL system. Without ADMIN_RESOURCE (or with a wrong
 * value), any authenticated admin user could reach this action
 * regardless of their assigned role.
 */
final class Export extends Action
{
    // Must match a resource ID declared in acl.xml. This alone
    // is what protects the endpoint, not the admin menu entry.
    public const ADMIN_RESOURCE = 'Mironsoft_SecurityDemo::report_export';

    public function execute()
    {
        $result = $this->resultFactory->create(ResultFactory::TYPE_RAW);
        $result->setContents($this->generateSensitiveReport());
        return $result;
    }

    /**
     * Generates the confidential export payload.
     *
     * @return string
     */
    private function generateSensitiveReport(): string
    {
        // Report generation logic omitted for brevity.
        return '';
    }
}

// Framework enforcement, roughly what Action::dispatch() performs
// before execute() is ever called:
//
// if (!$this->_authorization->isAllowed(static::ADMIN_RESOURCE)) {
//     throw new \Magento\Framework\Exception\AuthorizationException(
//         __('You do not have permission for this action.')
//     );
// }

4. Privilege escalation via parameter tampering

With parameter tampering, an attacker modifies values in request parameters, hidden form fields, or JSON payloads that the application actually expects to be controlled by the server. There are two flavors of privilege escalation: horizontal escalation means accessing another user's data at the same permission level, as in the IDOR example. Vertical escalation means a user with lower privileges obtains functionality reserved for a higher permission level, such as a regular customer effectively gaining admin rights.

A particularly common entry point is mass assignment: a registration or profile update endpoint accepts a full JSON object and writes it unfiltered into the database entity, instead of only accepting the fields that are actually allowed. If the endpoint expects fields like firstname and email, an attacker also tries fields like group_id, is_admin, or role. If the server blindly accepts these fields because an ORM or repository persists the entire object, a single tampered request is enough for a full privilege escalation. The defense is an explicit allowlist of permitted fields per endpoint, never a generic "save whatever arrives".


// Legitimate profile update request the frontend actually sends
{
  "customer": {
    "firstname": "Anna",
    "lastname": "Example",
    "email": "anna@example.com"
  }
}

// Tampered request an attacker sends directly to the API,
// adding fields the UI never exposes, hoping the backend
// assigns them blindly (mass assignment -> vertical escalation)
{
  "customer": {
    "firstname": "Anna",
    "lastname": "Example",
    "email": "anna@example.com",
    "group_id": 1,
    "is_admin": true,
    "website_id": 0
  }
}

// Server-side defense: an explicit allowlist, never a blind
// $entity->setData($request->getParams()) or equivalent
// $allowedFields = ['firstname', 'lastname', 'email'];
// foreach ($allowedFields as $field) { ... }

5. The principle of least privilege as a foundation

The principle of least privilege states that every user, every process, and every API key receives only the minimum permissions strictly necessary to perform its specific task, nothing more. The inverse, a deny-by-default approach, is the decisive architectural choice: access is denied by default and must be explicitly granted, instead of being allowed by default and selectively restricted. A system that starts from "everything allowed except what's explicitly blocked" will, with high probability, eventually forget a block somewhere. A system that starts from "everything denied except what's explicitly allowed" makes missing permissions immediately visible, because legitimate actions then simply fail and stand out.

In practice this means: fine-grained roles instead of a single shared admin account for the whole team, database users with only the permissions actually required instead of a universal root account for the application, and API keys with a tightly scoped range instead of a master key for every endpoint. Regular access reviews are mandatory, because permissions almost always grow over time and are rarely actively revoked when tasks or team members change. Temporarily elevated rights for individual maintenance tasks should be granted with a time limit and logged, rather than left in place permanently.

6. ACL implementation in the Magento admin area

Magento implements the principle of least privilege in the admin area through a declarative ACL resource tree defined in each module's acl.xml. Every resource gets a unique ID nested hierarchically under a parent resource, for example under Magento_Backend::admin. Admin roles are linked to a subset of this tree via System > Permissions > User Roles, and every controller checks through the ADMIN_RESOURCE constant whether the currently logged-in admin user's role has access to exactly that resource. This mechanism fires automatically on every request, before execute() is even called.

The most common implementation mistake is shipping a new admin controller without an ADMIN_RESOURCE constant, or with an incorrect one. If it's missing entirely, Magento's default behavior kicks in, which varies in strictness depending on version and parent class, and in the worst case the controller becomes reachable for every logged-in admin user regardless of their role. It's equally risky to reuse an already existing, broad resource like Magento_Backend::admin instead of defining a dedicated, fine-grained resource for the new module. Every new module should define its own ACL branch with the most specific sub-resources possible.


<!-- app/code/Mironsoft/SecurityDemo/etc/acl.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="Mironsoft_SecurityDemo::main" title="Security Demo" sortOrder="200">
                    <!-- Fine-grained sub-resource per capability, not one
                         single broad resource for the whole module -->
                    <resource id="Mironsoft_SecurityDemo::report_view"
                              title="View Reports" sortOrder="10"/>
                    <resource id="Mironsoft_SecurityDemo::report_export"
                              title="Export Reports" sortOrder="20"/>
                    <resource id="Mironsoft_SecurityDemo::settings"
                              title="Module Settings" sortOrder="30"/>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

7. Access control in the customer and storefront context

In the storefront context, it's not about admin roles but about isolating customer data from each other, as well as between websites and store views in a multi-site setup. Every repository method that loads a resource by ID, such as an order, an address, or a saved payment method, must, beyond a plain existence check, verify that the loaded entity actually belongs to the currently authenticated customer_id of the active session. This check belongs at the service or repository layer, not just in the controller, so it fires automatically no matter where in the code the method gets called from.

REST and GraphQL APIs add another dimension: customer tokens must be strictly scoped to the issuing customer, and the API must never operate based on a customer_id sent in the request, but exclusively on the identity derived from the validated token. In addition, sequentially numbered, easily enumerable internal IDs in API responses should be avoided wherever the data model allows it, because they make it considerably easier for attackers to systematically probe IDOR candidates, even when the actual authorization check is implemented correctly.

8. CORS and API misconfigurations as an access control gap

A misconfigured Cross-Origin Resource Sharing (CORS) policy also falls under Broken Access Control, because it effectively determines which foreign domains are allowed to make authenticated requests on behalf of a logged-in user. Especially dangerous is the combination of Access-Control-Allow-Origin blindly reflecting the Origin header together with Access-Control-Allow-Credentials: true. This combination lets practically any website send authenticated requests to the API from the victim's logged-in browser and read the response, completely defeating the session protection provided by the same-origin policy.

Other frequent API misconfigurations in the same problem area: JWT tokens whose aud or scope claims aren't validated server side, so a token issued for a different service still works against your own API, plus missing rate limits, which allow practically unbounded automated probing of IDOR candidates or parameter tampering payloads. An API gateway or reverse proxy that centrally checks origin, token scope, and request rate before the actual application logic runs significantly reduces the attack surface, but it does not replace the object-level authorization check inside the application itself.

9. Testing and detecting Broken Access Control

Because automated scanners are structurally bad at detecting Broken Access Control, targeted manual and semi-automated testing is required. The basic technique: call every endpoint with several differently privileged sessions, such as a regular customer, a customer without a login, and, where relevant, an admin user with a restricted role, and systematically check whether the response matches the expected permission level. Burp Suite's Autorize extension automates this comparison by automatically replaying requests from a highly privileged session using the session cookies of a lower-privileged session and flagging deviations.

In addition, integration tests that explicitly expect a 403 or 404 status for unauthorized access belong in every test suite, right next to the tests for the success case. A code review checklist item that explicitly asks about the authorization check on every new controller, every new API route, and every new repository method prevents that control from being forgotten in the rush of a feature deadline. Deny-by-default as an architectural principle also means here: a missing test for an endpoint should be treated as an open security question, not as a silently accepted gap.


#!/usr/bin/env bash
# Simple IDOR probe: compare responses for the same resource ID
# across two different authenticated sessions.
set -euo pipefail

RESOURCE_URL="https://shop.example.com/rest/V1/orders/mine/1042"
TOKEN_OWNER="eyJhbGciOiJI...owner-token"
TOKEN_OTHER="eyJhbGciOiJI...other-customer-token"

echo "[TEST] Request as resource owner"
curl -s -o /tmp/owner.json -w "%{http_code}\n" \
  -H "Authorization: Bearer ${TOKEN_OWNER}" "$RESOURCE_URL"

echo "[TEST] Same resource ID, different authenticated customer"
status=$(curl -s -o /tmp/other.json -w "%{http_code}" \
  -H "Authorization: Bearer ${TOKEN_OTHER}" "$RESOURCE_URL")

if [[ "$status" == "200" ]]; then
  echo "[FAIL] Order 1042 was returned to a customer who does not own it"
  exit 1
else
  echo "[OK] Access correctly denied with status $status"
fi

The following overview summarizes the key vulnerability classes from this article and shows which insecure pattern maps to which secure pattern.

Vulnerability Insecure pattern Secure pattern Risk impact
IDOR Load ID from request without ownership check Ownership check on every object access Data leak of other customers' data
Function-Level AC Only the UI button is hidden ADMIN_RESOURCE + isAllowed() server side Unprotected admin actions executable
Mass Assignment Full JSON object saved blindly Explicit allowlist of permitted fields Vertical privilege escalation
CORS Reflect origin blindly + credentials true Explicit origin allowlist Cross-site access to session data
Permission model Allow-by-default, one shared admin account Deny-by-default, fine-grained roles Uncontrolled privilege growth

Notably, all five patterns follow the same underlying rule: security doesn't come from hiding a feature, but from an explicit, server-side check that runs again on every single request. Anyone who consistently enforces this rule at the repository and service layer instead of only in the controller closes most Broken Access Control gaps structurally, before a single test even runs.

Mironsoft

Security audits, ACL design, and access control reviews for Magento stores

Ready for a professional access control review?

We analyze your Magento store's controllers, API endpoints, and ACL configuration for IDOR, missing function-level access control, and privilege escalation risks, then implement the fixes together with your team.

Access control audit

Manual review of every endpoint using differently privileged sessions

ACL design

Fine-grained admin roles and ACL resource trees built on least privilege

Fix & retest

Ownership checks, allowlists, and integration tests against regressions

10. Summary

Broken Access Control ranks number 1 in the OWASP Top 10 because access control logic is spread across many endpoints, and a single forgotten check is enough to expose another user's data or functionality. IDOR happens when an object ID is accepted without an ownership check. Missing function-level access control happens when only the UI, not the server, restricts an action. Privilege escalation via parameter tampering, especially through mass assignment, lets attackers manipulate fields like roles or group IDs that should actually be server controlled.

The principle of least privilege, combined with a consistent deny-by-default approach, is the architectural answer to all three problem classes. In Magento this is implemented through fine-grained ACL resources in acl.xml, correctly set ADMIN_RESOURCE constants in every admin controller, and explicit ownership checks in every repository method. No automated scanner replaces targeted testing with differently privileged sessions, which is why this test belongs in every security review routine, not just an annual penetration test.

Broken Access Control, the essentials at a glance

Prevent IDOR

Verify every object ID server side against the authenticated user, never trust authentication alone.

Function-Level AC

ADMIN_RESOURCE and isAllowed() in every controller, hiding UI is not a security control.

Least Privilege

Deny-by-default, fine-grained roles instead of shared admin accounts, regular access reviews.

Testing & Monitoring

Test multiple privileged sessions per endpoint, build 403/404 tests into every test suite.

11. FAQ: Broken Access Control

1What is Broken Access Control and why is it number 1 in the OWASP Top 10?
Broken or missing checks on whether a user may perform an action or view an object. Number 1 because it appears in most tested applications and is hard for scanners to detect.
2What is an IDOR vulnerability?
Occurs when an object ID is exposed directly and only authentication, not actual ownership, is checked. A changed ID returns someone else's data.
3Horizontal vs. vertical privilege escalation?
Horizontal: access to a same-level user's data. Vertical: a lower-privileged user obtains functionality of a higher permission level, such as admin rights.
4What does missing function-level access control mean?
An action is only hidden in the frontend, while the server endpoint stays reachable without a role check. Every endpoint must verify server side independently.
5How does parameter tampering work?
An attacker modifies parameters or JSON fields that the server should actually control, such as role IDs. Unvalidated acceptance can lead to privilege escalation.
6What is the principle of least privilege?
Every user gets only the minimum permissions needed. Deny-by-default, fine-grained roles instead of shared accounts, regular access reviews.
7How does Magento's ACL system work?
Hierarchical resource tree in acl.xml, roles get subsets of it, every controller checks access automatically via ADMIN_RESOURCE.
8Is hiding buttons in the frontend enough?
No, that's usability, not security. Without a server-side check, the endpoint stays reachable for anyone who knows or guesses the URL.
9How do I test Broken Access Control?
Test endpoints with multiple privileged sessions, for example with Burp Autorize, plus integration tests expecting 403/404 for unauthorized access.
10What role does CORS play in Broken Access Control?
Blindly reflecting origin plus allowed credentials lets foreign sites send authenticated requests on the user's behalf. This defeats the same-origin policy.