Security rules

PreviousNext

How Firestore and Storage rules actually work — why a query is checked instead of its results, the delete trap in Storage, and how to test both against the emulator.

Security rules are the boundary. Your Firebase config ships inside the app bundle, so anyone with the app can call the API directly; the route guards in app/_layout.tsx are there so users are not looking at empty screens, not to keep anyone out.

Both starters ship rules and tests that execute them. That combination is unusual and it is the point: rules are a separate language that fails open in ways your app code cannot reveal.

The one thing to internalise

A read rule is evaluated against the query, not against the documents it would return.

Firestore refuses any query it cannot prove in advance is limited to documents the rule allows. It does not run the query and filter the results.

// rule: allow read: if isOwner(resource.data.ownerId);
 
// permission-denied — Firestore cannot prove this is scoped
query(collection(db, 'tasks'), orderBy('createdAt', 'desc'));
 
// fine — the filter matches the rule
query(collection(db, 'tasks'), where('ownerId', '==', uid));

This is the exact inverse of Postgres row level security, where the WHERE clause is optional and the policy silently narrows the result set. Code ported from the Supabase starter will fail loudly here, which is better than the alternative but still surprising.

Firestore rules

The auth starter

match /users/{userId} {
  allow get:    if isOwner(userId);
  allow create: if isOwner(userId)
                && request.resource.data.onboarded == false
                && request.resource.data.keys().hasOnly([...]);
  allow update: if isOwner(userId)
                && request.resource.data.diff(resource.data).affectedKeys()
                     .hasOnly(['displayName', 'photoURL', 'onboarded', 'updatedAt']);
}

Three things doing real work:

  • The document id is the uid, so isOwner(userId) is checkable without reading the document first. A userId field inside the document would cost a read on every rule evaluation.
  • No list. Nothing in the app queries this collection, and allowing it would let any signed-in user enumerate every account.
  • diff().affectedKeys().hasOnly(...) is how you say "these fields and no others" — the equivalent of leaving a column out of an UPDATE policy. email is excluded deliberately: it mirrors the Firebase Auth record, and letting the client edit it here would put the two out of step permanently.

For tasks, both sides of an update are checked:

allow update: if isOwner(resource.data.ownerId)
              && request.resource.data.ownerId == resource.data.ownerId

resource stops you editing someone else's task; request.resource stops you handing yours to someone else. Dropping either half is a real hole.

The no-auth starter

Every rule is open, and the file says so in a comment. One thing is still pinned down:

allow create: if isValidTask(request.resource.data)
              && request.resource.data.createdAt == request.time;

A client cannot forge a creation time — no backdating a document to win an ordering, even in a demo.

No catch-all match

Neither starter has match /{document=**}. Anything not explicitly named is denied — the Firestore equivalent of leaving RLS on with no policy, and the single most common way a Firebase project leaks.

Adding one back "to fix a permission error" opens every collection you ever create. The rules tests assert its absence for that reason.

Storage rules

The delete trap

In Storage rules, write means create + update + delete, and on a delete request.resource is null. So this:

allow write: if isOwner(userId) && request.resource.size < 2 * 1024 * 1024;

silently denies every delete — null has no .size. Both starters split the methods:

allow create, update: if isOwner(userId)
  && request.resource.size < 2 * 1024 * 1024
  && request.resource.contentType.matches('image/(jpeg|png|webp)');

allow delete: if isOwner(userId);

hooks/useDeleteAccount.ts depends on that split working, and there is a test asserting it.

The uid in the path

match /avatars/{userId}/{fileName} { ... }

That path segment is the whole access check, exactly as (storage.foldername(name))[1] = auth.uid()::text is in a Supabase policy. It is why useAvatarUpload builds the path from user.uid rather than anything the picker returned.

Download URLs are capabilities

getDownloadURL() returns a URL carrying an access token. Tightening the rule later does not invalidate a URL already in the wild — you have to rotate the object's download token in the console. Treat those URLs as shared links, not as permission checks.

Testing the rules

pnpm rules:test

This starts the emulators, runs rules-tests/ under a separate Node jest project, and shuts them down again. It needs a JDK 21 or newer — the emulators are Java processes, and firebase-tools 15 refuses to start on anything older.

The tests use @firebase/rules-unit-testing:

rules-tests/firestore.test.ts
it('rejects an UNSCOPED query even for a signed-in user', async () => {
  await assertFails(
    getDocs(query(collection(alice(), 'tasks'), orderBy('createdAt', 'desc')))
  );
});
 
it('accepts the same query once it is scoped to the owner', async () => {
  await assertSucceeds(
    getDocs(query(collection(alice(), 'tasks'), where('ownerId', '==', ALICE)))
  );
});

testEnv.withSecurityRulesDisabled() seeds fixtures that the rules would otherwise reject — the admin escape hatch, used only for setup.

Deploying

pnpm deploy:rules     # rules only
npm run deploy:indexes   # indexes only
npm run deploy           # both

Until you do this, Firestore uses whatever the console last had, which for a new project is usually locked down. Every screen showing an error toast on a fresh scaffold almost always means the rules were never deployed.

Learn more