Platform Guides

Firebase Security Rules: Working Examples for Firestore, Realtime Database and Storage

September 12, 202611 min read
Focus
Firebase
Risk
High
Stack
Supabase/Next.js
Detection
Ubserve Runtime Simulation
Firebase Security Rules examples for Firestore, Realtime Database and Cloud Storage.

Firebase Security Rules are the only thing standing between your database and the public. Copy-paste rules for ownership, roles, public reads and file uploads.

Firebase gives clients direct access to your data. Google's own documentation says the rules are the only safeguard. Here are the rules that actually work, the ways people write them wrong, and how to catch a bad rules deploy after it ships.

Secure your vibe-coded app with Ubserve

  • Takes less than 60 seconds
  • 100+ security checks run through your app
  • Plain English explanations for each issue
  • AI fix prompts for every issue
Scan my app free

Firebase Security Rules are the authorization layer for Firestore, Realtime Database and Cloud Storage. Because Firebase clients read and write your data directly from the browser, the rules are not one defence among several. Firebase's documentation puts it plainly: Firebase allows clients direct access to your data, and Security Rules are the only safeguard blocking malicious users.

One note on what this page is and is not. Our own scan dataset of 1,141 apps is heavily Supabase-weighted, so unlike our Supabase and Vercel guides, this one carries no Firebase failure rate from our scans. We are not going to invent one. What follows is drawn from Firebase's documentation and from rules that work in production.

The model in one line

Every request from a client carries an identity, or no identity at all. A rule decides whether that specific request may read or write that specific path. There is no middle layer where you can add a check later. If the rule allows it, it happens.

That leads to the single most useful habit in Firebase: write rules per collection, start from the most restrictive position, and test them as a signed-out visitor before you ship.

Defaults: locked mode and test mode

When you create a database, Firebase offers two starting points.

Locked mode denies everything. Firebase's docs state the default rules for locked mode deny access to all users on Firestore and Realtime Database, and that on Cloud Storage only authenticated users can access buckets. In Firestore it looks like this:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Test mode is the opposite. It allows open reads and writes until an expiry date written into the rule itself:

match /{document=**} {
  allow read, write: if request.time < timestamp.date(2026, 10, 12);
}

Test mode is where most Firebase incidents begin, in one of two ways. Either the date passes and the live app breaks without warning, or the app ships before the date and the database is world readable and writable in the meantime. Firebase's docs are explicit about the deadline: update your Security Rules before you deploy your app to production, because once deployed it is publicly accessible even if you have not launched it.

If you are not sure which mode a project is in, look for if true or a request.time comparison in your rules file. Either one means anyone can read your data today.

Firestore: the rules you actually need

Documents owned by one user

The common case. A user may read and write only their own document:

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

    match /users/{userId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == userId;
    }
  }
}

A collection where ownership lives in a field

When the document id is not the user id, compare against the field. Creates and updates need different clauses, because on a create the document does not exist yet:

match /posts/{postId} {
  allow read: if true;

  allow create: if request.auth != null
                && request.resource.data.authorId == request.auth.uid;

  allow update, delete: if request.auth != null
                        && resource.data.authorId == request.auth.uid;
}

resource.data is the document as it exists now. request.resource.data is the document as the client is trying to write it. Mixing these up is the second most common rules bug.

Subcollections need their own rules

This is the Firestore behaviour people most often assume wrongly. A rule on /users/{userId} does not cover /users/{userId}/private/{docId}. Firestore rules apply to the path they match and do not flow down into subcollections, so a subcollection with no matching rule is denied, and a subcollection matched by a loose wildcard somewhere else can be wide open.

Match the subcollection explicitly:

match /users/{userId} {
  allow read, write: if request.auth.uid == userId;

  match /private/{docId} {
    allow read, write: if request.auth.uid == userId;
  }
}

Stopping a user rewriting fields they should not control

An owner check alone still lets a user change any field in their own document, including one like plan or credits. Pin the fields that must not move:

match /accounts/{accountId} {
  allow update: if request.auth.uid == accountId
                && request.resource.data.plan == resource.data.plan
                && request.resource.data.credits == resource.data.credits;
}

Roles, done properly

Do not read a role out of a document the user can write. Use custom claims, which are set from a trusted server with the Admin SDK and arrive on the user's token:

match /invoices/{invoiceId} {
  allow read: if request.auth.token.admin == true
              || resource.data.customerId == request.auth.uid;
  allow write: if request.auth.token.admin == true;
}

Realtime Database: different syntax, the opposite trap

