← ALL_LOGS

Why Healthcare Patient Portals Keep Failing IDOR Tests

The door that was never locked

Patient portals are the front door of modern healthcare. They handle appointment scheduling, lab results, billing statements, prescription histories, and secure messaging with care teams. For millions of patients, they are the primary interface through which they engage with their own health information. For security teams, they are a recurring source of high-severity findings that should have been caught before the first user ever logged in.

With striking regularity, patient portals fail one of the most basic security tests a penetration tester can run: change a number in the URL and see whose records come back. No special tools. No elevated privileges. No exploit chain. Just a browser, a curious finger, and an incrementing integer.

This is Insecure Direct Object Reference IDOR and it sits squarely under OWASP’s A01: Broken Access Control for good reason. It is not an obscure vulnerability class that catches only the most sophisticated developers off guard. It is a fundamental failure to answer the question every application must answer before returning sensitive data: does the person asking for this resource have the right to see it?

In healthcare, where the resources in question are protected health information governed by HIPAA, the consequences of getting that answer wrong are not theoretical. They are regulatory investigations, mandatory breach notifications, and the quiet, irreversible erosion of patient trust.


What IDOR looks like in a portal

The pattern is almost always the same, and it is almost always obvious once you know to look for it. A patient logs in, navigates to their test results, and lands on a URL structured like this:

GET /portal/results?patient_id=10042

The application fetches records for patient 10042 which happens to be the logged-in user and renders them on screen. The request looks reasonable. The response looks correct. The feature works exactly as designed.

The problem is what happens when you change the parameter:

GET /portal/results?patient_id=10043

In a vulnerable application, the backend trusts the client-supplied ID without checking it against the authenticated session. It queries the database for patient 10043’s records and returns them to a user who has no clinical, legal, or any other relationship to that patient. No error. No redirect. Just someone else’s lab results, rendered as helpfully as if they were your own.

The attack scales trivially. A simple script iterating through patient IDs from 10000 to 99999 can enumerate an entire patient population in under an hour, pulling names, dates of birth, diagnoses, medications, and test results for every record in the system. The attacker does not need to be sophisticated. They need to be patient and have a copy of Python.

What makes this particularly damaging in a healthcare context is the sensitivity of the data involved. A leaked email address from a retail breach is annoying. A leaked HIV diagnosis, mental health history, or reproductive health record can affect employment, insurance, custody proceedings, and personal safety. IDOR in a patient portal is not a medium-severity finding to be addressed in the next quarterly release cycle. It is a critical vulnerability that should halt deployment until resolved.


Why it keeps slipping through

Given how simple IDOR is to find and fix, its persistence across healthcare portals demands an explanation. There are several compounding factors, and understanding them is necessary for addressing the root cause rather than just patching individual instances.

Outsourced development without security requirements. Portal development is frequently outsourced to healthcare IT vendors, regional system integrators, or offshore development teams whose contracts are scoped around functional requirements: the portal must allow patients to view results, schedule appointments, and send messages. Security testing and in particular, authorization testing is rarely a contractual deliverable. Vendors deliver working software. Whether that software correctly enforces access control is often not tested until a third-party pen tester is brought in months or years after go-live.

Sequential numeric IDs as the default. Auto-increment integer primary keys are the path of least resistance in virtually every relational database framework. They are easy to implement, easy to read in logs, and easy to debug during development. No developer reaches for a UUID when an integer works perfectly well in a local environment where there is only one test user. The ID becomes part of the API contract, gets exposed in URLs and API responses, and stays there because changing it later is an engineering effort that never quite makes it to the top of the backlog.

Access control logic living in the UI layer only. This is perhaps the most common failure mode. The application is built so that a logged-in patient only sees links and buttons for their own records. A QA tester clicks through the interface, confirms they cannot navigate to another patient’s data, and marks the feature as passing. The API layer which is the layer that actually fetches and returns data is never tested in isolation. A tester who only uses the UI will never find this. A tester who opens Burp Suite, captures the outbound request, and replays it with a modified parameter will find it in minutes.

Lack of developer awareness around authorization versus authentication. Many developers have a solid grasp of authentication ensuring that users are who they say they are but treat authorization as a UI concern rather than a backend responsibility. If the user is logged in, they are authenticated. If the UI doesn’t show them unauthorized data, they are authorized. This conflation is the conceptual error that IDOR exploits. Authentication and authorization are separate problems, and both must be enforced at the API layer, independently of what the front end chooses to render.


The one-hour fix

There is no single silver bullet that eliminates IDOR from an existing codebase with one commit. But the following three changes address the root cause for most portal implementations and can realistically be scoped, reviewed, tested, and deployed within a single sprint often within a single working day for a focused engineering team.

Fix 01 — Enforce ownership server-side on every data fetch

Every API endpoint that returns patient data must validate that the requested resource belongs to the authenticated user before returning anything. The session not the request parameter is the authoritative source of identity.

In practice, this means the query must join against the session context. The client-supplied parameter becomes informational at best; the session context is what determines what data the user is permitted to retrieve.

Before — vulnerable:

// Trusts the client-supplied patient_id entirely
const results = await db.query(
  'SELECT * FROM results WHERE patient_id = ?',
  [req.query.patient_id]
);

After — fixed:

// Validates the requested ID against the authenticated session
const results = await db.query(
  'SELECT * FROM results WHERE patient_id = ? AND patient_id = ?',
  [req.query.patient_id, req.session.patient_id]
);

