The rule that has to be written twice
Firestore security rules do not cascade to subcollections. Forget that in one place and a single query returns every private message on the platform.
6 min readGlitz · Rules stage
On the creator marketplace the apps talk to the database directly. There is no API layer between a phone and a document, which is the normal shape for a Firestore app, and it moves the entire authorisation model into one file of 696 lines. Every question of who may read or write what is answered there, in a rules language with no loops and no shared state, where a mistake is not a bug in one screen but a hole in the platform. Most of the decisions in that file come from one property of the language.
Rules do not cascade
A conversation between two users is a document, and its messages are a subcollection under it. The conversation rule says a reader must be one of the two participants. The natural assumption is that the messages inherit that. They do not. A rule matches a path and nothing else, so the messages collection starts with no rule at all. Firestore also offers collection-group queries, which read every subcollection of a given name across the whole database in one call. Put those two facts together and an unguarded messages subcollection means one query returns every private message on the platform. The comment above the message rule says exactly that, and then the participant check is written out again: the reader's id must be in the parent conversation's participant list, fetched with a get on the parent document.
It is written a third time for a top-level messages collection that neither app uses any more. Rather than delete it and hope nothing still points there, it is locked: reads gated on the same lookup, every write denied. The sender check on the subcollection carries one more condition, that the message's owner field equals the caller, because without it any signed-in user could inject a message into any conversation attributed to anyone, and since message updates are denied the forgery would be permanent. There is a shared participant helper elsewhere in the file. It is not used here. It checks the buyer, creator and brand fields of a sale, not the two-person list on a conversation, and pretending those were the same check is the abstraction that eventually lets one through.
A grant is not an edit
A user owns their profile document and may update it. The profile also carries the flags that make someone an administrator, a founding member, an approved account or a paid tier. The first version of the defence was the obvious one: those keys may not change. It broke the mobile app, which writes the whole profile back on every edit and omits the server-managed keys it does not know about. To that rule, a founding member editing their own profile looked like a founding member dropping their own flag, and the write failed.
So the rule blocks a grant, not a change. A privileged flag may be absent or unchanged; it may not go from false to true. Three functions carry this: one on create, so a new profile cannot arrive privileged; one on every update, allowing removal and refusing a flip; one on the owner's own edits, which pins the account type so a buyer cannot promote themselves to a brand, and pins the field tying a creator to the brand that referred them, since that is set by a server function after checking the code and a client asserting it could attach itself to any brand. One flag is deliberately left out of the create check. Approval may arrive true on a new profile because the signup flows set it client-side, and the comment says so, so that the next reader does not fix it.
The guest tier
Guest checkout on the web signs the visitor in anonymously, so the simplest rule of all, that the caller is authenticated, is true for someone who has typed nothing but a card number. Every write surface in the file is therefore gated on a stricter helper: authenticated and not anonymous. Guests get their own profile, attributions, transactions and addresses, and the guest profile may only be created with a fixed list of keys, a buyer account type and an empty username, so a guest cannot squat a username the login lookup would later trust. Guests may not write an order at all. A fabricated order document would look like a paid one, so guest orders go through a server function that verifies the payment with the processor before anything is written.
Money is written by the server
The commission ledger denies every client write; entries are minted only by server functions, which bypass the rules. A sale can be created by its buyer and then never updated or deleted from a client. An order can be updated by the brand across the fulfilment fields only, never price, address, product or buyer, and the buyer's one permitted change is to flag a return. Tracking links cannot be created by a client, because the only legitimate path is the server function that authorises them, and anyone but the link's owner may touch nothing except the click counters, which stops a link's creator id being rewritten to hijack another creator's commission. The history of commission rates is append-only. Most of these lines are one word long, false, and each has a comment saying what it would cost to change it.
Tested like code, run by hand
The rules have a test script: 35 checks against the local emulator, each asserting that a specific write is allowed or refused. The interesting ones read like the threat model. A guest cannot write a transaction. A full user cannot plant a payment record for another user, with their own id on the document and the victim's id inside it, which would have surfaced in the victim's checkout query. An administrator cannot grant administrator from the client. The script seeds the documents the denial tests target, so a refusal is attributable to the rule and not to a missing document, and it takes a path to an older rules file so a change can be proved to fail before and pass after.
Said plainly, as the rest of this site tries to be: the script runs when someone types the emulator command from the comment at the top of the file. There is no continuous integration on the repository and no package script that invokes it. The file has 41 commits since January 2026, and the comment about collection-group queries landed in a commit titled closing access-control holes, in the same pass that hardened the money path described in the other two notes on this platform.
What it costs
Repetition, on purpose. The participant check exists three times because the one file where a clever abstraction is most tempting is the one where it is most dangerous. Every get inside a rule is a document read, paid for and waited on, so the message rules pay for their own safety on every call. And the shape of the mobile app's writes reaches all the way into the security model: the grant-not-change rule exists because a full-document write from a phone drops keys it does not know about. That is the argument of this site again. The person who wrote the phone's profile screen and the person who wrote the rule that refused it were the same person, and the fix took one commit instead of a meeting.