API authorization testing: a reproducible local workflow

We reproduced an object ownership flaw with eight real HTTP requests against a small local API. Bob could read Alice's synthetic note in vulnerable mode. Enabling the server's ownership check denied that request while preserving both users' legitimate access.

This is a scripted Python and curl experiment with downloadable source and responses. No Incursion by Excession Labs agent or model executed the test. We checked the installed CLI's version and help separately. A suggested Incursion review prompt appears below; we did not run it.

Define the ownership boundary and test scope

API authorization testing checks whether an authenticated identity may perform a particular action on a particular object. Our policy is deliberately small: Alice owns alice-note, Bob owns bob-note, and neither user may read the other's note. Knowing an object's ID grants no access.

This fixture demonstrates OWASP API1:2023, Broken Object Level Authorization. OWASP recommends checking the caller's permission for the requested action and record wherever client input selects an object. A valid login alone does not establish that permission.

The fixture is intentionally insecure in its vulnerable mode. It has no configurable bind address and always listens on loopback. For a staging engagement, agree the hosts, accounts, records, allowed methods and stopping conditions with the system owner before replacing this scope. The Excession Labs Acceptable Use Policy describes authorized use. This article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into the same directory. You need Python 3.9 or later and curl; the Python code uses only the standard library. Inspect the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts vulnerable and fixed servers in sequence, issues four real curl requests to each, saves the responses and stops the servers. Each curl request bypasses proxies with --noproxy '*', has a one-second connection timeout and a three-second total timeout, and does not follow redirects. No login, model call or paid API is required.

Our recorded command was python3 verify.py --output recorded-run. It exited successfully with {"checks": 8, "passed": true, "failures": []} plus the evidence path. That means the demonstration matched its expected outcomes, including the intentionally vulnerable response.

The run on 2 November 2026 used fixture and runner version 1.0.0, Python 3.14.3 and curl 8.7.1 on macOS ARM64. Exact runtime strings, UTC timestamps, commands and source SHA-256 hashes are in results.json. A rerun will use different ports, timestamps and response dates.

Establish positive and negative controls

We first confirmed each identity could read its own object, then sent Bob's credentials with Alice's object ID. The anonymous request checks that authentication still applies. We repeated all four requests against a fresh server with the ownership check enabled.

Observed HTTP status codes from eight local requests
Request identityRequested objectPolicy expectsVulnerable modeFixed mode
AliceAlice's note200200200
BobBob's note200200200
BobAlice's note403200: note disclosed403: forbidden
AnonymousAlice's note401401401

Successful owner requests matter: a server that denies every request would also deny cross-account access, but would break the application. The anonymous 401 shows that the vulnerable server authenticates callers, so the failure is in the object permission decision that follows.

Verify the response before writing a finding

In the recorded vulnerable run, Bob's cross-account request used port 32818. This is the exact executed command, reformatted across lines:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request GET \
  --header 'Authorization: Bearer demo-bob-token' \
  http://127.0.0.1:32818/objects/alice-note

The recorded HTTP response contains these status and body values; headers are omitted from this excerpt:

HTTP/1.0 200 OK

{"id": "alice-note", "owner": "alice", "text": "Alice synthetic note"}

The server's source maps Bob's bearer value to Bob, the selected object belongs to Alice, and the response includes Alice's note. Together, those facts verify a cross-account read under this fixture's policy. A 200 status without the object body would be weaker evidence: some APIs return a generic success envelope, an empty object or a login page.

A useful finding records the affected method and path, authenticated identity, object owner, intended policy, expected denial, observed status and disclosed fields. Here the impact is disclosure of one synthetic note. This experiment does not establish a production severity rating or show unauthorized modification. The Incursion introduction explains the product's finding workflow; the finding here comes from the captured HTTP responses.

Enable the server-side check and retest

The same fixture source has two modes. In vulnerable mode, it returns an existing object after authentication. Fixed mode enables this check before sending the object:

if self.server.mode == "fixed" and obj["owner"] != identity:
    return self.reply(403, {"error": "forbidden"})

This is an executed configuration change that activates a real server-side ownership check. It is not a generated patch or a production deployment. The check is sufficient for this toy policy, where every note has exactly one owner; real systems must also account for tenant membership, sharing, role permissions and the requested action.

The runner restarted the server in fixed mode on port 32824 and repeated the same identity, method and object combinations. Bob's request for Alice's note returned 403 with {"error": "forbidden"}. Both owners still received their own note with 200, and the anonymous request still returned 401. The runner compares response bodies as well as status codes, so a denial containing the protected note would fail its checks.

To inspect the servers manually in two separate terminals, use python3 fixture.py --mode vulnerable --port 8765 and python3 fixture.py --mode fixed --port 8766. Adjust the example curl URL to the corresponding port. Stop each server with Ctrl-C. The published run used the runner and ephemeral ports.

Inspect the evidence bundle

All captured bearer values and bodies are synthetic public demo data. The bundle includes the actual HTTP response text, including dates and server headers. No production credentials or customer data were involved.

Optional Incursion review: suggested, not executed

On 2 November 2026 the installed binary returned excession 0.1.4. We also ran excession exec --help; both commands and their outputs are saved in cli-check.json. CLI 0.1.5 is current at this update, so its help text can differ from the saved output. We did not launch an agent task because agent scope enforcement and model spend were not independently verified for this experiment. No Incursion sandbox, tool approval or model behavior was exercised.

The unexecuted suggested prompt for reviewing this bundle with Incursion asks it to read only the supplied source and responses, cite case IDs and distinguish observation from inference. Its central instruction is:

Using only the recorded evidence, explain whether Bob's authenticated GET for Alice's note crossed the stated ownership boundary in vulnerable mode, and whether the identical identity and object were denied in fixed mode.

That suggestion is a review of existing evidence, not an independent retest. Prompt instructions do not enforce network scope or containment. Consult the current CLI documentation and review your tool permissions before running an agent.

What this experiment does not cover

Eight requests against one toy endpoint demonstrate one read-access boundary. We did not test writes, deletion, lists, alternate encodings, GraphQL, nested resources, shared objects, tenant hierarchies, caches, JWT validation or production infrastructure. The fixed fixture's result cannot establish that an unrelated API is secure.

For a real engagement, turn the application's permission policy into a test matrix, retain both successful and denied controls, and inspect returned data. Keep failed or inconclusive runs alongside successful reproductions. Extend the matrix only within the agreed scope, and retest the same cases after each authorization change.