Firestore Security Rules can't reason across documents — two ways that bites you
Two real, silent Firestore Rules failures: interpolating a reference field into a rule path instead of calling get() on it directly, and an aggregate query that fails outright (not partially) unless the rule can prove every possible matched document is readable.
Firestore Security Rules evaluate one document at a time. That’s easy to nod along to in the abstract, but it bit me twice in the same afternoon, in two different shapes, and both failures were silent — no crash, no obvious error, just “permission denied” or an empty result where a real one belonged.
1. A reference field is not a path string
A request document stores a reference to the resource it’s about:
requests/{requestId}
requester_ref: /users/abc
resource_ref: /resources/xyz <- a real DocumentReference, not a string
The rule needs to let the resource’s owner read requests made against it. The instinctive way to write that, if you’re used to interpolating field values into rule paths, is something like:
// WRONG — treats resource_ref as if it were a bare id to interpolate
allow read: if get(/databases/$(database)/documents/resources/$(resource.data.resource_ref)).data.owner_ref == request.auth.uid;
This doesn’t throw. It just never matches — resource_ref is already a full
DocumentReference, not a path segment, so splicing it into another path string
produces something that resolves to nothing. The fix is to stop treating it as a
string at all: get() and exists() accept a DocumentReference value directly.
// RIGHT — pass the reference straight into get()
allow read: if get(resource.data.resource_ref).data.owner_ref == request.auth.uid;
The practical cost of getting this wrong: the feature looked done. Writes succeeded, the UI rendered, and only the one access path that depended on the broken rule — the resource owner trying to see requests against their own resource — quietly returned nothing, with no error surfaced anywhere in the client.
2. An aggregate query needs every possible match to be readable
Firestore’s count()/sum()/average() aggregate queries run a permission
check against the query, not against the documents it happens to return. If the
rule can’t prove that every document the query could match is readable by
the caller, the whole call fails — not degrades, not returns a partial sum, just
throws.
Concretely: a shares/{id} collection had a self-or-admin read rule (each backer
can read their own share records, nothing more). A screen needed “total units
sold for this campaign” — a sum() aggregate over shares filtered by
campaign_ref. That call fails outright for a non-admin caller, because the rule
can’t guarantee every other backer’s share record in that campaign is readable
by this caller — even though the caller has a perfectly legitimate reason to
want the total, not the individual rows.
Two ways out: widen the read rule so anyone signed in can read raw share rows
(cheap, but now every backer’s identity and amount is visible to every other
backer), or stop running the aggregate at all — maintain a denormalized counter
(campaigns/{id}.units_sold) via a server-side trigger that increments on every
share write, and have the client just read that field. The second is strictly
better once you have any server-side compute available; the first is the
honest stopgap when you don’t.
The shared lesson
Both failures come from the same root cause: a Rule can only reason about the
document it’s attached to, plus whatever you explicitly get(). It can’t follow
a reference automatically, and it can’t inspect “everything a query might touch”
— it can only prove safety for what it can see up front. Anything that needs a
cross-document answer either needs an explicit get() on the exact reference
value, or needs the answer denormalized onto a single document ahead of time.