Home/Journal/Teardown
TeardownCriticalfirebasefirestoreaccess-control

The Mental-Health Records Anyone Could Read

An open Firestore collection returned patient identity and depression-screening scores with no login. Here is how we found it and how to close it.

Ajay Kumar··8 min read·a healthcare care-navigation startupBroken access control on a cloud database (CWE-284/306)

The setup

The target was a healthcare care-navigation startup - the kind of product that sits between a patient and the health system, screening people, tracking how they are doing, and routing them to the right care. Products like this hold some of the most sensitive data there is. Not just names and contact details, but clinical signal: what someone was screened for, and how they scored.

The product was pre-launch and built on Firebase. That combination is worth a closer look, and not as a criticism. Firebase is a superb way to move fast - it hands a small team authentication, a database, and hosting on day one. The trade-off is that Firebase pushes the entire authorization decision into a security-rules file that is easy to get almost right. So we looked where the speed-versus-safety tension is sharpest: the database rules.

Recon

Working black-box, from the open internet with no account, we mapped the product’s front-end and the Firebase project it talked to. A Firebase-backed web app leaks more than most teams expect. The project identifiers and the shape of the data model are visible to any browser that loads the page, because the client talks to Firestore directly.

From that surface we enumerated the collections the app referenced. Most of the app behaved like a normal authenticated product: load the page, get prompted to log in. But the underlying data path - the client’s direct line to Firestore - is governed by security rules, not by whether the UI shows you a login screen. That is the layer we tested.

The hypothesis

Firebase security rules are a common miss, and they fail in a very specific way. The default instinct, when a team is moving fast, is to gate reads on authentication: allow a read if request.auth != null. That reads as “you have to be logged in,” which feels safe. It is not the same as “you may only read your own record,” and on a pre-launch build the gap is often even wider - rules left in an open state from early development, never tightened before the data went in.

So the hypothesis was simple: at least one collection would be readable without the ownership check, and possibly without any authentication at all. If that collection held patient records, an anonymous read would return clinical data.

What we tried

We issued a direct, unauthenticated read against the patient collection - the same query the app’s own client makes, minus any logged-in session. No account. No token. Just the request.

It returned records.

Then we did the part that matters as much as the finding: we stopped. We read exactly enough to confirm what the records contained and to understand the shape of the data, and we did not enumerate the collection or pull the patient base. The product was pre-launch, which meant the records we could see were early and small in number, but they were real. We treated them as real. What we saw, we shredded.

What we found

The open collection returned full patient records to an anonymous caller. Each record combined identity with clinical signal:

-> GET (unauthenticated read of the patient collection)

<- [
     {
       "patientId": "<int>",
       "name": "...",
       "phq_score": <depression-screening score>,
       "navigatorId": "...",
       "mfaCode": "<plaintext 6-digit code>",
       ...
     },
     ... over a hundred patient records, each with identity + screening score ...
   ]

Two things made this Critical rather than merely bad.

First, the payload was mental-health PHI. Not “a user exists,” but “this named person was screened for depression and here is their score.” That is among the most sensitive categories of health data, and it was readable by anyone.

Second, the same records stored MFA codes in plaintext, at rest, alongside the identity fields. A code meant to be a fleeting second factor was sitting in the database as a readable string.

The open read also leaked the structural keys that connect the system together: the patient identifiers were sequential integers, and each record carried the navigator identifier that owned the relationship. Those two facts turned a single leak into the entry point for a chain.

The chain we did not walk

The open read did more than expose records. It handed over the identifiers that the rest of the system trusted.

The patient IDs were sequential integers. That alone means a downstream endpoint keyed on patient ID can be walked from the first record to the last by counting. The leaked records also exposed the navigator identifiers and the plaintext MFA codes - and a separate, unauthenticated OTP-verify endpoint accepted those codes with no rate limit and, on success, minted a session token.

Read in sequence: the open collection leaks a patient’s identifier and a live code; the unthrottled verify endpoint turns that code into a session; the sequential patient ID lets that session walk the entire patient base. Each link was individually confirmable from the outside.

We confirmed the preconditions of that chain and stopped there. We did not submit codes to mint live tokens, and we did not pull real patient data beyond the single read that proved the first link. Proving the door is open does not require walking through every room behind it. The severity is established by the preconditions; exercising the full chain would have meant touching real people’s records, and there was no need.

Why it happens

No one made a careless decision here. This is the normal shape of building fast on a platform that trusts its rules file.

Firebase authorizes at the database, in a rules language that is quick to write and quick to get subtly wrong. The failure is almost always the same substitution: “is this caller authenticated” stands in for “does this caller own this record.” On a pre-launch build the rules are frequently even more open than that - scaffolded permissively during development so the team can iterate, with the intention of tightening them before launch. Then the data arrives before the tightening does. The MFA codes in the record are the same story: the code was written to the record to make a verification flow work, and it was never scrubbed once used. Every one of these is a byproduct of velocity, not negligence.

For developers

If you hold sensitive records in Firestore, or any client-accessible cloud database, four controls close this:

  1. Authorize on ownership, never on presence. A rule that says allow read: if request.auth != null protects nothing from another logged-in user, and on an open collection it protects nothing at all. The rule must compare the caller’s identity to the record’s owner: allow read: if request.auth.uid == resource.data.ownerId. Write the same check for every collection that holds user data.
  2. Never store MFA codes at rest. A one-time code should live only long enough to verify, and never as a plaintext field on a record. Store a short-lived hash with an expiry if you must store anything, and delete it the moment it is used.
  3. Separate identifiers from PHI. Keep clinical fields in records that are governed by their own strict rules, distinct from the lookup keys the app uses to join data. One leaked collection should not hand over both who someone is and what they were screened for.
  4. Do not key records on sequential integers. Guessable IDs turn one leaked record into a map of all of them. Use unguessable identifiers so that reading one record tells an attacker nothing about the next.

And one detection step: before any real data enters a Firestore project, run an unauthenticated read against every collection from a clean client. If anything comes back, the rules are open.

The takeaway

A cloud database does not protect a record by showing a login screen in front of it. It protects a record by asking the right question at read time - not “is someone logged in” but “does this exact caller own this exact record.” When the wrong question is asked, the most sensitive data in the system becomes an anonymous read, and the identifiers it leaks become the keys to everything downstream.

This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in The Cloud Database With the Door Left Open.

Frequently asked

What is broken access control on a cloud database?
It is a database that decides whether to return a record based on the wrong question - usually 'is anyone logged in' or nothing at all - instead of 'does this specific caller own this record.' On a service like Firestore, a permissive security rule lets any client read data that should have been private to one user.
Why is storing MFA codes in a database record dangerous?
A multi-factor code is a short-lived secret that proves possession of a phone or email. Written into a database record at rest, it stops being a second factor - anyone who can read the record can read the code, which defeats the entire point of the second factor and can be replayed to pass verification.
How do you secure a Firestore collection that holds patient data?
Write security rules that check ownership on every read and write - the authenticated user's ID must match the record's owner field - never merely that a caller is authenticated. Keep short-lived secrets like MFA codes out of stored records, and separate identifiers from sensitive clinical fields so one leak does not expose both.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →