Defensive Research

Broken Access Control Testing for Defenders

Broken access control testing for defenders — detect IDOR and BOLA from authorization-failure telemetry with Sigma and SPL rules, plus deny-by-default hardening.

A server-room corridor of steel racks with a wall-mounted keycard reader glowing red denied
Threat reference

Broken access control testing, for a defender, is a two-part job: verify that every endpoint enforces ownership server-side, and instrument authorization failures so abuse is visible in real time. The fingerprint of an IDOR or BOLA attack is unmistakable in logs: one account walking sequential object IDs, or a burst of 403s as an attacker maps which references it can reach. This guide ships the test logic and the detection.

Broken access control is the #1 risk in the OWASP Top 10 (A01), held across consecutive editions and found in the large majority of applications tested. It is the most common serious web flaw, and the hardest to fix with a single patch.

What is broken access control?

Broken access control is the failure of the rule that user A cannot read user B’s data. When it breaks, an attacker who is fully, legitimately logged in changes an ID in a request and reads someone else’s invoice, message, or medical record. No exploit, no malware — just a logged-in user asking for a resource the server hands over without checking ownership.

This maps to MITRE ATT&CK T1190 — Exploit Public-Facing Application, and the resulting access looks like T1078 — Valid Accounts because the attacker is a valid account. That overlap is why detection has to focus on behavior, not credentials.

What is the difference between IDOR and BOLA?

IDOR and BOLA are the same flaw under two names — the server trusts a client-supplied ID without verifying the requester owns that object. IDOR (Insecure Direct Object Reference) is the classic web term; BOLA (Broken Object Level Authorization) is the OWASP API Security Top 10 API1:2023 equivalent. The variants differ only in direction and goal:

VariantWhat the attacker doesTelemetry fingerprintDetection
Horizontal (IDOR/BOLA)Swaps an object ID for another user’sOne session, many distinct object IDs in sequenceEnumeration rule
VerticalReaches functions above their role403s on admin paths, then a 200 that slips throughPrivileged-path denials
Forced browsingProbes for hidden references/endpointsRising 403/404 across many paths from one actorDenial-spike rule

A critical defender’s note: non-sequential IDs do not fix this. UUIDs raise the guessing cost, but an attacker who obtains a valid identifier (from a referrer, a shared link, or an API response) exploits the missing check just the same. Obfuscation is not authorization.

How to detect broken access control from telemetry

Authorization decisions are events, and abuse has a shape. Log every decision with actor, object, action, and allow/deny outcome — the same auth-event stream behind JWT misconfiguration detection — then two patterns surface the attack.

Detect object-ID enumeration

A single session touching many distinct object IDs on the same endpoint in a short window is the IDOR/BOLA signature. Normal users view their own handful of records, not hundreds in sequence.

Sigma Sequential Object-ID Access by Single Actor (IDOR/BOLA)
title: Sequential Object-ID Access by Single Actor
id: 8b4e2d10-darkpwn-illustrative
status: experimental
logsource:
  product: application
  service: api
detection:
  selection:
    endpoint|contains: '/api/'
    http_method: 'GET'
  timeframe: 5m
  condition: selection | count(distinct object_id) by actor_id > 50
falsepositives:
  - Batch/reporting jobs and admin tooling that legitimately read many records
level: medium

Detect authorization-failure spikes

A burst of denied requests across many resources is forced browsing. Counting deny outcomes per actor turns your authorization layer into an IDS.

SPL Authorization-Failure Spike by Single Actor
index=app sourcetype=authz:decision outcome=deny
| bin _time span=5m
| stats count AS denies, dc(object_id) AS distinct_objects by _time, actor_id, src_ip
| where denies >= 20 AND distinct_objects >= 10

The same enumeration detection in KQL

KQL Object Enumeration Relative to a Per-User Baseline
let window = 5m;
let baselineDays = 14d;
// Each principal's own normal breadth of object access
let baseline =
    AuthzDecision
    | where TimeGenerated between (ago(baselineDays) .. ago(window))
    | summarize Objects = dcount(ObjectId) by ActorId, bin(TimeGenerated, window)
    | summarize P95 = percentile(Objects, 95), Mean = avg(Objects) by ActorId;
