Launch and other stacks

Firebase security rules basics: lock down Firestore and Storage

Firebase security rules are the only thing deciding who can read and write your Firestore, Realtime Database and Cloud Storage data, because your app’s Firebase config is public by design. Replace any if true or test-mode request.time < … rule with rules that compare request.auth.uid with the document’s owner, and test them in the emulator as a visitor, a stranger and the owner before deploying.

TL;DR

  • Your apiKey and projectId are public. Rules are your security.
  • Test mode opens everything for 30 days. if request.auth != null opens everything to anyone who signs up.
  • Match on ownership: request.auth.uid == userId, or resource.data.ownerId == request.auth.uid.
  • Test with @firebase/rules-unit-testing and the emulator; keep the rules in your repo and deploy from there.

Why rules are your whole security model

A Firebase web app talks to Firestore, Realtime Database and Cloud Storage directly from the browser using a config object with an apiKey. That key identifies your project; it doesn’t grant access, and it isn’t secret. Anyone can copy it from your site and use the Firebase SDK against your project. What they can do is decided entirely by your security rules, evaluated by Google’s servers on every request.

Firebase Admin SDKs on servers bypass rules, the same way Supabase’s service role key bypasses RLS, so admin credentials must never reach the browser either.

The rules that leak

firestore.rules: three ways to be open
// 1. Open to everyone, forever
allow read, write: if true;

// 2. "Test mode": open to everyone until the date
allow read, write: if request.time < timestamp.date(2026, 12, 31);

// 3. Open to anyone who signs up
allow read, write: if request.auth != null;

Test mode is what the Firebase console offers when you create a database, so you can build without rules for 30 days. Apps ship with it, or the date gets pushed forward when it expires and the app stops working. The third form is Firebase’s version of PL-002: if anyone can create an account, “signed in” means anyone.

Realtime Database uses JSON rules; the equivalent of rule 1 is {"rules": {".read": true, ".write": true}}.

How rules evaluate

  • match blocks select document paths. {userId} captures a path segment; {document=**} matches everything below.
  • allow grants an operation if its condition is true: read (or get and list) and write (or create, update, delete).
  • If any matching allow is true, the request is allowed. A broad {document=**} rule overrides careful rules elsewhere.
  • request.auth is the signed-in user (or null). resource.data is the document as stored; request.resource.data is the document as it would be after a write.
  • Rules are not filters: a query must ask only for documents the rules allow, or the whole query is refused. Query with where('ownerId', '==', uid) to match an ownership rule.

Owner-only rules to start from

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    function signedIn() { return request.auth != null; }
    function isOwner(uid) { return signedIn() && request.auth.uid == uid; }

    // A user's own profile, keyed by their uid
    match /users/{userId} {
      allow read, update, delete: if isOwner(userId);
      allow create: if isOwner(userId);
    }

    // Documents with an ownerId field
    match /notes/{noteId} {
      allow read, delete: if isOwner(resource.data.ownerId);
      allow create: if isOwner(request.resource.data.ownerId);
      // Owners can edit, but can't hand the note to someone else
      allow update: if isOwner(resource.data.ownerId)
        && request.resource.data.ownerId == resource.data.ownerId;
    }

    // Public to read, written only by the author
    match /posts/{postId} {
      allow read: if true;
      allow create: if isOwner(request.resource.data.authorId);
      allow update, delete: if isOwner(resource.data.authorId);
    }
  }
}

Anything not matched is denied, so there is no need for a catch-all. Never store a role on a document the user can update; for admins, use custom claims set from a server with the Admin SDK and check request.auth.token.admin == true.

storage.rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Each user reads and writes only under users/<their uid>/
    match /users/{userId}/{allPaths=**} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
  }
}

Test the rules before you deploy

The emulator runs your rules locally, and @firebase/rules-unit-testing lets you act as any user. This test seeds one private document, then checks a visitor and a stranger are refused while the owner can still read:

rules.test.mjs
// npm i -D firebase @firebase/rules-unit-testing
// run: firebase emulators:exec --only firestore "node rules.test.mjs"
import { readFileSync } from 'node:fs';
import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
import { doc, getDoc, setDoc } from 'firebase/firestore';

const env = await initializeTestEnvironment({
  projectId: 'demo-rules-check',
  firestore: { rules: readFileSync('firestore.rules', 'utf8') },
});
await env.withSecurityRulesDisabled(async (ctx) => {
  await setDoc(doc(ctx.firestore(), 'users/owner'), { email: 'owner@example.com' });
});

const visitor = env.unauthenticatedContext().firestore();
const stranger = env.authenticatedContext('stranger').firestore();
const owner = env.authenticatedContext('owner').firestore();

await assertFails(getDoc(doc(visitor, 'users/owner')));
await assertFails(getDoc(doc(stranger, 'users/owner')));
await assertSucceeds(getDoc(doc(owner, 'users/owner')));
await assertFails(setDoc(doc(stranger, 'users/owner'), { email: 'x' }));
console.log('rules pass: visitor and stranger denied, owner reads');
await env.cleanup();

A demo- project id keeps the emulator fully offline. Run the test in CI so a later change can’t quietly reopen the database.

Keep rules in the repo, deploy from the repo

Rules edited in the Firebase console and never copied back drift away from what your repo says, and from what anyone reviewing the repo will see. Keep firestore.rules and storage.rules in the repo, referenced from firebase.json, and deploy with:

terminal
firebase deploy --only firestore:rules,storage

The console’s Rules Playground is handy for one-off checks, but it doesn’t replace a test that runs on every change.

App Check is not a rule

Firebase App Check makes it harder for scripts outside your app to call your backend. It is a useful extra layer, but a determined attacker can still act through your real app, so it doesn’t make open rules safe. Fix the rules first.

Sources

The checks behind this guide, each with example code, a self-check and the fix.

Free check

Check your app for this, free.

Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.

Free for your first appNever touches your database

Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.