Black Shard

Insights6 July 2026

What an API penetration test finds that a web application test misses

A browser-driven test exercises the API through the one path the interface allows. This sets out what a separate API engagement adds: the authorisation matrix across roles and tenants, mass assignment, versions nobody retired, GraphQL cost limits, and tokens issued to machines.

Dense bundles of fibre patch leads fanning into an unlabelled switch panel, lit cold cyan on dark slate

Why does a web application test miss most of the API surface?

A browser-driven test exercises the API through one path, the path the interface allows. The tester clicks what the application renders, and the parameters that travel are the ones the form collects. That covers the product as a user meets it, and the server accepts a great deal more than the interface ever sends.

An account settings page might issue four requests. The router behind it exposes the whole resource family: the collection, the item, its nested collections, the bulk operations the mobile client uses, the export the reporting service calls, and the administrative operations no interface links to. The front end reaches a fraction of what the server will answer.

The second gap is state. An interface hides transitions it does not want taken, so the button is not rendered and the wizard refuses to advance until step two is complete. None of that constrains the server. An API test drives those transitions directly, in the wrong order, twice, and from an account that should not be able to make them at all.

What an API penetration test needs before it starts

Three inputs decide whether the engagement covers the API or samples it. The first is a machine-readable description of the surface: an OpenAPI document, a GraphQL schema export, a request collection from the client team, or, failing all three, the routing table dumped from the framework. Hand over captured mobile traffic as well, since the mobile client usually calls operations the web front end never touches.

The second is accounts, where the shape matters more than the count. That means two accounts in every role, held in two separate tenants. One account per role only proves an endpoint answers; it cannot prove the object check exists, because there is no second object of the same kind belonging to somebody else to ask for. Two tenants turn cross-tenant access from an inference into a demonstration, because the tester holds a real identifier from tenant B and a valid session in tenant A.

The third is an explicit position on rate limiting. Replaying every operation against every role, then again with no token and with a cross-tenant identifier, turns a few hundred operations into several thousand requests, so a gateway throttling at a threshold tuned for humans leaves the engagement measuring the gateway. Settle beforehand whether the tester's addresses are allow-listed at the edge, and whether resource-consumption behaviour is tested deliberately in its own window. Authentication has to be repeatable without a human, because a token that expires in fifteen minutes behind an SMS challenge reduces an API security assessment to spot checks.

The authorisation matrix is most of the engagement

Most of what an API test finds is one question asked systematically: for every operation, for every role, on every object, does the server check what it should. The current edition of the OWASP API Security Top 10 is the 2023 list, and three of its ten entries are authorisation failures. They look alike in a report and they are separate bugs with separate fixes.

The write side of the property-level class is mass assignment. A profile update endpoint accepts a body and copies it onto the user record, and the interface sends a display name and a time zone. The tester sends those plus a role field, a tenant identifier or an email-verified flag, and it binds, because plenty of frameworks map a request body onto a model by matching names and leave the allow-list to the developer. The field names never have to be guessed, since the same endpoint returns them in its own response.

The same defect pointed outward is over-exposure on the way back. A current-user endpoint returns the fully serialised row, including a password reset token or an internal risk score, and it stays invisible in the browser because the front end renders three of those fields. The fix is a response contract: an explicit projection per endpoint and per role, so what the handler loads and what the client receives are two separate decisions.

Driving the matrix is mechanical once the accounts exist. Capture one authenticated request per operation, replay each with every other role's token, with no token, and with an identifier owned by the other tenant, recording status and body every time. A correct server answers with a consistent refusal across the whole grid. We treat the object-level class on its own elsewhere in these notes, including why unguessable identifiers do not fix it. The point here is that a single-tenant engagement cannot produce this grid at all.

  • Object level authorisation, API1:2023, is the check that the record you asked for belongs to you. It fails when a handler loads by identifier, confirms the record exists, and returns it without ever asking whose it is.
  • Function level authorisation, API5:2023, is the check that your role may invoke this operation. It fails when a route is registered and the handler verifies authentication but not entitlement, so the role-assignment endpoint with no button in a standard user's interface answers that user's token exactly as it answers an administrator's.
  • Object property level authorisation, API3:2023, is the check on individual fields in both directions: which fields a caller may set on the way in, and which fields a caller may see on the way out.

Error responses and business flows taken out of order

Error responses are the second reliable source of findings, because they are written for developers and then shipped to everybody. A login endpoint that returns one status for an unknown address and another for a wrong password lets an attacker confirm which addresses hold accounts, one request at a time. A password reset that answers quickly for addresses it does not recognise and slowly for the ones it does leaks the same fact with identical bodies, because only the real path stops to send mail.

Business flow abuse, API6:2023, is the class no scanner finds, because no single request is malformed. Every call is well formed and authorised, and the abuse is in the sequence. A refund endpoint with no idempotency key can be replayed against one order until the money runs out. A checkout that computes a total in one call and applies a discount in another can be driven in reverse, and two concurrent requests against one balance check can both pass when the check and the write are not in a single transaction.

Testing that class means understanding the domain before touching a request. The tester reads the state machine, writes down every transition and its preconditions, then attacks them: replay a transition, invoke it from a state that should forbid it, invoke two at once, and take the step the interface only offers after payment before paying. Asking whoever owns the product which sequences would cost real money usually surfaces cases nobody had written down.

Resource consumption, API4:2023, needs no authorisation flaw to hurt. A page-size parameter that accepts a hundred thousand, an unbounded export running against the primary database, or an endpoint that sends an SMS before authentication all cost somebody money, and a web application test rarely reaches them because the interface caps the input long before the server does.