AuthzDecision
| where TimeGenerated > ago(window)
| summarize Objects = dcount(ObjectId), Denies = countif(Outcome == "deny"),
            Endpoints = dcount(Endpoint) by ActorId, SourceIp
| join kind=leftouter baseline on ActorId
| extend Threshold = coalesce(P95, 20.0) * 3
| where Objects > Threshold
| project ActorId, SourceIp, Objects, Threshold, Denies, Endpoints
| order by Objects desc

The baseline join is what makes this deployable. A fixed threshold of fifty objects is wrong in both directions: it pages constantly on the support team who legitimately open hundreds of records a day, and it never fires on the individual customer whose normal breadth is three. Score each principal against their own history and both problems disappear.

How do you set the threshold?

Derive it, do not guess. Measure dcount(object_id) per actor per five-minute window across thirty days of normal traffic, then look at the distribution per role, not globally — support staff, batch jobs, and end users have genuinely different shapes, and blending them produces a threshold that fits none of them.

A workable starting point is three times the 95th percentile for that actor’s role, tightened after two weeks of live alerts. Set the enumeration ratio as a second condition: a principal reading many objects with a high deny ratio is far more suspicious than one reading many objects successfully, because success suggests they are authorized and denial suggests they are probing.

What telemetry do you need to detect broken access control?

This detection has one hard prerequisite that most applications do not meet: the authorization decision must be an event, not a silent return statement.

1. A structured authorization-decision log. Every allow and deny, carrying actor, object identifier, object type, action, endpoint, outcome, and source IP. Logging only denials halves your detection ability, because enumeration that succeeds is the actual breach and it produces no denials at all.

2. A stable actor identifier. The principal ID, not the session or IP. Both of the latter rotate, and correlation breaks the moment an attacker cycles them.

3. Object identifiers you can count distinctly. If the object reference is buried in a request body your logging never parses, no enumeration rule can work.

4. Sufficient retention for baselining. Thirty days minimum, or per-principal baselines have nothing to learn from.

Which false positives will you actually see?

False positiveWhich rule it hitsWhy it happensResolution
Support and admin staffEnumerationTheir job is reading many customers’ recordsSeparate baseline per role; never a global threshold
Batch and reporting jobsEnumerationDesigned to read everythingExclude by service principal, and monitor the job itself
Mobile app syncEnumerationBulk fetch on cold startAllowlist the sync endpoint specifically, not the client
Data-export featuresEnumerationThe feature is bulk accessRate-limit and log; alert on volume, not existence
Broken client retry loopsDenial spikeA bug hammers a forbidden endpointFix the client; suppress by user-agent + endpoint pair
Expired-session burstsDenial spikeToken expiry produces many denials at onceExclude denials whose reason is authentication, not authorization
Pen tests and scannersBothThey enumerate deliberatelyAllowlist source, keep rules enabled, verify they fired
Shared or service accountsEnumerationMany humans behind one principalMigrate to per-principal identity — the noise is telling you something real

The expired-session row is worth implementing carefully. Conflating authentication failures with authorization failures is the single biggest source of noise in this detection class, and the fix is to carry a reason field on the deny event so the two are separable at query time rather than guessed at from status codes.

How do you triage an enumeration alert?

  1. Determine whether the accesses succeeded or were denied. Successful enumeration is a data breach in progress; denied enumeration is reconnaissance against a control that is holding.
  2. If successful, scope the exposure immediately. You logged object IDs, so the list of exposed records is a query rather than an investigation — and that list is what regulatory notification timelines depend on.
  3. Establish whether the actor is compromised or malicious. Check the session’s origin against the user’s baseline; an account behaving anomalously from a new device is likely hijacked rather than rogue.
  4. Identify the vulnerable endpoint and confirm the missing check. Read the code path for the endpoint that served the unowned objects.
  5. Look for the same pattern on sibling endpoints. A missing ownership check is almost never isolated; it reflects a pattern in how that service was written.
  6. Contain by disabling the endpoint or the principal, depending on whether the flaw or the actor is the problem.
  7. Preserve the access log. For a confirmed exposure it is the authoritative record of what was reached, and it is what breach counsel will ask for first.
  8. Add a regression test asserting that account A cannot read account B’s object on that endpoint, so the fix cannot silently revert.

