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.
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.// ❌ 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.
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 & assess▶
Trigger: a security researcher reports being able to read other patients' records by changing the id.
- 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.
- 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.
- 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.
- Cut the access path. Disable the vulnerable endpoint (feature flag) or put an emergency authorization check / WAF rule in front of it immediately.
- Revoke the attacker. Kill the offending account and sessions; block the IP as a stopgap (they can rotate, so this is temporary).
- 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.
- Add object-level authorization (Section 3) to the endpoint — bind every object to the session user in the query.
- 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.
- 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.
- Re-enable the endpoint with the fix and monitoring in place; confirm normal traffic.
- Determine what was accessed from the preserved logs — which records, whose PHI — to scope the notification.
- 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.
- Root cause, blamelessly: object-level authz wasn't a required pattern, and bulk-read anomalies weren't alarmed. Systems, not people.
- Systematize the fix: a shared authorization helper every endpoint must use, plus automated cross-user access tests in CI (Section 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.
| Layer | Fix | Stops |
|---|---|---|
| 1 · Authorize every object | Bind each object to the session user in the query (or a central policy check); return 404 for objects the user can't see | The IDOR itself (A01) |
| 2 · Detect | Log every sensitive access; alarm on anomalies — one user reading many distinct records, spikes in 403/404, off-hours bulk reads | The breach running unnoticed for hours (A09) |
| 3 · Slow the abuse | Rate-limit per account and per IP; add CAPTCHA/step-up on anomalous patterns | Mass enumeration; buys time to respond |
4Readiness scorecard
Ten questions. MediSync can now answer "yes" to all of them; before the breach, it failed the first two.