Acme Corp (name changed). Work by the founding couple, Q3 2026. All figures measured.
The servers were clean. The code was not.
Passwords stolen elsewhere, an endpoint that never checked who owned a record, and five root causes in the code and the web server. How we answered it, read-only first, and what we measured.
What happened
Acme Corp runs a multi-tenant web platform: a PHP application behind nginx and load balancers. Automated scans from commercial VPN and hosting addresses had been running for three days before our session.
Then an attacker tried passwords stolen elsewhere on the platform's login: 374 successful logins in 908 attempts, into at least 10 real user accounts. The attacker opened no account of their own: a CAPTCHA stopped the sign-up attempts. From the hijacked sessions the attacker pulled other tenants' records in bulk, through an endpoint that never checked who owned the record.
How we worked
We worked in one Claude Code session, with one subagent, read-only first. Claude Code read the logs, the code and the servers; we asked the questions and judged every answer. Every change had a backup and a way back.
The first 36 minutes
We asked the logs where the theft came from. From the first request on, its signature came only from commercial VPN and hosting ranges. 36 minutes into the work we blocked the attacker's addresses at the application and the load balancers, and temporarily closed the leaking endpoints. After the block the attacker sent more than 94,000 further requests; not one got through.
We sorted every address in the logs: customer, bot, VPN or data centre, and residential. We checked the residential addresses one by one; each was a real customer of the platform.
The servers were clean
We scanned all 16 of Acme Corp's VMs, read-only: no new users, no web shell, no unexpected file changes or processes. The source code, the database passwords and the file system did not leak. The breach was in the application layer.
Five root causes
We asked the code what let the attacker in. It gave five root causes.
- No ownership check. The endpoint the records left through, and dozens of others, returned a record without checking which tenant it belonged to.
- Protection in name only. 50 endpoints took the tenant ID from the request body, so any user could send another tenant's.
- A router bypass. Internal module and core paths answered direct calls, with no login.
- No rate limit. Nothing limited how often logins and verification codes could be tried.
- Exposed files. A phpinfo page, directory listings and dependency lock files were open to anyone.
What we changed
We closed the first four in under three hours from the first message, in the code and in nginx.
- One ownership guard. A single PHP function that answers 403 when a session touches a record of another tenant. With no session or no record it lets the request through, so cron jobs and webhooks keep working. After a scan of the whole code base we put it into 229 files.
- A 404 for internal paths. nginx answers direct calls to internal paths with a 404.
- A rate limit. nginx limits login and verification-code POSTs per IP address; GET requests are not affected.
- A live nginx swap. The rate limit needed a module the running nginx had been built without. We swapped in the official package binary of the same version, live, with USR2, and let the old master drain. Not one request dropped during the swap. We held the packages, so an automatic update would not undo it.
Every changed PHP file passed php -l, and every nginx change passed nginx -t before the reload. The fifth root cause, the exposed files, went to Acme Corp's own team, on the to-do list.
How we checked
- The guard's unit test. 5 of 5 scenarios pass: the same tenant gets through, another tenant gets a 403, and no session, no record or an empty ID let the request through.
- Production, after the patch. 219 legitimate requests answered 200: 0 false positives. The guard's log tag stayed empty for real users.
- The rate limit. 10 fast login POSTs: 7 answered 200, then 429.
- The block. Of more than 94,000 attacker requests after it, not one got through.
What Acme Corp got
Acme Corp's team got a full report: the real diffs, the timeline, where every address came from, and a to-do list. It named the next lead, a TLS fingerprint.
- 36 minfrom the start of our session to the attacker blocked
- 0 of 94,000+attacker requests that got through after the block
- 16 of 16VMs clean in a read-only scan: no new users, no web shell, no unexpected file changes
- 4 of 5root causes closed in under three hours, in the code and in nginx; we handed the fifth to Acme Corp's own team
- 374 of 908logins with stolen passwords succeeded, into at least 10 real user accounts
- 50endpoints took the tenant ID from the request body
- 229files behind one ownership guard
- 0 of 219legitimate requests blocked by the guard after the patch
- 5 of 5unit-test scenarios the guard passes
- 0requests dropped during the live nginx swap
On your estate we start the same way, read-only, and every change waits for your approval. The health check reads your infrastructure; for code or an incident, book a call.