Step 2 is the one that determines how the next month goes. Teams that log object identifiers can scope a breach in minutes; teams that log only endpoints and status codes end up notifying every customer because they cannot prove which records were touched.

How to test for broken access control

Access-control bugs hide from automated scanners because the tools cannot infer business intent. Test it deliberately, in an environment you own:

  1. Create two accounts, A and B, each owning distinct objects.
  2. Authenticated as A, request B’s object IDs across every object-scoped endpoint. Every one should return 403/404, never B’s data.
  3. As a standard-role user, request admin endpoints. Every one should deny.
  4. Automate these as CI tests — one per role, per endpoint — so a new endpoint without an ownership check fails the build.

This pairs with confirming your detections fire, the way SQL injection detection gets validated against real telemetry.

How to defend against broken access control

Detection finds the gap. These design controls close it.

  • Combine RBAC with explicit ownership checks. RBAC handles vertical access (who can do what); per-object ownership handles horizontal access (whose data). You need both.
  • Centralize the authorization logic and reuse it, so one endpoint cannot quietly miss the check — and so the decision is loggable in a uniform format.
  • Build access-control tests into CI, mapped to OWASP ASVS V4 Access Control.

Which authorization model should you build on?

The model you choose determines whether “one missed check is a breach” stays true, because some architectures make forgetting the check structurally difficult.

ModelHandles horizontal access?Where it failsBest for
Role checks in each handlerNo, unless hand-written per objectEvery new endpoint is a fresh chance to forgetSmall apps, early stage
Centralized middlewarePartially — knows the route, not the objectObject ownership still lives in the handlerUniform APIs
Policy engine (OPA/Cedar-style)Yes, if objects are passed inPolicies drift from code; needs its own testsMulti-service estates
Query-scoped ownershipYes, structurallyRequires discipline in the data layerAny app with a relational store
Relationship-based (Zanzibar-style)Yes, including sharing and nestingReal operational complexityComplex sharing models

Query-scoped ownership is the highest-leverage row for most teams and the least discussed. If every data-access function requires the principal as a parameter and applies WHERE owner_id = :principal internally, then forgetting the authorization check does not return someone else’s record — it returns nothing. The check stops being something a developer must remember and becomes something the type system and the data layer enforce. That is a far stronger guarantee than any amount of code review, and it composes well with a policy engine handling the vertical, role-shaped questions on top.

The general principle worth extracting: prefer designs where the insecure version does not work over designs where the secure version must be remembered. Every control that depends on recall will eventually be forgotten on the endpoint shipped at the end of a quarter.

Common access-control mistakes

  • Front-end-only enforcement. Hiding a button does not stop a crafted request.
  • Object obfuscation mistaken for authorization. UUIDs are not a control.
  • Silent 403s. No event means no detection.
  • Untested new endpoints. Every shipped route is a fresh chance to forget the check.
  • Logging only denials. Enumeration that succeeds is the actual breach and produces none.
  • A single global threshold. Support staff and end users have completely different baselines.
  • Conflating auth failures with authz failures. Session expiry bursts drown the real signal.
  • Reconstructing decisions from HTTP status at the edge. 403, 404, and empty-200 all mean “not yours” in different frameworks, and none of them name the object.
  • Not logging object identifiers. You cannot scope a breach you cannot enumerate.

Broken access control checklist

