Skip to content

Guide

Firebase Security Rules Best Practices

By Lucas Weber, CyberSec42 · Updated 28 Oct 2026

CyberSec42

In Firebase, the config object with your API key is meant to ship in the frontend: it identifies your project, it does not protect your data. Who can read and write what in Cloud Firestore, the Realtime Database and Cloud Storage is decided by Firebase Security Rules alone. Getting them right means denying by default, checking the owner on every document, validating fields on every write, and testing the result with the emulator and a second account.

The short version.

How Firebase protects data

Public key, rules, Admin SDK.

Go to section

Common mistakes

From test mode to the open bucket.

Go to section

Apps built fast

Why the gap stays invisible.

Go to section

Rule examples

Firestore, Storage, Realtime Database.

Go to section

Testing

Emulator, unit tests, second account.

Go to section

Checklist

Everything to check before launch.

Go to section

How Firebase protects your data

A Firebase app talks to Cloud Firestore, the Realtime Database and Cloud Storage straight from the browser or mobile app, with no server of your own in between. To do that, it ships a config object containing apiKey, projectId and a few other values. Anyone who opens your app can read them.

That is by design. The Firebase documentation on API keys states that keys for Firebase services only identify your project and app. They do not need to be treated as secrets and are fine to include in code or checked-in config files. The same page is explicit that API keys are not used to control access to backend resources: that is done with Firebase Security Rules for your users and App Check for your apps. The Firebase security checklist says the same. Firebase adds two caveats: restrict every key to the APIs the app actually uses, and use separate keys for other Google services.

The consequence: an attacker does not need a stolen key to query your database. They lift the config from your app and send their own requests. What they can see or change is decided by your rules and nothing else.

Each of the three products has its own rules. Firestore and Cloud Storage share a rules language built on match and allow statements; the Realtime Database uses a JSON structure with .read, .write and .validate. Three principles apply across the board:

When you create a database or Storage bucket, you choose between locked mode and test mode. In locked mode, Firestore and the Realtime Database deny all access according to the documentation, while Cloud Storage admits any authenticated user. Test mode grants access to everyone.

The common mistakes

None of these are weaknesses in Firebase. They are rules doing exactly what they say. Firebase describes most of them itself on its “Avoid insecure rules” page.

1. Test mode rules never get replaced

Firebase calls test mode good for getting started, but it lets anyone read and overwrite your data. What you end up with is allow read, write: if true; or a rule that only checks a date:

// DO NOT USE: anyone can do anything until this date
match /{document=**} {
  allow read, write: if request.time < timestamp.date(2026, 12, 1);
}

Until that date the database is open to anyone who knows the project ID. Firebase warns that anyone can then steal, modify or delete your data. For the Realtime Database, the documentation says test mode ends after a trial period: you are alerted by email, and then your rules deny all requests. The app suddenly stops working. Pushing the date back at that point simply keeps the database open.

2. Only checking that someone is signed in

A rule like allow read, write: if request.auth != null; looks like protection. Firebase lists it as not recommended: any signed-in user gets read and write access to your entire database. If anyone can sign up for your app, that is effectively public. Anonymous authentication makes it worse. The security checklist points out that anyone can create anonymous accounts, so non-public data should require a non-anonymous sign-in or a verified email address.

3. No ownership check

Every rule protecting personal data needs to compare against the signed-in user's ID, request.auth.uid. There are two patterns: the owner is part of the path, such as /users/{userId}, or stored in a field, such as ownerId. With the second pattern, often only reads are protected. If creates do not check that request.resource.data.ownerId matches the caller, a user can create documents on someone else's behalf. If updates do not check it, they can hand their own document over to another account.

4. No field validation

If a user may write their profile document, then without further checks they may set any field in it, including role, isAdmin or a subscription tier. If your app or your rules later derive permissions from those fields, anyone can promote themselves. Firestore has the tools for this, described in the documentation on field-level control:

Reads are a different story. Firestore always decides reads at the document level, and the documentation states that rules alone cannot prevent users from reading specific fields within a document. Sensitive fields belong in a separate document with its own rule.

5. Rules that behave differently than expected

Three properties of the rules language regularly cause gaps:

6. Cloud Storage left too open

The default Cloud Storage rules give every authenticated user access, not just the owner of a file. For invoices, ID documents, contracts or private photos that is not enough. Storage rules need the same ownership check as the database, usually via a path containing the user ID. Uploads also need limits: without checks on size and content type, any user can put arbitrary files of any size into your project, and you pay for them.

7. Admin credentials in the client

Per the documentation, the Admin SDK runs in privileged environments such as a server, with full admin privileges, and bypasses the rules. It authenticates as a service account, often via a JSON file containing a private key. The security checklist calls these keys sensitive and says they must be kept secret. Once such a file ends up in a web app, an app bundle or a public repository, your rules no longer matter. If that happens, replace the key immediately and delete the old one.

