Skip to content

Security Rules

Firenook evaluates your Firestore and Storage Security Rules in its own engine, with no Java. Read this page to see how rules are loaded and reloaded, how to test them with @firebase/rules-unit-testing, and how to find out why a request was denied.

Firenook reads the rules files firebase.json names when it starts, as the official CLI does:

firebase.json
{
"firestore": { "rules": "firestore.rules" },
"storage": { "rules": "storage.rules" }
}
  • Firestore: the firestore object’s rules, or one file per database when firestore is an array (named databases).
  • Storage: one file for every bucket, or one file per target for every bucket the target maps to (buckets and targets).

One engine compiles and evaluates both. Storage takes version 1 and rules_version = '2' files, and its rules can call firestore.get() and firestore.exists() against the local Firestore.

A request with no Authorization header is a signed-out client, and the Admin SDKs’ owner token skips the rules, as on the official emulator. Who a request is from has the details.

Each rules file is watched. One second after the last change, Firenook compiles it and installs it:

firenook firestore: database "(default)" rules reloaded from ~/demo-shop/firestore.rules
firenook storage: rules for every bucket reloaded from ~/demo-shop/storage.rules

A file that does not compile leaves the rules in force serving requests, and says where it went wrong:

firenook storage: rules for every bucket in ~/demo-shop/storage.rules did not compile; the rules in force are unchanged: 5:53: expected a rules expression

Tests can also replace the rules over HTTP. On the Firestore port, PUT /emulator/v1/projects/<project>:securityRules replaces the rules of every database of the project, as on the official emulator. It reads the body as JSON whatever its content type, as the official emulator does, so it takes the rules @firebase/rules-unit-testing sends.

On the Storage port, PUT /internal/setRules does the same for Storage. A body that does not compile there leaves no rules until the next valid one, as on the official emulator.

@firebase/rules-unit-testing runs against Firenook as it does against the official emulator: initializeTestEnvironment loads your rules, authenticatedContext() and unauthenticatedContext() are checked as a signed-in and a signed-out client, and withSecurityRulesDisabled() skips the rules.

  1. Install the library and the web SDK next to Firenook:

    Terminal window
    npm install --save-dev @firebase/rules-unit-testing firebase
  2. Write the rules. These let a customer create and read only their own orders, and upload only to their own folder:

    firestore.rules
    rules_version = '2';
    service cloud.firestore {
    match /databases/{database}/documents {
    match /orders/{orderId} {
    allow read: if request.auth != null && resource.data.owner == request.auth.uid;
    allow create: if request.auth != null && request.resource.data.owner == request.auth.uid;
    }
    }
    }
    storage.rules
    rules_version = '2';
    service firebase.storage {
    match /b/{bucket}/o {
    match /users/{uid}/{file} {
    allow read, write: if request.auth != null && request.auth.uid == uid;
    }
    }
    }
  3. Write the tests:

    test/rules.test.mjs
    import { readFileSync } from 'node:fs';
    import { after, before, beforeEach, test } from 'node:test';
    import {
    assertFails, assertSucceeds, initializeTestEnvironment,
    } from '@firebase/rules-unit-testing';
    import { doc, getDoc, setDoc } from 'firebase/firestore';
    import { ref, uploadString } from 'firebase/storage';
    let env;
    before(async () => {
    // emulators:exec tells the library where the emulators are.
    env = await initializeTestEnvironment({
    projectId: 'demo-shop',
    firestore: { rules: readFileSync('firestore.rules', 'utf8') },
    storage: { rules: readFileSync('storage.rules', 'utf8') },
    });
    });
    beforeEach(() => env.clearFirestore());
    after(() => env.cleanup());
    test('a customer creates and reads their own order', async () => {
    const ada = env.authenticatedContext('ada').firestore();
    await assertSucceeds(setDoc(doc(ada, 'orders/o1'), { owner: 'ada', total: 42 }));
    await assertSucceeds(getDoc(doc(ada, 'orders/o1')));
    });
    test('a customer cannot create an order for someone else', async () => {
    const ada = env.authenticatedContext('ada').firestore();
    await assertFails(setDoc(doc(ada, 'orders/o2'), { owner: 'grace' }));
    });
    test("a customer cannot read another customer's order", async () => {
    await env.withSecurityRulesDisabled(async (admin) => {
    await setDoc(doc(admin.firestore(), 'orders/o3'), { owner: 'grace' });
    });
    const ada = env.authenticatedContext('ada').firestore();
    await assertFails(getDoc(doc(ada, 'orders/o3')));
    });
    test('a signed-out visitor cannot read an order', async () => {
    await env.withSecurityRulesDisabled(async (admin) => {
    await setDoc(doc(admin.firestore(), 'orders/o4'), { owner: 'ada' });
    });
    const visitor = env.unauthenticatedContext().firestore();
    await assertFails(getDoc(doc(visitor, 'orders/o4')));
    });
    test('uploads go only to your own folder', async () => {
    const ada = env.authenticatedContext('ada').storage();
    await assertSucceeds(uploadString(ref(ada, 'users/ada/avatar.txt'), 'hi'));
    await assertFails(uploadString(ref(ada, 'users/grace/avatar.txt'), 'hi'));
    const visitor = env.unauthenticatedContext().storage();
    await assertFails(uploadString(ref(visitor, 'users/ada/avatar.txt'), 'hi'));
    });
  4. Run them under emulators:exec, which starts the emulators, tells the library where they are (FIRESTORE_EMULATOR_HOST, FIREBASE_STORAGE_EMULATOR_HOST and the hub’s address), and stops when the tests end. Trimmed to the tests:

    Terminal window
    $ npx firenook emulators:exec --only firestore,storage "node --test test/rules.test.mjs"
    All emulators ready
    ✔ a customer creates and reads their own order (80.504375ms)
    [2026-10-22T11:41:38.513Z] @firebase/firestore: Firestore (13.1.0): GrpcConnection RPC 'Write' stream 0x127c9e7c error. Code: 7 Message: 7 PERMISSION_DENIED: Missing or insufficient permissions.
    false for 'create' @ L6
    ✔ a customer cannot create an order for someone else (20.507625ms)
    ✔ a customer cannot read another customer's order (26.373375ms)
    ✔ a signed-out visitor cannot read an order (24.686708ms)
    ✔ uploads go only to your own folder (18.29025ms)
    ℹ tests 5
    ℹ pass 5
    ℹ fail 0

    The SDK logs the refused write, with the rule line that refused it; the test expected the refusal and passes.