A copy-paste list for code review and detection:

  1. Deny by default — every resource forbidden unless a rule grants access.
  2. Enforce ownership server-side: scope queries to the principal (WHERE owner_id = :current_user).
  3. Combine RBAC (vertical access) with per-object ownership checks (horizontal access).
  4. Centralize the authorization logic and reuse it across every endpoint.
  5. Emit a structured event for every allow/deny decision (actor, object, action, outcome).
  6. Alert on one actor accessing many distinct object IDs in a short window (enumeration).
  7. Alert on denial spikes across many resources from one actor (forced browsing).
  8. Add CI tests per role per endpoint: A cannot read B’s objects; a user cannot reach admin functions.
  9. Cover PUT/PATCH enumeration and GraphQL field-level authorization, not just GET entry points.
  10. Log every allow and deny — enumeration that succeeds produces no denials at all.
  11. Carry a reason field so authentication failures are separable from authorization failures.
  12. Baseline per role, not globally; support staff and end users have different normal shapes.
  13. Emit the decision event where the check happens, not reconstructed from HTTP status at the edge.
  14. Alert on endpoints with traffic but zero authorization events — that is a missing check.
  15. Log object identifiers, so a confirmed exposure can be scoped by query rather than assumption.

Items 14 and 15 are the two with outsized payoff. The first detects the vulnerability class itself rather than its exploitation — an endpoint that never emits an authorization decision has no check to emit one. The second decides whether a breach costs you a targeted notification or a company-wide one.

Why can’t a scanner find these?

It is worth being explicit about why this category resists automation, because teams routinely assume a scanner covers it and it does not.

Access-control flaws are failures of authorization, and authorization is application-specific by definition. A scanner can tell that a URL returned 200; it cannot know that this user should not have been able to see that record, because the correct answer lives in your business rules rather than in the HTTP response. The response looks identical whether the access was legitimate or not — which is precisely what makes the class both common and long-lived.

That is why the practical detection strategy is differential: exercise the same endpoint as two users with different entitlements and compare. The bug is visible in the difference, never in either response alone. It is also why this belongs in your test suite rather than in a scanning window — the entitlement matrix is something only your own code knows, and it changes with every feature.

The takeaway

Access control cannot be patched in one place, so you defend it in two: deny by default with server-side ownership checks, and instrument every authorization decision so enumeration and forced browsing show up as the anomalies they are. The same object- and field-level authorization failures resurface in APIs — see GraphQL authorization mistakes to detect. Access control also has a physical sibling — badge and RFID cloning, covered in Proxmark3 RFID security for defenders. Continue across the web-application attack surface with SSRF detection and XSS CSP hardening, or explore the full Defensive Research pillar.

Training & tools referenced

Disclosure: Some links below are affiliate links. If you buy through them, darkpwn may earn a commission at no extra cost to you. We only recommend training and tools we actually use in our own lab, and affiliate links never influence editorial coverage.

  • TryHackMeAuthorized labs to practice access-control testing and detectionSecurity Training
    Start training

Frequently asked questions

What is the difference between IDOR and BOLA?

They are the same flaw under two names. IDOR (Insecure Direct Object Reference) is the classic web term; BOLA (Broken Object Level Authorization) is the OWASP API Security Top 10 equivalent. Both mean the server trusts a client-supplied ID without verifying the requester owns that object.

Do UUIDs prevent IDOR?

No. UUIDs raise the cost of guessing an identifier, but an attacker who obtains a valid one — from a referrer header, a shared link, or an API response — still exploits the missing ownership check. Obfuscation is not authorization.

How do you detect broken access control?

Log every authorization decision with actor, object, action, and outcome, then alert on two patterns: a single actor accessing many distinct object IDs in a short window (enumeration), and a spike of denied requests across many resources (forced browsing).

How do you set the threshold for an enumeration alert?

Derive it from your own data rather than picking a number. Measure distinct object IDs per actor per five-minute window over thirty days, and compute the distribution per role — support staff, batch jobs, and end users have genuinely different shapes. A workable start is three times the 95th percentile for that role, tightened after two weeks of live alerts. Add the deny ratio as a second condition, because many objects with many denials is far more suspicious than many objects accessed successfully.

Can you detect a missing authorization check itself?

Yes, and it is stronger than detecting exploitation. If authorization decisions are emitted as structured events at the point the check happens, an endpoint that serves traffic while producing zero authorization events has no check to emit one. Alerting on that gap finds the vulnerability before anyone exploits it.

What is the top OWASP risk?

Broken access control is A01 in the OWASP Top 10, the highest-ranked web application risk across consecutive editions, found in the large majority of applications tested.