Case Study · Security · Incident Response

Anatomy of a Breach — 400k records via one IDOR

MediSync, a healthtech, leaked 400,000 patient records through a single broken-access-control bug. No exotic exploit, no zero-day — just an API that checked who you are and forgot to check what you're allowed to see. Here's exactly how it happened, and how to make sure it can't happen to you.

A worked application of the Application Security playbook

0The bug — authenticated, but not authorized

MediSync's patient API had solid authentication — login, MFA, sessions. Its endpoint GET /api/patients/:id returned a patient record. The fatal line: it fetched the record by the id in the URL and never checked that the logged-in user was allowed to see that patient. Being logged in was treated as permission to read anyone.

⚠️ IDOR / broken access control — the #1 web risk (OWASP A01)

An Insecure Direct Object Reference: the API returns an object based on an id the client supplies, without checking they own or may access it. It's trivial to exploit — increment a number — and catastrophic in impact. It tops the OWASP Top 10 and leaks more data than any exotic exploit.

Authentication answered "who are you?" Authorization — "are you allowed to touch THIS?" — was never asked. Those are two separate checks, and forgetting the second on even one endpoint is a breach.

The missing check, on the wrong side of the trust boundary
   user logged in ✓        but: do they OWN patient 1043? ✗ never checked
   GET /api/patients/1043
        │
   ❌ SELECT * FROM patients WHERE id = 1043               → returns anyone
   ✅ SELECT * FROM patients WHERE id = 1043
                          AND owner_user_id = :sessionUser → returns nothing
   the id comes from the URL (attacker-controlled);
   the owner check must come from the SIGNED SESSION — on EVERY object.
The one line that caused the breach — and its fix
// ❌ what shipped: fetch by client-supplied id, no ownership check (IDOR)
app.get('/api/patients/:id', requireAuth, async (req, res) => {
  const patient = await db.patient(req.params.id)   // ANY id works
  res.json(patient)
})

// ✅ the fix: bind the object to the SESSION user in the query itself
app.get('/api/patients/:id', requireAuth, async (req, res) => {
  const patient = await db.query(
    'SELECT * FROM patients WHERE id = $1 AND owner_user_id = $2',
    [req.params.id, req.user.id])                   // owner from SESSION, not URL
  if (!patient) return res.status(404).end()        // 404: don't confirm it exists
  res.json(patient)
})

1The attack — enumerate and scrape

A curious (then malicious) user noticed their record lived at /api/patients/1042. They changed it to 1043 and got someone else's chart. From there it was a five-line script: loop the ids from 1 to 400,000 and save every response. In hours, the whole database was on the attacker's disk.

Sequence diagram — the enumeration attack
sequenceDiagram autonumber participant Atk as Attacker participant App as MediSync API participant DB as Database participant Log as Audit log Atk->>App: GET /api/patients/1042 (their own record) App->>DB: SELECT WHERE id = 1042 DB-->>App: patient 1042 App-->>Atk: 200 OK Note over Atk: change the number, script it loop id from 1 to 400000 Atk->>App: GET /api/patients/ID App->>DB: SELECT WHERE id = ID DB-->>App: ANY patient — no owner check App-->>Atk: 200 OK, another patient record end App--)Log: 400k reads, one user, one IP — but nobody watched the log
Renders with Mermaid. The attack is described in the text above.
Why it worked so well: the ids were sequential integers, so enumeration was trivial. UUIDs would have slowed guessing — but note carefully: non-guessable ids are NOT authorization. They're obscurity. The real bug is the missing ownership check; a UUID just makes it take longer to find. Fix the authz, and the id format stops mattering.

2Incident response — what MediSync did next

A researcher reported it; MediSync had minutes that mattered. The response follows the classic Detect → Contain → Eradicate → Recover → Learn — run from a rehearsed plan, not improvised. Expand each stage.

ADetect & assessthe clock starts here

Trigger: a security researcher reports being able to read other patients' records by changing the id.

  1. Confirm and scope. Reproduce the bug safely; check access logs for who else did it — one user pulling 400k records from one IP is unmistakable in hindsight.
  2. Declare an incident. Assign an incident lead, a comms owner, and loop in legal early — this is regulated health data (HIPAA), so the notification clock is real.
  3. Preserve evidence. Snapshot logs before touching anything, so you can later prove what was accessed and when.
