← Liana Grigory
Insights

Proving security rules without a database

Access control is the least-tested code in most Firebase projects, because testing it looks expensive. It is not: rules can be evaluated in isolation, in about a second, for nothing.

By Liana Grigory · 10 September 2026 · 9 min read

Security rules are the only thing standing between a Firebase project and a public database. They are also, in most projects I have looked at, the least tested code in the repository. The reason is not negligence. It is that the obvious way to test them — sign in as somebody, try to read something, see what happens — is slow, requires accounts to exist, writes to a real database, and produces a result you have to interpret.

So it gets done once, by hand, at the moment the rules are written, and then never again. Which means every subsequent rule change is deployed on the strength of somebody reading it and thinking it looks right.

There is a much better option, and it is the one I wish I had reached for years earlier: rules can be evaluated against a hypothetical request without any data, any accounts, or any database at all.

Rules are a pure function

The insight that makes this tractable is that a security rule is not a piece of application logic that happens to run near the data. It is a pure function from (request, resource) to a boolean.

The request carries the authentication state — the uid, the token, the custom claims — along with the method and, for writes, the incoming data. The resource carries the current state of the document being accessed. Given those two inputs, the rule returns allow or deny, deterministically, with no side effects.

Nothing about that requires the document to exist. It requires you to be able to describe a document, which is a different and much cheaper thing. And that is exactly what the rules evaluation API accepts: a ruleset as text, plus a list of test cases, each describing a request and the resource state it acts on, and it returns which cases were allowed and which were denied, along with the line of the rule that decided it.

No project data is read. No documents are written. No accounts are created. It costs nothing and it runs in about a second.

What this changes

Three things, and the third one is the one that mattered most to me.

First, it makes negative tests cheap. The important assertions about a security rule are all of the form "this must be refused", and those are precisely the ones that are awkward to perform by hand, because setting up a genuinely unauthorised request means having a second account with the wrong claims. As a description in a test case it is three lines.

Second, it makes the rules a specification. A test file that enumerates who may read what, in ordinary language, next to the expected verdict, is a far better description of the access model than the rules file itself — which is written in a constrained expression language and optimised for evaluation, not for reading.

Third, it makes rule changes safe to make. This is the real payoff. The reason security rules calcify in a mature project is that nobody wants to touch them: the file is load-bearing, the consequences of getting it wrong are severe and silent, and there is no way to know whether a change broke something except to deploy it. With a test suite, a rules change is an ordinary change. You edit it, you run the suite, and if the suite is any good it tells you which access you just broke.

Calcified rules are dangerous in a specific way: when a legitimate feature needs access the rules do not grant, and nobody dares restructure them, the path of least resistance is to bolt on a permissive clause. That is how deny-by-default becomes deny-by-default-with-six-exceptions, and it happens one reasonable-looking commit at a time.

What to actually assert

A test suite that only checks the happy path is close to worthless, because the happy path is what you already verified by using the product. The cases worth writing are the ones nobody exercises by accident.

  • Unauthenticated access to everything. Every path, no token, expecting denial. This is the assertion that catches the single worst failure, and it is trivially cheap to write once you have the collection list.
  • Cross-tenant and cross-user reads. Authenticated as user A, reading a document owned by user B. In a multi-tenant product, authenticated as a member of tenant A reading a document belonging to tenant B. This is the one that matters most and the one hand-testing almost never covers.
  • Privilege escalation through the write path. A user writing their own document, but setting role to admin, or altering ownerUid, or changing a price, or back-dating a timestamp. Server-owned fields have to be immutable from the client, and the only way to know they are is to try to move them.
  • Claims that are absent or wrong. A token with no custom claims at all, one with the claim set to an unexpected value, one with the right claim for the wrong tenant.
  • Field-level shape. If a rule validates types, lengths or enumerations, test the values just outside the boundary. A rule that accepts a string where it meant to accept a short string is a denial-of-wallet waiting to happen.
  • Deletes separately from writes. They are distinct operations and people frequently authorise them together without meaning to.

Where the emulator is still the right tool

The evaluation API answers "does this rule permit this request". It does not answer "does the application behave correctly", and there are questions it structurally cannot reach.

Rules that call get() or exists() against another document need that document to exist, so those depend on state. Anything involving a sequence of operations — a write that is only valid because of what an earlier write did — is not a single-request question. And rules that depend on how the client library actually shapes a query are best verified against something that behaves like the real service.

The local emulator suite covers all of that, still costs nothing, and still touches no production data. The two are complements: the evaluation API for the large matrix of who-may-do-what, which is where the volume is, and the emulator for the smaller set of stateful and sequence-dependent behaviours.

What neither of them is, and what nothing should be, is a live query against production to see what happens. That is metered, it is slow, and it tests the deployed rules rather than the ones you are about to deploy — which is the wrong question in the only moment that matters.

Making it a gate rather than a ritual

A test suite that has to be remembered is a test suite that will be forgotten. The version that holds is the one wired into the same command that deploys, so that the rules cannot reach production without the suite passing. It runs in about a second, it needs no credentials beyond the ones the deploy already has, and it requires no infrastructure.

The habit worth building alongside it is that every rule change arrives with the test that would have caught its absence. Not a test that the new access works — you will notice that immediately, because it is the feature. A test that the access you did not intend to grant is still refused. That is the assertion nobody makes and the one that would have caught every rules bug I have seen, including my own.

The wider point

The reason this is worth writing down is not that rules testing is difficult. It is that the argument for skipping it — that verifying access control requires accounts, data and a running system — sounds obviously true and is false.

Authorisation in this architecture is a declarative artefact, and declarative artefacts can be evaluated in isolation. Once that is clear, the cost of knowing your access model is correct drops to roughly zero, and the only remaining reason not to know is that nobody has written the cases down.

Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.

Home · Terms of Use · Privacy Notice