Query Authorization
A query is checked once, before it runs, against the read rules along path + the wildcard child level + childPath. Understanding that one sentence prevents two mistakes.
Read rules do not filter query results. There is a single permission check for the whole query. If it passes, every matching record is returned; if it fails, the query returns status: "Failed" and nothing else. Rules never run per record, so you cannot rely on them to strip private rows out of a result set — use childPath to scope what the query can reach, or keep private data in a separate subtree.
A read rule that compares against a wildcard will deny queries. The check walks a synthetic path with a $ placeholder standing in for "any child", so a wildcard variable binds to the literal string "$" rather than a real key. Given:
"users": { "$uid": { "read": "admin.uid == $uid" } }
a get(["users","matt"]) succeeds for Matt, but query({ path: ["users"], … }) is denied for everyone — the rule evaluates admin.uid == "$". To make a collection queryable, grant read with a rule that does not depend on the wildcard's value:
"users": {
"$uid": {
"public": { "read": "true" }, // queryable with childPath: ["public"]
"private": { "read": "admin.uid == $uid" } // get-only, never queryable
}
}
The practical consequence: scope by path, not by filter. Put each user's data somewhere they can be granted, and query inside it — rather than querying a shared collection and hoping rules narrow the result.
// WRONG — a shared collection with an ownership rule. The collection check
// runs once with $orderId unbound, so this returns nothing at all.
// rules: { "orders": { "$orderId": { "read": "admin.uid == data.ownerId" } } }
query({ path: ["orders"], query: "child.status == 'open'" });
// RIGHT — give each user their own subtree, grant that, and query inside it.
// rules: { "orders": { "$uid": { "read": "admin.uid == $uid" } } }
query({ path: ["orders", myUid], query: "child.status == 'open'" });
// Also fine — a genuinely public collection.
// rules: { "posts": { "$postId": { "read": "true" } } }
query({ path: ["posts"], query: "child.published == true" });
Server-side query() runs as root and bypasses all of this, so a callable or a POST endpoint is the right place to implement a filtered read that rules cannot express.