Two things the library does that surprise people, on the official emulator as on Firenook: clearStorage() deletes only the files at the bucket’s root, not those in folders, and a test that expects a delete to be allowed fails with storage/object-not-found when the file was never uploaded. Upload it first, with the rules disabled.

Rules tests exported from the console run the same way. Run your tests covers test runners and CI.

While the suite runs, Firenook records which rule expressions your requests evaluated, and how they came out. Open the report on the Firestore port:

http://127.0.0.1:8080/emulator/v1/projects/demo-shop:ruleCoverage.html

Leave out .html for the same report as JSON. Recording is on by default. The report can contain document data and decoded auth claims, so keep it local; --no-diagnostics turns recording off, and the coverage route then answers that it is unavailable.

A Firestore request the rules refuse fails with production’s message and, on the next line, what refused it, as on the official emulator:

The rules The message’s second line
An allow statement that came out false false for 'get' @ L5, the method and the line
Several, each false false for 'get' @ L13, false for 'get' @ L14
No allow statement for the path No matching allow statements
A condition that failed while it ran The error and the line, such as field "uid" is not available on this value for 'get' @ L10

The official emulator words a failed condition its own way (Null value error., for one), so a test should not match that text. A signed-out read with the web SDK throws an error whose code is permission-denied and whose message is:

Missing or insufficient permissions.
false for 'get' @ L5

Over REST, the same read answers:

{"error":{"code":403,"message":"Missing or insufficient permissions.\nfalse for 'get' @ L5","status":"PERMISSION_DENIED"}}

Storage denials name no line, as on the official emulator. Over HTTP, a refused upload answers 403 with {"error":{"code":403,"message":"Permission denied. No WRITE permission."}}, and the SDK throws an error whose code is storage/unauthorized and whose message is:

Firebase Storage: User does not have permission to access 'users/grace/avatar.txt'. (storage/unauthorized)

The console’s log has a line for every Firestore denial, with the rules file and line:

Rules denied get on orders/o1 for an unauthenticated client at firestore.rules:5
Rules denied create on orders/o9 for ada at firestore.rules:6

A signed-in user’s uid takes the place of “an unauthenticated client”. The plain output (--plain, pipes and CI) does not print these lines; open the console’s log to see them.

Each service’s Rules page, Firestore › Rules and Storage › Rules, lists every decision as it happens: the request, who made it, and the verdict. Open one and the lines that decided it are lit in the editor, with the value of each expression. On a denied read in the Firestore data view, “See why” explains it in place. The page’s playground tries a request as any user against the rules in the editor, saved or not, and saved tests run again on every edit. The console has the rest.

firenook mcp gives an agent three rules tools: rules_get for the rules in force, rules_list_denials for recent denials with the lines that denied them, and rules_evaluate to ask whether a request as a given user would be allowed, with the deciding line and each expression’s value. Nothing is written. Connect your coding agent sets it up.