> operator@api-test:~# curl -s -H 'Authorization: Bearer $USER_TOKEN' https://api.target.com/v2/users/1/role -X PATCH -d '{"role":"admin"}'<span class="cursor-blink">_</span>_
The application was modern in every respect. A React single-page application served from a CDN, communicating with a RESTful API backed by microservices running in Kubernetes. The code was written in TypeScript. The database was PostgreSQL with parameterised queries throughout — no SQL injection. The frontend used a modern templating engine with automatic output encoding — no cross-site scripting. The authentication layer used OAuth 2.0 with short-lived JWTs and refresh token rotation. HTTPS everywhere. CORS properly configured. Content Security Policy headers in place.
By every traditional measure — the OWASP Top 10 of five years ago, the checkbox compliance scan, the automated vulnerability assessment — this application was secure.
We compromised it in an afternoon. Not through any of the vulnerabilities it was protected against, but through the ones nobody had tested for: logic flaws in the API's authorisation model. We escalated from a standard user account to full administrative access, accessed every customer's data, and modified billing records — all by sending legitimate HTTP requests that the API accepted without question.
Every request was syntactically valid. Every request was authenticated. Every request was authorised — according to the code. The code was wrong.
The client was a B2B SaaS company providing a project management and invoicing platform. They had approximately twelve thousand business customers, each with multiple user accounts. The platform handled commercially sensitive data — project timelines, billing rates, invoices, client contact details, and payment information (tokenised via a third-party payment processor, with no raw card data stored).
We had been engaged to conduct a web application and API penetration test. The scope covered the public-facing API (api.target.com/v2), the single-page application (app.target.com), and the associated authentication and authorisation infrastructure. We were provided with two test accounts: a standard user account within a test organisation, and an organisation administrator account within the same test organisation.
The client's development team had recently completed an automated security scan using a commercial DAST tool, which had returned a clean result — no high or critical findings. They expected our manual assessment to confirm the automated result.
It did not.
Modern web applications are API applications. The user interface is a cosmetic layer — a React or Angular frontend that translates user actions into API calls and renders the responses. Every button click, every form submission, every page load generates one or more HTTP requests to the backend API. The security of the application is the security of those API endpoints.
Our first step was to map the complete API surface. We proxied all traffic from the frontend through Burp Suite and used the application extensively — creating projects, adding team members, generating invoices, modifying settings, uploading files — to capture every API endpoint the application called.
We supplemented this with analysis of the frontend JavaScript bundle, which contained route definitions and API client code that revealed additional endpoints not triggered during normal usage — administrative endpoints, internal tooling endpoints, and deprecated v1 API paths.
Forty-seven API endpoints mapped. The frontend application exposed the standard user and organisation management endpoints through its UI. But the JavaScript bundle — the compiled frontend code served to every user's browser — contained references to four additional administrative endpoints that were not rendered in the UI for standard users. The admin functionality was hidden from the interface, but the endpoints existed and were callable.
Hiding administrative functionality in the frontend is not a security control. It is a user interface decision. If the API endpoint exists and accepts requests, the absence of a button in the UI is irrelevant — the request can be constructed manually.
Broken Object-Level Authorisation (BOLA) — ranked as the number one risk in the OWASP API Security Top 10 — occurs when an API endpoint accepts a request for a resource (identified by an ID in the URL or request body) without verifying that the authenticated user is authorised to access that specific resource. The API checks who you are but not whether you should see this particular object.
We tested this by requesting resources belonging to other organisations using our standard user token.
The API returned the details of another customer's organisation — a real, production customer — using our standard user token from a completely different organisation. The API verified that the request was authenticated (valid JWT token) but did not verify that the authenticated user belonged to the requested organisation.
We tested the BOLA vulnerability systematically across all resource endpoints.
| Endpoint | BOLA Vulnerable | Data Exposed |
|---|---|---|
| GET /v2/orgs/{orgId} | Yes | Organisation name, plan, billing email, member count |
| GET /v2/orgs/{orgId}/members | Yes | Full member list — names, emails, roles, last login |
| GET /v2/projects/{projectId} | Yes | Project names, descriptions, timelines, budgets, client names |
| GET /v2/projects/{projectId}/tasks | Yes | Task details, assignees, time entries, billable rates |
| GET /v2/projects/{projectId}/files | Yes | File metadata and signed download URLs for project attachments |
| GET /v2/invoices/{invoiceId} | Yes | Full invoice detail — line items, amounts, client addresses, payment status |
| GET /v2/users/{userId} | No ✓ | Returns 403 for users outside own organisation |
| GET /v2/orgs/{orgId}/billing | No ✓ | Returns 403 for organisations the user does not belong to |
Six of eight tested resource endpoints were vulnerable to BOLA. The user endpoint and the billing endpoint correctly enforced organisation-level authorisation. The inconsistency was telling — it indicated that authorisation checks were implemented on a per-endpoint basis by individual developers, not enforced by a centralised middleware. Some developers had remembered. Some had not.
Six API endpoints permitted authenticated users to access resources belonging to any organisation by manipulating the resource ID in the request URL. The vulnerability exposed organisation details, member lists, project data, task records, file attachments, and invoice data for all twelve thousand customer organisations.
The BOLA vulnerability's impact was amplified by the predictability of the resource identifiers. Organisation IDs followed a sequential pattern (org_prod_000001 through org_prod_012847). Project IDs were sequential integers. Invoice IDs were sequential integers prefixed with the year.
Sequential IDs transform a BOLA vulnerability from a targeted attack (accessing a specific known resource) into a mass data harvesting opportunity. A simple loop iterating through the ID range could enumerate every organisation, every project, and every invoice on the platform.
One hundred sequential requests. One hundred successful responses. No rate limiting. The API imposed no restriction on the volume or velocity of requests from a single authenticated user. An attacker could enumerate the entire customer base — twelve thousand organisations, their members, their projects, their invoices — in a matter of minutes.
We did not enumerate the full dataset. We confirmed the vulnerability on five organisations, documented the finding, and stopped. The purpose was to demonstrate the capability, not to exercise it.
Mass assignment (also known as auto-binding) occurs when an API accepts a client-supplied JSON payload and applies all fields to the underlying data model without filtering for permitted attributes. If the data model includes sensitive fields — such as a user's role, an account's billing plan, or an administrator flag — and the API does not explicitly exclude these fields from client-supplied input, an attacker can modify them by simply including them in the request.
The PATCH /v2/users/{userId} endpoint allowed users to update their profile — name, email, avatar, notification preferences. We examined the request that the frontend sent when a user updated their display name.
By adding the role field to a profile update request, we changed our account's role from member to org_admin. The API accepted the field without validation. The frontend never sent the role field — it was not an option in the user interface. But the API accepted it because the backend code applied all fields from the JSON payload to the user model without an allowlist of permitted fields.
As an organisation administrator, we now had access to functionality that was previously restricted: managing team members, modifying billing settings, accessing audit logs, and — crucially — the ability to test whether the platform-level administrative endpoints were also vulnerable.
The four administrative endpoints discovered in the JavaScript bundle were intended for the client's internal support team — platform-level operations such as listing all organisations, viewing all users, impersonating customer accounts for support purposes, and modifying subscription plans.
With our mass-assigned org_admin role, we tested these endpoints.
The administrative endpoints performed a role check — but the check was insufficiently granular. The endpoint required a role containing 'admin'. Our mass-assigned role of org_admin matched the check. The intended role was platform_admin — an internal-only role used by the client's support team. But the authorisation logic used a substring match rather than an exact match, and 'org_admin' contains 'admin'.
The impersonation endpoint was the most severe finding. It issued a JWT token that allowed us to act as any user on the platform. With this capability, we could access any customer's data, modify any invoice, change any project, and perform any action as any user — a complete compromise of the application's multi-tenancy boundary.
| Step | Action | Weakness Exploited |
|---|---|---|
| 01 | Mapped 47 API endpoints including 4 hidden admin endpoints | Admin endpoints discoverable in frontend JavaScript bundle |
| 02 | Accessed other organisations' data via BOLA on 6 endpoints | No object-level authorisation — authenticated users can access any resource by ID |
| 03 | Enumerated 12,847 organisations via sequential IDs with no rate limiting | Sequential predictable IDs; no rate limiting on API requests |
| 04 | Escalated to org_admin via mass assignment on profile update endpoint | User role field writable via API; no field allowlisting on PATCH |
| 05 | Accessed platform admin endpoints — listed all orgs, all users | Substring role check: 'org_admin' matched condition requiring 'admin' |
| 06 | Used impersonation endpoint to obtain tokens for arbitrary users | Impersonation available to any 'admin' role; complete multi-tenancy bypass |
This application would have passed a traditional web application vulnerability scan with minimal findings. There was no SQL injection — parameterised queries prevented it. No cross-site scripting — output encoding prevented it. No CSRF — token-based authentication prevented it. No insecure deserialization. No XML external entity injection. The classic OWASP Top 10 vulnerabilities were absent.
The vulnerabilities we found — BOLA, mass assignment, insufficient role granularity, missing rate limiting — belong to a different category. They are logic flaws, not code flaws. They cannot be detected by automated scanners because scanners test for known patterns — malformed input that triggers errors. Logic flaws involve syntactically valid requests that produce unintended results. The request is correct. The response is correct. The authorisation decision is wrong.
The most impactful architectural change is centralised authorisation middleware. Rather than relying on individual developers to implement authorisation checks on each endpoint, a centralised middleware intercepts every API request, extracts the authenticated user's identity and organisation membership, and verifies that the requested resource belongs to the user's organisation before the request reaches the endpoint handler. This eliminates the inconsistency that arises when authorisation is implemented endpoint-by-endpoint.
Field allowlisting on all endpoints that accept client-supplied JSON must be implemented as a non-negotiable standard. Every PATCH and POST endpoint should explicitly define which fields it accepts from the client. All other fields must be stripped before the payload reaches the data model. This is a one-line configuration in most modern frameworks — an allowlist of permitted field names.
Replacing sequential IDs with UUIDs does not fix BOLA — the authorisation check must still verify that the user is permitted to access the resource. But UUIDs eliminate the enumeration vector. An attacker cannot iterate through UUIDs the way they can iterate through sequential integers. UUIDs are a defence-in-depth measure, not a primary control.
Automated authorisation testing in the CI/CD pipeline is the long-term solution to preventing BOLA regressions. Integration tests that explicitly verify cross-tenant access — creating a resource as User A and attempting to access it as User B from a different organisation — should be part of the test suite for every endpoint. If the test passes (User B is blocked), the endpoint is correctly authorised. If it fails, the deployment is blocked.
This application was well-built by competent developers using modern tools and frameworks. It had no injection vulnerabilities. It had no cryptographic weaknesses. It had no insecure configurations. By every metric that an automated scanner measures, it was secure.
But an automated scanner does not understand that User A should not see Organisation B's invoices. It does not understand that the 'role' field should not be writable by the user. It does not understand that 'org_admin' and 'platform_admin' are different levels of privilege. These are business logic decisions that exist in the application's requirements, not in its code patterns.
The evolution of web application security has been remarkable. Injection vulnerabilities are declining. Frameworks enforce secure defaults. But the attack surface has not shrunk — it has shifted. From code injection to authorisation logic. From SQL manipulation to API parameter tampering. From exploiting the language to exploiting the design.
Testing must evolve accordingly. A DAST scan is necessary. It is not sufficient. If you are not testing your API's authorisation logic with manual, business-context-aware testing, you are not testing the part of the application that is most likely to be vulnerable.
Until next time — stay sharp, stay curious, and the next time someone tells you their application passed an automated scan, ask them: did it test authorisation?
This article describes a web application and API penetration test conducted under formal engagement with full written authorisation from the client. All testing was performed against a production environment using dedicated test accounts within scope. No customer data was exfiltrated, modified, or stored beyond the minimum necessary to confirm the findings. Cross-tenant access was verified on five organisations only; full enumeration was not performed. All identifying details have been altered or omitted to preserve client confidentiality. Unauthorised access to computer systems is a criminal offence under the Computer Misuse Act 1990. Do not attempt to replicate these techniques without proper authorisation.
Hedgehog Security conducts API penetration testing that goes beyond automated scanning. We test the business logic — the authorisation decisions, the data boundaries, the role models, and the access controls that determine who can see what. BOLA, mass assignment, IDOR, and privilege escalation are the vulnerabilities of modern applications. They require human testers who understand your application's intended behaviour.