if (results.length === 0) {
  return res.status(403).json({ error: 'Access denied' });
}

Two important details in the fixed version: first, if the IDs do not match, the query returns nothing and the application responds with a 403 Forbidden not a 404 Not Found. Returning a 404 leaks the fact that a record with that ID exists, which itself constitutes an information disclosure. A 403 reveals nothing about the existence of the resource. Second, in many implementations you can simplify further by removing the client-supplied ID entirely and always using the session value:

// Cleanest approach ignore client-supplied ID entirely
const results = await db.query(
  'SELECT * FROM results WHERE patient_id = ?',
  [req.session.patient_id]
);

If the application genuinely needs to support a patient_id parameter for example, in a clinician-facing view the logic must explicitly check whether the requesting user has a clinical relationship to that patient before proceeding.

Fix 02 — Replace sequential integer IDs with UUIDs in public-facing references

Switching from auto-increment integers to UUIDs in URLs and API responses does not fix broken access control. A UUID-based endpoint with no ownership check is just as vulnerable just harder to enumerate by guessing. But replacing sequential IDs eliminates the low-effort mass enumeration that turns a theoretical vulnerability into a practical mass breach within minutes.

// Generate a UUID at record creation
const { v4: uuidv4 } = require('uuid');
const publicId = uuidv4(); // e.g. '3f6d8a21-9c4e-4b2a-b7f1-0e5d1c3a9b7e'

// Store and expose this instead of the internal integer primary key
await db.query(
  'INSERT INTO results (internal_id, public_id, patient_id, ...) VALUES (?, ?, ?, ...)',
  [internalId, publicId, patientId, ...]
);

The URL then becomes:

GET /portal/results/3f6d8a21-9c4e-4b2a-b7f1-0e5d1c3a9b7e

An attacker cannot enumerate this. They can still attempt to access a known UUID for example, one they observed in their own session for a different user, which is why fix 01 remains essential. But the combination of UUIDs and server-side ownership checks eliminates both the enumeration vector and the authorization bypass.

Fix 03 — Centralize authorization in a middleware layer

The most durable fix is not patching individual endpoints but restructuring how authorization is enforced across the application. Rather than relying on every developer, on every feature, in every sprint, to remember to add an ownership check to every new endpoint, centralize the check in middleware that runs before any handler executes.

// Authorization middleware
async function requireOwnership(resourceType) {
  return async (req, res, next) => {
    const resourceId = req.params.id || req.query.id;
    const sessionUserId = req.session.patient_id;

    const resource = await db.query(
      `SELECT patient_id FROM ${resourceType} WHERE public_id = ?`,
      [resourceId]
    );

    if (!resource.length || resource[0].patient_id !== sessionUserId) {
      return res.status(403).json({ error: 'Access denied' });
    }

    next();
  };
}

// Applied to routes
router.get('/results/:id', requireOwnership('results'), getResultsHandler);
router.get('/appointments/:id', requireOwnership('appointments'), getAppointmentHandler);
router.get('/billing/:id', requireOwnership('billing'), getBillingHandler);

With this pattern, the ownership check is impossible to accidentally omit. A new endpoint that does not apply the middleware will fail code review not because reviewers catch the missing check by luck, but because the absence of the middleware is visible in the route definition. Authorization becomes opt-out-impossible rather than opt-in-easy-to-forget.


Test before you ship — a twenty-minute protocol

The deeper fix is process, not code. Every patient portal feature that returns personal health information should pass a mandatory access control test before it is merged to main not a full penetration test, not a security audit, just a structured and repeatable walkthrough that any developer or QA engineer can execute.

The protocol is simple:

  1. Log in as patient A. Navigate to the resource being tested. Capture the outbound request using browser developer tools or a proxy.
  2. Note the resource identifier in the URL or request body.
  3. Log in as patient B in a separate browser session.
  4. Replay the request from step 1 using patient B’s session cookie and patient A’s resource identifier.
  5. Confirm that the response is a 403, not the requested data.

This takes approximately twenty minutes per endpoint type. It catches IDOR every time. It requires no special tooling just two test accounts and a browser. And it costs a fraction of the incident response, breach notification, OCR investigation, and reputational damage that follows when a security researcher or a threat actor runs the same test against your production system and gets a different answer.

Build this test into your definition of done for any endpoint that returns PHI. Make it a required checklist item in your pull request template. Automate it in your integration test suite using two seeded test users and assertions on the response status code. The implementation is trivial. The protection is real.


The bottom line

IDOR is not a sophisticated attack. It does not require nation-state resources, zero-day exploits, or months of reconnaissance. It requires a browser and the observation that the number in the URL is probably not the only number that works. The fact that it continues to appear in patient portal assessments year after year, vendor after vendor is not a reflection of the complexity of the problem. It is a reflection of how rarely authorization is tested as a first-class concern.

Healthcare organizations that deploy patient portals have an obligation to their patients that extends beyond HIPAA compliance. They have accepted responsibility for some of the most sensitive information those patients will ever share. Meeting that obligation means testing access control before go-live, not after a breach. It means building authorization checks into the development process, not bolting them on during a remediation sprint. And it means treating a finding like IDOR with the urgency it deserves not as a medium-severity item to be addressed eventually, but as a critical failure that has no business being in a production system that handles protected health information.

The fix is an afternoon of engineering work. The cost of not fixing it is measured in patient harm, regulatory consequence, and trust that cannot be rebuilt.