8. Treating App Check as a substitute for rules

App Check verifies that a request comes from your genuine app on a genuine device. The App Check documentation describes App Check and Firebase Authentication as complementary: Authentication protects your users, App Check protects you, the developer, from abuse of your backends. Firebase also notes that App Check does not eliminate all abuse. A signed-in user running your genuine app passes App Check. Whether they may read someone else's data is still decided by the rules alone. Firebase recommends enabling App Check for every supported service, as an extra layer, not a replacement.

Why apps built quickly are especially exposed

With AI coding tools, templates or app builders, a working Firebase app can come together very fast. The risk lies less in the generated code than in what gets checked afterwards, because broken rules are invisible in everyday use:

That is why the Firebase security checklist advises against writing rules as a pre-launch task and recommends treating them like a database schema, written alongside the app. If you are on Supabase instead, the equivalent guide is Supabase Row Level Security Best Practices.

Rule examples: minimal and correct

The Firestore example below covers the two most common cases: one profile document per user at /users/{userId}, and notes in /notes whose owner is stored in an ownerId field. Anything not explicitly allowed stays denied.

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

    function signedIn() {
      return request.auth != null;
    }
    function isUser(uid) {
      return signedIn() && request.auth.uid == uid;
    }
    function validNote(d) {
      return d.title is string && d.title.size() <= 200
          && d.body is string && d.body.size() <= 10000;
    }

    // Profile: the user only, allowed fields only
    match /users/{userId} {
      allow read: if isUser(userId);
      allow create: if isUser(userId)
        && request.resource.data.keys().hasOnly(['displayName'])
        && request.resource.data.displayName is string
        && request.resource.data.displayName.size() <= 80;
      allow update: if isUser(userId)
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayName'])
        && request.resource.data.displayName is string
        && request.resource.data.displayName.size() <= 80;
      allow delete: if false;
    }

    // Notes: owner stored in the document
    match /notes/{noteId} {
      allow read, delete: if isUser(resource.data.ownerId);
      allow create: if isUser(request.resource.data.ownerId)
        && request.resource.data.keys().hasOnly(['ownerId', 'title', 'body'])
        && validNote(request.resource.data);
      allow update: if isUser(resource.data.ownerId)
        && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'body'])
        && validNote(request.resource.data);
    }
  }
}

Four details matter here. rules_version = '2'; must be the first line. There is no rule on {document=**}, so collections not listed here are not accessible at all. Users cannot set fields like role or isAdmin, because hasOnly excludes them; create such values on the server. And ownerId cannot change on update, because it is not in the allowed list.

Since rules are not filters, the app has to query its notes so that the rule permits every possible result, that is, filtered on ownerId equal to the current user's ID. A query over the whole collection fails, and that is the intended behaviour.

The same principle applies to Cloud Storage. This example lets each user access only files in their own folder and, on upload, accepts only images under 5 MB. The limits follow the example in the Storage documentation:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /users/{userId}/{fileName} {
      allow read, delete: if request.auth != null
        && request.auth.uid == userId;
      allow create, update: if request.auth != null
        && request.auth.uid == userId
        && request.resource.size < 5 * 1024 * 1024
        && request.resource.contentType.matches('image/.*');
    }
  }
}

If you still use the Realtime Database, the same logic looks like this. $uid is the wildcard for the user ID in the path, and "$other" rejects any field not listed:

{
  "rules": {
    "notes": {
      "$uid": {
        ".read": "auth !== null && auth.uid === $uid",
        ".write": "auth !== null && auth.uid === $uid",
        "$noteId": {
          ".validate": "newData.hasChildren(['title', 'body'])",
          "title": { ".validate": "newData.isString() && newData.val().length <= 200" },
          "body": { ".validate": "newData.isString() && newData.val().length <= 10000" },
          "$other": { ".validate": false }
        }
      }
    }
  }
}

One caveat: no ".read": true may sit higher up, for example directly under "rules" or "notes". Because of cascading, it would apply to everything below.

How to test your rules

Only test projects you own or have written permission to test. Firebase ships its own tooling for this.

Step 1: The Rules Playground for a quick check

The rules editor in the Firebase console offers the Rules Playground for Firestore, the Realtime Database and Storage. You simulate a read or write on a path, unauthenticated, as an anonymous user or as a specific user ID, and see whether the rules allow it. Firebase itself positions the playground for quick checks and points to the emulator for full testing.

Step 2: Rules unit tests against the emulator

The Firebase Local Emulator Suite runs Firestore, the Realtime Database and Storage locally, for example with firebase emulators:start --only firestore. For automated tests there is the @firebase/rules-unit-testing library. It simulates signed-in and signed-out users, and assertSucceeds and assertFails check whether an operation is allowed or rejected. A test for the notes rules above, following the documented API:

import { readFileSync } from 'node:fs';
import {
  initializeTestEnvironment, assertSucceeds, assertFails,
} from '@firebase/rules-unit-testing';