Realtime Database rules are JSON, and the ownership pattern looks like this:

{
  "rules": {
    "users": {
      "$uid": {
        ".read": "$uid === auth.uid",
        ".write": "$uid === auth.uid"
      }
    }
  }
}

The trap here is the reverse of Firestore's. Realtime Database rules cascade downward and cannot be revoked. If you grant .read: true at a parent node, every child under it is readable, and a stricter rule deeper in the tree will not take that access back. Rules only ever widen access as you go down, never narrow it.

So a single permissive rule near the root of a Realtime Database project exposes everything beneath it. If you have ".read": true anywhere above your user data, nothing below it is protected.

Cloud Storage: check the file, not just the user

Storage rules use the Firestore-style syntax. Scope uploads to the user's own folder and validate what is being uploaded:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {

    match /users/{userId}/{allPaths=**} {
      allow read: if request.auth != null && request.auth.uid == userId;

      allow write: if request.auth != null
                   && request.auth.uid == userId
                   && request.resource.size < 5 * 1024 * 1024
                   && request.resource.contentType.matches('image/.*');
    }
  }
}

Without the size and content-type checks, an authenticated user can upload files of any size and type to your bucket, which is a hosting bill and a malware distribution problem at the same time.

The four mistakes that matter

1. if request.auth != null

// Any signed-in user can read every document in the collection.
allow read: if request.auth != null;

This is the most common rule in broken Firebase apps, and it reads as if it does something. It checks that the caller is signed in and nothing else. On an app with public signup, being signed in means nothing: an attacker creates an account in ten seconds and then reads every record.

The rule you almost always want compares the caller to the data:

allow read: if request.auth != null
            && resource.data.ownerId == request.auth.uid;

This is the same mistake that dominates Supabase apps, where a policy checks auth.uid() is not null instead of auth.uid() = user_id. Different database, identical error, and AI coding tools generate both versions constantly because the weak one makes the app work immediately. Is Cursor safe explains why that pattern keeps appearing in AI-written code.

2. Expecting rules to filter results

Firestore rules are not a where clause. If a query could touch a document the rules deny, the whole query is rejected. Developers read this as a bug and often "fix" it by loosening the rule.

The correct fix is to make the query mirror the rule:

// Rule
allow read: if resource.data.ownerId == request.auth.uid;
// Query must constrain the same field
const q = query(
  collection(db, "documents"),
  where("ownerId", "==", auth.currentUser.uid)
);

3. Leaving the wildcard match in place

match /{document=**} {
  allow read, write: if request.auth != null;
}

A recursive wildcard at the root grants access across every collection and subcollection, regardless of the careful per-collection rules you wrote alongside it, because a request is allowed if any matching rule allows it. Use a root wildcard only for a deliberate deny-all.

4. Heavy lookups inside rules

get() and exists() let a rule read another document, which is useful for membership checks. They also count as billed reads and are limited per request, so a rule that chains several lookups will be slow and can fail under load. Where you can, copy the field you need onto the document itself.

The Admin SDK ignores your rules

This one catches teams who have written good rules and assume they are covered everywhere.

Security Rules govern requests from client SDKs. Server code that uses the Firebase Admin SDK with a service account bypasses them completely. Every Cloud Function, API route or backend job that uses the Admin SDK is effectively running with no rules at all.

That is by design, and it means authorization moves into your code the moment a request touches the server. A Cloud Function that accepts a document id from the client and reads it with the Admin SDK has to check the caller owns that document itself, because nothing else will.

App Check does not fill this gap either. It verifies that a request comes from your genuine app rather than a script, which is useful against abuse. It does not verify that a real, signed-in user is allowed to read the specific record they asked for. An attacker with a normal account in your normal app passes App Check. Rules and your own server checks are still the authorization layer.

Test the rules, then deploy them

Rules are code, and the emulator runs them locally without touching production data:

firebase emulators:start --only firestore

For real coverage, Firebase's rules unit testing library, @firebase/rules-unit-testing, lets you assert both directions, which is the part people skip. Testing that the owner can read is easy. Testing that a stranger cannot is the test that matters.

Deploy rules on their own, separately from application code:

firebase deploy --only firestore:rules
firebase deploy --only storage
firebase deploy --only database

Two details from Firebase's deployment documentation matter here. A deploy overwrites the existing rules entirely rather than merging with them, and releases take several minutes to fully propagate. So a local rules file that is missing one collection's rules does not just fail to add them. It removes the ones that were live.

Catch a bad rules deploy after it ships