BContain

Goal: stop the bleeding without destroying evidence.

  1. Cut the access path. Disable the vulnerable endpoint (feature flag) or put an emergency authorization check / WAF rule in front of it immediately.
  2. Revoke the attacker. Kill the offending account and sessions; block the IP as a stopgap (they can rotate, so this is temporary).
  3. Rate-limit the endpoint hard so no one can mass-enumerate while you build the real fix.
CEradicate — fix the root cause

Goal: fix the actual vulnerability, not just the reported endpoint.

  1. Add object-level authorization (Section 3) to the endpoint — bind every object to the session user in the query.
  2. Audit EVERY endpoint for the same class of bug — IDOR is rarely alone. Check every route that takes an id: detail, update, delete, and nested resources.
  3. Deploy the fix and verify it as another user (automated "can user A read user B's object?" tests).
DRecover & notify

Goal: restore normal service and meet legal obligations.

  1. Re-enable the endpoint with the fix and monitoring in place; confirm normal traffic.
  2. Determine what was accessed from the preserved logs — which records, whose PHI — to scope the notification.
  3. Notify affected patients and regulators per HIPAA/GDPR timelines. Transparency here is both legally required and reputation-preserving.
ELearn — a blameless post-mortem

Goal: make this class of bug structurally impossible, and improve detection.

  1. Root cause, blamelessly: object-level authz wasn't a required pattern, and bulk-read anomalies weren't alarmed. Systems, not people.
  2. Systematize the fix: a shared authorization helper every endpoint must use, plus automated cross-user access tests in CI (Section 3).
  3. Fix detection: alarm on the signal that was screaming — one user reading thousands of distinct records — so next time you catch it in minutes, not from a researcher.

3The fixes — so it can't recur

One endpoint's patch isn't the fix; the fix is making broken access control structurally hard and detectable. Three layers, plus a data model that encodes ownership.

Entity diagram — encode ownership in the model
erDiagram USER ||--|| ROLE : has USER ||--o{ PATIENT : "authorized for" PATIENT ||--o{ RECORD : owns ACCESS_LOG }o--|| USER : "who accessed" USER { bigint user_id string role "patient / clinician / admin" } PATIENT { bigint patient_id bigint owner_user_id "the authz link that was missing" } RECORD { bigint record_id bigint patient_id string phi "protected health info" } ACCESS_LOG { bigint user_id bigint patient_id datetime at }
Renders with Mermaid. Every object carries an owner; every access is logged.
LayerFixStops
1 · Authorize every objectBind each object to the session user in the query (or a central policy check); return 404 for objects the user can't seeThe IDOR itself (A01)
2 · DetectLog every sensitive access; alarm on anomalies — one user reading many distinct records, spikes in 403/404, off-hours bulk readsThe breach running unnoticed for hours (A09)
3 · Slow the abuseRate-limit per account and per IP; add CAPTCHA/step-up on anomalous patternsMass enumeration; buys time to respond
🛡️
The systemic fixObject-level authorization can't be one middleware you hope covers everything — it's per object, per action, on every endpoint. MediSync made it a required shared helper and added CI tests that try to read another user's objects. Now a new endpoint that forgets the check fails the build, not a patient's privacy.

4Readiness scorecard

Ten questions. MediSync can now answer "yes" to all of them; before the breach, it failed the first two.

Every object access checks ownership/role server-side, using the session identity — on every endpoint (detail, update, delete, nested).
The owner check is in the query (scope by session user), not a filter applied after fetching.
Objects the user may not see return 404, not 403 (don't confirm existence).
Authorization is a shared, required helper, not copy-pasted per route.
CI has cross-user access tests ("can A read B's object?") that fail the build.
Sensitive reads are logged; anomalies (bulk reads, 403/404 spikes) alarm a human.
Endpoints are rate-limited per account and per IP.
Ids may be UUIDs for defense-in-depth — but the team knows that's obscurity, not authorization.
There's a written, drilled incident-response plan with roles, comms, and the breach-notification clock.
Access logs are preserved and queryable so "what was accessed?" is answerable in minutes.
The one-line takeaway: the most damaging web breaches aren't clever — they're a forgotten authorization check on an endpoint that authenticated just fine. Prove ownership on every object, every time, from the signed session; log and alarm on bulk access; and make the check a build requirement, not a hope.