const env = await initializeTestEnvironment({
  projectId: 'demo-rules-test',
  firestore: { rules: readFileSync('firestore.rules', 'utf8') },
});

// seed test data with rules disabled
await env.withSecurityRulesDisabled(async (ctx) => {
  await ctx.firestore().doc('notes/n1')
    .set({ ownerId: 'alice', title: 'A', body: '' });
});

const alice = env.authenticatedContext('alice').firestore();
const bob   = env.authenticatedContext('bob').firestore();
const guest = env.unauthenticatedContext().firestore();

await assertSucceeds(alice.doc('notes/n1').get());
await assertFails(bob.doc('notes/n1').get());      // someone else's note
await assertFails(guest.doc('notes/n1').get());    // signed out
await assertFails(bob.doc('notes/n2')              // on alice's behalf
  .set({ ownerId: 'alice', title: 'x', body: '' }));
await assertFails(alice.doc('notes/n1')            // change the owner
  .update({ ownerId: 'bob' }));

await env.cleanup();

For continuous integration, Firebase recommends firebase emulators:exec: it starts the emulators, runs your test script, for example firebase emulators:exec --only firestore "npm test", and shuts them down afterwards. The security checklist advises adding these tests to your CI pipeline, so a later change that loosens a rule gets caught.

Step 3: Test with a second account

The final test runs against your real project. Create two test accounts, A and B, and use A to create some records and upload a file. Then sign in as B and, from your app or a small test script using the Firebase client, try to:

Every one of these attempts must fail with a permission error. Repeat the tests while signed out. Keep in mind that, per the documentation, changes to Firestore rules can take up to a minute to affect new queries and up to ten minutes to reach active listeners, so do not test the instant you deploy.

Pre-launch checklist

AreaAction
Test modeNo rule with if true or a date-only condition left active in Firestore, the Realtime Database or Storage
DefaultEverything denied unless explicitly allowed; no broad rule on {document=**}
OwnershipEvery rule for personal data compares against request.auth.uid, including on create and update
FieldsAllowed fields via hasOnly, updates via affectedKeys(), types and lengths checked; roles and tiers not user-editable
Sensitive fieldsStored in separate documents with their own rule, since reads are controlled per document
SubcollectionsEvery subcollection has its own rule
Realtime DatabaseNo high-level .read or .write that overrides deeper rules
StorageOwnership check per path, upload size and content type limited
KeysOnly the Firebase config in the client, API key restricted; service account keys on the server only
Sign-inAnonymous accounts get no access to non-public data
App CheckEnabled for every supported service, in addition to the rules
TestsRules unit tests in the emulator run in CI; second-account and signed-out tests passed

What testing your own rules will not catch

Unit tests cover exactly the cases you thought of. Whether a Cloud Function using the Admin SDK skips a check, whether a team member can see another team's invoices, or whether a chain of individually allowed steps still reaches someone else's data often only shows up when someone deliberately attacks the app with several roles. If personal data is actually accessed in the process, that can trigger a notification obligation under Article 33 GDPR. Whether it does in a given case is a legal question. This article is not legal advice.

Would your Firebase rules hold up against a real attack?

We test apps built on Firebase, including those built with AI tools, within a scope agreed in writing: rules for Firestore, the Realtime Database and Storage, write permissions and fields that carry privileges, keys in the client, sign-in and Cloud Functions, each with several test accounts and without a session. Every finding comes with reproducible evidence and a concrete fix. Learn more about the AI app security check.

Request an app security check

CyberSec42 is an independent technical security assessment, not an accredited certification body, and does not provide legal advice. CyberSec42 is not affiliated with Google or Firebase.

Frequently asked questions

Is it safe to expose the Firebase API key in the frontend?

Yes. According to the Firebase documentation, keys for Firebase services only identify your project and app and do not need to be kept secret. Restrict the key to the APIs you use. Protecting your data is the job of Security Rules.

Is request.auth != null enough?

No, not for per-user data. It lets every signed-in user read and write all of your data, and Firebase lists it as not recommended. Also compare request.auth.uid with the owner of the document or file.

What happens when test mode rules expire?

For the Realtime Database, the documentation says you are alerted by email and then the rules deny all requests. Replace them with real access rules at that point rather than moving the expiry date. A later date keeps the database open to everyone.

Does App Check replace Security Rules?

No. App Check verifies that a request comes from your genuine app. Which user may read or change which data is still governed by the rules. Firebase recommends App Check as an additional layer.

Do the rules apply to the Admin SDK and Cloud Functions?

No. Server client libraries and the Admin SDK bypass Security Rules. Checks in Cloud Functions have to be written in your own code, and service account keys belong on the server only.

How do I test Firebase Security Rules automatically?

With the Firebase Local Emulator Suite and the @firebase/rules-unit-testing library. It simulates signed-in and signed-out users, and firebase emulators:exec runs the tests in CI.

Further reading

Sources