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.
Where the rules come from
Section titled “Where the rules come from”Firenook reads the rules files firebase.json names when it starts, as the
official CLI does:
{ "firestore": { "rules": "firestore.rules" }, "storage": { "rules": "storage.rules" }}- Firestore: the
firestoreobject’srules, or one file per database whenfirestoreis 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.
Reload on save
Section titled “Reload on save”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.rulesfirenook storage: rules for every bucket reloaded from ~/demo-shop/storage.rulesA 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 expressionTests 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.
Test your rules
Section titled “Test your rules”@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.
-
Install the library and the web SDK next to Firenook:
Terminal window npm install --save-dev @firebase/rules-unit-testing firebase -
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;}}} -
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'));}); -
Run them under
emulators:exec, which starts the emulators, tells the library where they are (FIRESTORE_EMULATOR_HOST,FIREBASE_STORAGE_EMULATOR_HOSTand 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 0The 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.
Coverage
Section titled “Coverage”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.htmlLeave 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.
Find out why a request was denied
Section titled “Find out why a request was denied”The message
Section titled “The message”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' @ L5Over 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
Section titled “The console”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:5Rules denied create on orders/o9 for ada at firestore.rules:6A 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.
Your coding agent
Section titled “Your coding agent”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.
- Firestore: named databases and per-database rules.
- Cloud Storage: rules per bucket.
- MCP tools: every tool the agent gets.