Deny by default: when security rules are the only authorization layer
The architecture I keep arriving at has no server in it. The browser talks to the database directly, holding the signed-in user's own token, and there is no API tier in between. People assume that is a compromise made for cost. It is, partly — but it also removes an entire class of vulnerability, because the code path that could be tricked into acting with elevated privilege does not exist.
What it costs you is the comfortable place to put an if statement. There is no
middle tier to check whether this user may do this thing. The rules file is the whole of it,
and that concentration is the point.
The rule file is the security review
When authorization lives in application code it is spread across every handler, and reviewing it means reading all of them and trusting that no future handler forgets. When it lives in rules, it is one file, a few hundred lines, and it can be read end to end in an afternoon by someone who did not write it. That is a genuine advantage and it is rarely stated.
It also means the file has to be written as though it were the last line of defence, because it is:
match /{document=**} { allow read, write: if false; }
Everything after that is a named exception. If you cannot explain in one sentence why an exception exists, it should not.
What the client is allowed to be trusted about
Nothing, except its identity — and only because that arrives as a signed token the client cannot forge. Every other assertion in a request is an assertion by an attacker until a rule proves otherwise. In practice that means three habits.
Never read a role from a document the holder of that role can write. Roles
belong in custom claims, set by trusted code, and nowhere else. A role field on a
user document that the user may update is an administrator button with a longer name.
Validate shape, not just permission. A rule that says who may write, but not what, allows a signed-in user to store a five-megabyte string in a field you expected to hold a ZIP code. Constrain types, sizes and enumerations at the boundary.
Enumerate the writable keys, never the forbidden ones.
affectedKeys().hasOnly([...]) fails safe when a new field is added later;
hasAny([...]) as a blocklist fails open, and it fails open silently, on the day
someone adds a field and forgets this file exists.
The costs, stated plainly
Rules are not a general-purpose language, and you will meet the wall. Cross-service calls are
the sharpest edge: a Storage rule that calls firestore.get() denies everything
until the correct IAM grant exists, and the error does not tell you that. Aggregations across
documents are not expressible. Anything genuinely requiring a secret — signing a payment,
sending mail — still needs trusted code, and that is fine; the goal was never zero server
code, it was zero server code in the authorization path.
Prove the denials
Rules that have only ever been tested by using the app are untested, because the app only ever sends the requests it was written to send. Assert the negative cases directly against the rules-test API, which needs no data, no accounts and no production traffic. One assertion per boundary. A boundary that has never returned DENY under test is a boundary you are hoping about.
Assume the attacker has your source code, your token, and all the time they want. Then read the rules file again and ask what it still refuses.
Written by Liana Grigory, also written Liana Grigoryan — entrepreneur, technology founder and U.S. Army veteran in Los Angeles. More at Writing.