Does the OpenAPI document match what the server actually routes?

The specification is the most useful artefact an API tester is handed and the one most likely to be out of date. As an oracle it is excellent, naming every parameter, its type and its permitted values, so boundary and type-confusion cases are generated from the document. It also states what each operation is supposed to require, so an endpoint answering without the authentication the document claims is a finding you can prove against the client's own artefact.

Drift is the problem. A document generated by annotation describes what somebody remembered to annotate, and a hand-maintained one describes what the API looked like when someone last cared. The router is the authority either way, and the gap between them is where the forgotten surface lives: the previous major version still mounted for a customer who never migrated, the internal endpoint added for a support tool, the debug route behind a flag that is on in production.

So the test runs in both directions. Every documented operation gets exercised, and the real router gets enumerated independently by dumping the framework route table, diffing mobile traffic against the document, and searching the JavaScript bundles for paths the document does not contain. Old versions repay that effort, because authorisation middleware tends to be added to the version under active development. A check retrofitted into version three is frequently never backported to version one, which is still mounted and still answering.

This is API9:2023, improper inventory management, a governance failure presenting as a technical one. The remedy is a routing table somebody owns: every host, every version, every operation, its authentication requirement, its data classification, and a retirement date against anything deprecated.

What is different about testing a GraphQL API?

The transport collapses to one endpoint and one method, which breaks the assumptions behind most edge tooling. A gateway rule permitting a POST to the graph endpoint permits every operation the schema exposes, and path-based rate limits, filtering rules and access logs stop distinguishing a cheap lookup from a query that walks the entire customer table. An early check in any GraphQL security testing is whether anything in front of the application can still tell those apart, and usually nothing can.

Treat the schema as public. Introspection returns every type, field and argument, which hands a tester a complete map. Disabling it in production is worth doing and it does not count as a control, because field suggestions in error messages reconstruct names for anyone willing to spend the requests.

Cost and batching are the resource problem. Because types reference each other, a short document can ask for a customer, their orders, each order's customer and that customer's orders again, and the server will execute it. Depth limiting, complexity scoring against a per-operation budget and mandatory pagination are the controls worth verifying. Batching is the part people miss, because many servers accept an array of operations in one HTTP request, and aliasing lets one document invoke the same field hundreds of times, brute-forcing a login mutation while the gateway records a single request.

Authorisation has to live on the resolver, because there is no single entry point to guard. A type is reachable through every path in the graph that returns it, so a field guarded at the top-level query is unguarded when the same object arrives as a nested field of something else. The test is to reach an object by an indirect route and see whether the check follows. Allow-listing persisted operations shrinks the surface and does not substitute for the check on the resolver.

What can a leaked service token reach?

Machine-to-machine callers break the assumptions the rest of the model rests on. There is no session, no second factor, and no person whose role narrows what the call may touch. A token issued through a client-credentials grant carries scopes, and those scopes are the entire authorisation model for that caller. Scopes tend to be coarse, because they were written when the integration was built and widened whenever something broke.

So the test takes the credential the reporting integration uses and calls the operations that have nothing to do with reporting. Establish whether the token is bound to one tenant or all of them, whether it can write as well as read, whether the audit trail records the person who triggered the action or only the service principal, and how long the token stays valid.

The fixes are ordinary. Issue a distinct credential for each integration and each tenant, and keep lifetimes short enough that rotation is routine. Bind the token to its holder where the stack allows it, with certificate-bound tokens over mutual TLS as described in RFC 8705, or application-layer proof of possession as described in RFC 9449. Check what the service trusts from its own upstreams too, since an API that accepts a caller identity from an internal gateway header will accept that header from anyone who can reach the service directly.

What stops the same finding coming back

An API report describes a router on the day it was tested, and the router keeps changing. The classes above recur because they are what a framework does when nobody intervenes: binding a request body by convention, serialising the whole model, mounting a new version beside the old one, guarding at the entry point and never at the object. A retest proves the instances named in the report are closed, and keeping them closed is a build problem.

Two kinds of test earn their place in the pipeline. Contract tests assert that each response carries exactly the fields the contract names, so a new column on a model does not silently reach every client, and that a request carrying fields the contract does not name is rejected. Negative authorisation tests assert the other half of the matrix, so for a representative object of each resource type, a caller from another tenant and a caller in a lower role both receive a refusal.

The inventory deserves the same treatment. Generate the route list from the running application during the build, diff it against the committed specification, and fail the build on any route the document does not describe. That single gate closes most of the improper-inventory class permanently, because an endpoint cannot be forgotten if the build enumerates it on every commit.

We build and run production systems as well as test them. The operations and compliance portal we built for GRM LAW, a Brisbane law firm, covers intake, conflicts checks, the matter register and AML/CTF readiness over an append-only audit ledger. The staff portal we run for Stone Leaf Capital, an Australian capital-markets firm, records actor, action, and before and after state in its compliance audit log. Aurii, our own clinical software venture, is Australian-hosted on Azure and handles tenant health data with tamper-evident audit trails. Our approach page sets out how an engagement runs. For API work, findings arrive as reproducible requests carrying the exact body, headers and account that produced them, and the retest re-runs the whole matrix, because a fix applied in one handler rarely reaches its neighbours.

See what an attacker would find.

Australia-wide, from our Brisbane head office. Someone will contact you as soon as possible.

Open a briefinfo@blackshard.com.au