Every guide covers writing rules. Almost none cover what happens when someone deploys a bad set on a Friday, or a hotfix quietly reopens a collection. Because a deploy replaces everything at once, this is a real and common failure.

Every rules change is in your audit logs

Firebase records Security Rules changes in Google Cloud's audit logs under the firebaserules.googleapis.com service. Creating, updating and deleting rulesets and releases are recorded as Admin Activity, including the CreateRelease and UpdateRelease operations that a deploy performs. The Firebase Rules audit logging guide shows the Logs Explorer query:

protoPayload.serviceName="firebaserules.googleapis.com"

That gives you who changed the rules, when, and from where, for every deploy.

Turn that into an alert

In Google Cloud Logging you can create a log-based alert on that query, so anyone who can deploy rules generates a notification when they do. For a small team, an email on every rules release is the right amount of noise: it should happen rarely, and every one should be expected.

Keep rules in version control, and roll back by redeploying

Because a deploy replaces the whole ruleset, the safest rollback is simply redeploying the last known good rules file from your repository. That only works if the rules live in version control and nobody edits them directly in the console, so make that the rule for your team.

Block the dangerous patterns before they deploy

Add a check to CI that fails the build if the rules contain an open grant or a test-mode expiry:

if grep -nE "if true|request\.time <" firestore.rules storage.rules; then
  echo "Open or time-limited Firebase rules found. Refusing to deploy."
  exit 1
fi

It is crude, and it catches the two mistakes that cause the largest Firebase exposures.

One thing that is not a vulnerability

The apiKey in your Firebase web config is public by design. It identifies the project, it does not authorise access, and there is no way to use Firebase from a browser without shipping it. Finding it in your bundle is not a finding.

This trips people up because it looks exactly like a leaked credential, and it is the same shape of confusion as the Supabase anon key. The value that must never reach a client is a service account key, which is what the Admin SDK uses and which bypasses rules entirely. If one has ever been committed to a repository, rotate it now, and our guide on what to do when an API key is exposed covers the sequence.

Where this leaves you

Firebase's security model is unusually honest: your data is directly reachable by clients, the rules are the wall, and nothing else stands behind them for client requests. That is simpler than it sounds, because it means there is one place to look for client access and one rule to remember for server access.

Start from deny-all, write ownership rules per collection and per subcollection, never accept request.auth != null as an authorization check, check ownership yourself in anything using the Admin SDK, and watch the audit log for every rules release. Before launch, run the pre-deploy security checklist, and if your app was largely AI-generated, how vibe-coded apps get hacked covers why the permissive version of these rules is the one that tends to get written.

About the author

Samuel, Founder & maker of Ubserve
Samuel
Founder & maker of Ubserve

I'm Samuel, known online as Mr. Ballaz. I build Ubserve, a security scanner for apps built with AI tools like Cursor, Bolt, Lovable, and Supabase. Before Ubserve, I did manual security audits by hand — checking auth, exposed keys, and RLS policies one by one. Ubserve is that manual audit, automated, running in under 60 seconds instead of days.

Related resources

FAQs

What are Firebase Security Rules?+
They are the access control layer for Cloud Firestore, Realtime Database and Cloud Storage. Because Firebase clients talk to your data directly from the browser or app rather than through a server you control, the rules are the authorization layer. Firebase's documentation states that Firebase allows clients direct access to your data, and Firebase Security Rules are the only safeguard blocking access for malicious users.
What are the default Firebase Security Rules?+
Firebase offers locked mode as the default when you create a database. Firebase's documentation states that the default rules for locked mode deny access to all users for Cloud Firestore and Realtime Database, and that for Cloud Storage only authenticated users can access the buckets. Test mode is the opposite: it allows open reads and writes until an expiry date written into the rule.
Do Firebase Security Rules apply to the Admin SDK?+
No. Server code using the Firebase Admin SDK with a service account bypasses Security Rules entirely. Rules only govern requests from client SDKs. That means Cloud Functions and any backend using the Admin SDK must do their own authorization checks, because the rules you wrote will not stop them.
Why do my Firestore queries fail even though the documents exist?+
Firestore rules are not filters. If a query could return any document the rules would deny, the entire query is rejected rather than returning the allowed subset. The fix is to make the query match the rule: if the rule requires resource.data.ownerId == request.auth.uid, the query must include a where clause on ownerId with the same value.
Next step

Turn this resource into a real security check.

Review the guidance, then run the free scan to see whether this issue is actually exploitable in your app, no signup required.