← All posts
Deep dive #firestore#security-rules#backend#debugging

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.