Home/Journal/Teardown
TeardownCriticalfirebasefirestoreauthorization

Sign Up, Then Read Every Patient

Open signup plus a Firestore rule that checked login but not ownership let any account read thousands of patient records. Here is how, and the fix.

Rahul Dharan··8 min read·a healthcare voice-AI companyBroken authorization on a cloud database (CWE-863)

The setup

The target was a healthcare voice-AI company - a product that turns clinical conversations into structured records, operating under HIPAA and holding patient data by design. A company at that intersection knows security matters; the sensitivity of the data is the whole reason the product needs to exist. So this is not a story about a team that ignored the problem. It is a story about the single most common way a careful team still leaves a door open.

The product was built on Firebase, and it had a self-service signup flow. Those two facts, together, are the entire setup. Firebase pushes authorization into a rules file. Open signup means anyone can become an authenticated caller. If those two things meet in the wrong way, “you need an account” quietly becomes “anyone can read everything.”

Recon

Working black-box, from the open internet, we mapped the product and its Firebase project. The client-side code named the Firestore collections the app used, including a case collection - the store of structured clinical records the product’s whole value is built around.

We also found that signup was open, and that email verification was not enforced. A throwaway email address got us a working, authenticated account in the product with no confirmation step. That is the ingredient that matters: not an anonymous client, but a logged-in one that the system should still treat as a stranger to everyone else’s data.

The hypothesis

Firebase security rules fail in a very specific and very common way. Under deadline, the natural rule to write is allow read: if request.auth != null - “you have to be logged in.” It reads like a security control. It is actually an authentication check standing in for an authorization check. It confirms who you are; it never asks whether you own the record in front of you.

So the hypothesis was: if the case collection was gated on authentication rather than ownership, then a throwaway signup - not an anonymous request, an authenticated one - would read the whole collection. Open signup would supply the account. The naive rule would supply the access.

What we tried

We created an account through the open signup flow and, from that authenticated session, issued a read against the case collection - the same query the app’s own client makes.

It returned the collection.

Not our records - the product had never seen us before and we had created nothing. The full case collection, thousands of patient PHI documents, readable by an account that had existed for minutes. The rule had checked that we were logged in. It had not checked that any of this was ours.

Then we stopped. We established the exposure the way you establish it without abusing it: a document count to prove the scope was the entire collection, and one record read a single time to confirm the contents were real patient PHI. We did not bulk-pull the collection. We left the write endpoints untested pending sign-off - reading proved the severity, and writing to a live clinical store is not something to do on a hunch.

What we found

An authenticated read from a minutes-old throwaway account returned the full case collection:

-> GET (authenticated read of the case collection, throwaway account)

<- {
     "count": <thousands>,               // entire collection, not scoped to us
     "documents": [
       { "caseId": "...", "patient": "...", "clinicalNotes": "...", ... },
       ... patient PHI documents, none of them ours ...
     ]
   }

The governing rule was the textbook mistake:

// what the rule checked
allow read: if request.auth != null;      // "is the caller logged in"

// what it needed to check
allow read: if request.auth.uid == resource.data.ownerId;   // "does the caller own this record"

One detail is worth naming, because it speaks well of the team rather than badly: some sibling endpoints had already been fixed. On re-verification, other paths that we would have expected to be vulnerable now scoped access correctly. This was a team actively hardening its product. The case collection was simply one that had not yet been reached - the fix was in progress, and this door was still open when we checked.

Why it happens

No one confused authentication with authorization on purpose. The language quietly does it for you.

“Authenticated” and “authorized” sit one word apart in English and one clause apart in a rules file, and under shipping pressure the first silently gets treated as the second. request.auth != null looks like it is doing the job - it blocks anonymous callers, it makes the app behave correctly for a legitimate logged-in user, and in testing it feels like a locked door. The failure only appears when the caller is someone the system has never met but who is nonetheless logged in. Firebase’s default posture makes this easy to land in: rules trust any signed-in user unless you write the ownership check yourself. Add open signup with no email verification, and the population of “any signed-in user” becomes “anyone on the internet who wants an account.” The vulnerability is the meeting of two reasonable-looking decisions.

For developers

If you hold sensitive records in Firestore behind a signup flow, four controls close this:

  1. Scope every rule to ownership or tenant. Replace request.auth != null with a check that compares the caller to the record: allow read: if request.auth.uid == resource.data.ownerId, or a tenant match for multi-tenant data. Authentication is the floor, not the gate.
  2. Assume signup is hostile. Open registration means the set of “authenticated users” is the entire internet. Design every rule as if the next signed-in caller is an attacker who owns nothing, because they can be.
  3. Enforce email verification - and do not let it be the security control. Verification raises the cost of a throwaway account, but a verified attacker still passes an authentication-only rule. Verify email and scope on ownership; neither substitutes for the other.
  4. Test rules from a fresh account, not just anonymously. An anonymous read is the obvious test and it is not enough. The dangerous case is the authenticated-but-unauthorized one. Create a new account with no data and try to read another tenant’s records; if anything comes back, the rule is checking the wrong thing.

And one detection step: audit every collection rule for request.auth != null with no ownership comparison. Each one is a collection that any account can read.

The takeaway

“Authenticated” is not “authorized,” and the gap between them is where sensitive data leaks. A rule that checks whether a caller is logged in grants the whole collection to anyone who can log in - and when signup is open, that is everyone. The fix is to make every rule ask the second question, ownership, not just the first. Open signup does not break a well-scoped system; it only exposes a system that mistook the login check for the access check.

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 the difference between authentication and authorization?
Authentication answers 'who are you' - it verifies a caller has a valid account. Authorization answers 'are you allowed to touch this specific record.' A database that checks only authentication lets any logged-in user read data that should be scoped to its owner, which is why the two must be separate checks.
Why is 'request.auth != null' not enough in a Firestore rule?
That rule only asks whether a caller is signed in, not whether they own the record. Every authenticated user passes it, so it grants the entire collection to anyone with an account. When signup is open, anyone can get an account, which turns 'unauthorized' into 'authenticated-but-unauthorized' - and the rule waves them through.
How do you secure a Firestore collection against authenticated-but-unauthorized reads?
Write rules that scope every read to ownership or tenant - the signed-in user's ID must match an owner or tenant field on the record. Enforce email verification so throwaway signups cannot reach data, and test rules from a freshly created account, not just as an anonymous client.

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

How it works →