← Back to Blog
Security · 15 min read · July 3, 2026

Firebase Security Rules — Common Mistakes and How to Fix Them

Firebase Security Rules are your first and most important line of defense. Yet a single misconfigured rule can expose millions of user records. Here are the eight most common mistakes developers make — and exactly how to fix each one.

Core principle: Firebase Security Rules are allowlist-only. Everything is denied by default. The moment you write allow read, write: if true; in production, your database is wide open to anyone on the internet.

Why Security Rules Matter More Than You Think

Firebase Security Rules sit between your client app and your database. Every read and write operation passes through them. They are not optional configuration you get to later — they are the only thing preventing anyone who discovers your Firebase project ID from reading or writing your data.

Unlike a traditional backend API, Firebase lets client apps connect directly to Firestore or the Realtime Database. There is no server-side middleware filtering requests. Your security rules are the middleware. If they are wrong, there is nothing else protecting your data.

Here are the eight most common mistakes we see in production Firebase apps — and how to fix them before they become a breach.

Mistake 1: Leaving Your Database in Test Mode

This is the most common and most dangerous mistake. Firebase projects start in test mode by default:

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

The if true condition means anyone on the internet can read every document and write to any path. Test mode is useful during prototyping, but we regularly scan Firebase projects that have been in production for months with these default rules still active.

The Fix

Lock all access down immediately. Start with deny-all, then open only what you need:

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

    // Then grant specific access
    match /users/{userId} {
      allow read: if request.auth != null;
      allow write: if request.auth.uid == userId;
    }

    match /posts/{postId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update, delete: if request.auth.uid == resource.data.authorId;
    }
  }
}

Important: The order of match statements does not matter in Firestore rules — the most specific matching rule wins. But if you have a broad match /{document=**} with allow read, write: if true, it will match everything before your more specific rules can apply. Always start with if false on the broad match, then layer specific opens.

Mistake 2: Using request.auth.uid Incorrectly

Checking that request.auth is not null is not enough. Many developers write rules like this:

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

This allows any authenticated user to read and write any user document. User A can read user B's private data. User A can overwrite user B's profile with malicious content.

The Fix

Always tie document access to the authenticated user's ID:

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

The {userId} wildcard captures the document ID from the path. request.auth.uid is the Firebase Authentication UID of the currently signed-in user. When they match, the user can only access their own document.

For data that needs to be shared between users — like a team workspace — use custom claims or a separate access-control document:

match /workspaces/{workspaceId} {
  allow read: if request.auth != null &&
    exists(/databases/$(database)/documents/workspaces/$(workspaceId)/members/$(request.auth.uid));
}

Mistake 3: Not Validating Incoming Data Structure

Allowing authenticated users to write arbitrary data is almost as dangerous as leaving the database open. Without data validation, an attacker can:

Consider this broken rule:

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

It checks authentication but not the data being written.

The Fix

Use request.resource.data to validate the incoming document before allowing the write:

match /users/{userId} {
  allow write: if request.auth.uid == userId
    && request.resource.data.name is string
    && request.resource.data.email is string
    && request.resource.data.email.matches("@")
    && request.resource.data.role in ["user", "editor"]
    && request.resource.data.keys().hasOnly(["name", "email", "role", "createdAt"]);
}

This rule validates that:

Mistake 4: Overly Permissive Write Rules

A common pattern in early-stage apps is allowing full CRUD access because it is easier to develop. The result looks like this:

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

Any authenticated user can edit or delete anyone else's posts. This is how comment sections get defaced, blog posts get deleted, and user-generated content gets replaced with spam.

The Fix

Separate the permissions for create, update, and delete:

match /posts/{postId} {
  allow read: if request.auth != null;
  allow create: if request.auth != null;
  allow update: if request.auth.uid == resource.data.authorId;
  allow delete: if request.auth.uid == resource.data.authorId;
}

For additional safety on create, validate that the author field matches the authenticated user:

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

This prevents a user from creating a post and setting authorId to someone else's UID.

Mistake 5: Ignoring Subcollection Rules

This is one of the most subtle and dangerous mistakes. Security rules on a parent document do not cascade to subcollections. Consider this structure:

/users/{userId}/private/sensitive-data
/posts/{postId}/comments/{commentId}
/workspaces/{workspaceId}/logs/{logId}

If you only write rules for /users/{userId} and /posts/{postId}, the subcollections private, comments, and logs may have no rules at all — meaning they fall back to the default deny. But if you have a broad match /{document=**} { allow read, write: if true; } anywhere, subcollections are wide open.

The Fix

Explicitly define rules for every subcollection path:

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

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

match /posts/{postId} {
  allow read: if request.auth != null;
  allow create: if request.auth != null;

  // Comments subcollection
  match /comments/{commentId} {
    allow read: if request.auth != null;
    allow create: if request.auth != null
      && request.resource.data.authorId == request.auth.uid;
    allow update, delete: if request.auth.uid == resource.data.authorId;
  }
}

Audit tip: Go to the Firebase Console > Firestore > Rules and check if you have any subcollection paths that are not explicitly covered. Any path without a matching rule uses the default deny — but if your top-level rule is allow read, write: if true, subcollections inherit that full access. The safest approach is to replace the broad wildcard with specific paths.

Mistake 6: Overusing get() and exists() Without Understanding Costs

Firebase Security Rules support get() and exists() functions to read other documents during rule evaluation. They are powerful but expensive:

match /posts/{postId} {
  allow create: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == "premium";
}

Each get() call counts as a Firestore read operation. If every write to a popular collection triggers a get(), your read costs increase proportionally. For a collection with 100,000 writes per day, that is 100,000 extra reads just from rule evaluation.

The Fix

// Better: use custom claims
match /posts/{postId} {
  allow create: if request.auth.token.role == "premium";
}

// Only use get() when you must, and limit scope
match /admin/{document} {
  allow read: if request.auth.token.role == "admin"
    || get(/databases/$(database)/documents/roles/$(request.auth.uid)).data.isAdmin == true;
}

Mistake 7: Not Testing Rules Before Deploying

Writing Firebase Security Rules is notoriously error-prone because the syntax is different from JavaScript and the evaluation model takes time to learn. Deploying untested rules to production is a common source of either security holes (rules too permissive) or broken functionality (rules too strict).

The Fix

Use the Firebase Emulator Suite to test rules locally before deploying:

# Install the emulator
firebase init emulators

# Start the emulator
firebase emulators:start --only firestore,auth

# Deploy rules to emulator
firebase deploy --only firestore:rules --project demo-project

Then use the Security Rules playground in the Firebase Console to simulate specific requests:

  1. Go to Firestore > Rules > Rules Playground
  2. Set the simulated auth state (authenticated as a specific UID, or unauthenticated)
  3. Test each operation: read, write, create, update, delete
  4. Test edge cases: wrong user ID, missing fields, disallowed roles

Better yet, write automated tests using the Firebase Test SDK:

const { assertFails, assertSucceeds } = require("@firebase/rules-unit-testing");

const testEnv = await initializeTestEnvironment({
  projectId: "demo-project",
  firestore: { rules: fs.readFileSync("firestore.rules", "utf8") },
});

// Test: authenticated user can read own profile
const authedUser = testEnv.authenticatedContext("user-123");
await assertSucceeds(
  authedUser.firestore().doc("users/user-123").get()
);

// Test: authenticated user cannot read another user's profile
await assertFails(
  authedUser.firestore().doc("users/user-456").get()
);

Mistake 8: Confusing resource.data with request.resource.data

This is one of the most common syntax errors in Firebase Security Rules. The two objects serve entirely different purposes:

resource.data — the existing document in the database. Use this when checking current state before an update (e.g., "is the document currently owned by this user?").

request.resource.data — the incoming data the client wants to write. Use this when validating new values before allowing a write (e.g., "does the new email have a valid format?").

Here is the typical mistake:

// WRONG: checking incoming data against old data
match /users/{userId} {
  allow update: if resource.data.email.matches("@mycompany.com");
}

// RIGHT: validating the new data being written
match /users/{userId} {
  allow update: if request.resource.data.email.matches("@mycompany.com");
}

The first rule checks if the existing email matches the company domain — which it always will if the user was already set up correctly. The attacker can change their email to any value because the rule is checking old data, not the incoming write.

Putting It All Together: A Secure Rules Template

Here is a production-ready security rules template that avoids all eight mistakes:

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

    // Deny all by default
    match /{document=**} {
      allow read, write: if false;
    }

    // Users collection
    match /users/{userId} {
      allow read: if request.auth.uid == userId;
      allow create: if request.auth.uid == userId
        && request.resource.data.email is string
        && request.resource.data.displayName is string;
      allow update: if request.auth.uid == userId
        && request.resource.data.keys().hasAll(["email", "displayName"])
        && request.resource.data.keys().hasOnly(["email", "displayName", "photoUrl", "updatedAt"]);
      allow delete: if request.auth.uid == userId;

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

    // Posts collection
    match /posts/{postId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null
        && request.resource.data.authorId == request.auth.uid
        && request.resource.data.title is string
        && request.resource.data.title.size() < 200;
      allow update: if request.auth.uid == resource.data.authorId;
      allow delete: if request.auth.uid == resource.data.authorId;

      // Comments subcollection
      match /comments/{commentId} {
        allow read: if request.auth != null;
        allow create: if request.auth != null
          && request.resource.data.authorId == request.auth.uid;
        allow update, delete: if request.auth.uid == resource.data.authorId;
      }
    }

    // Admin-only collection (uses custom claims)
    match /admin/{document} {
      allow read: if request.auth.token.role == "admin";
      allow write: if request.auth.token.role == "admin";
    }
  }
}

Best Practices Summary

Final word: A single misconfigured Firebase Security Rule can expose your entire user database to the internet. These mistakes are common because Firebase rules syntax is different from JavaScript and the evaluation model is subtle. The good news is that once you fix these eight patterns, your database will be more secure than 90% of Firebase projects in production. Run a regular audit of your rules — every time you deploy, every time you add a new collection, and periodically even when nothing changes.

Frequently Asked Questions

What is the most common Firebase Security Rules mistake?

The most common mistake is leaving Firestore or Realtime Database in test mode with read and write set to true for all users. This makes your entire database publicly accessible. Always start with deny-all rules and grant access only to authenticated users with specific permissions.

How do I test my Firebase Security Rules before deploying?

Use the Firebase Security Rules playground in the Firebase Console, or the Firebase Emulator Suite which lets you run and debug rules locally. The firebase emulators:start command launches an emulated Firestore and Auth instance where you can test rules with specific authenticated user states before deploying to production.

What is the difference between resource.data and request.resource.data?

resource.data refers to the existing data in the database at the time of the request, while request.resource.data refers to the incoming data that the client is trying to write. Mistaking these two is a common error: using resource.data to validate incoming writes won't work because it checks the old data, not the new values being written.

Do Firebase Security Rules apply to subcollections automatically?

No. Security rules on a parent document do NOT cascade to subcollections. Each subcollection must have its own rules defined explicitly. This is one of the most commonly overlooked vulnerabilities — developers secure the top-level documents but leave subcollections completely open.

Can I use Firebase Security Rules with Firebase Storage?

Yes. Firebase Storage uses its own separate security rules syntax that follows similar patterns to Firestore rules. Storage rules control who can upload, download, or delete files, and can validate file size, content type, and path patterns. Storage rules use the same request.auth object for authentication checks.

Keep your Firebase app secure

RootCrak's AI Watchdog continuously monitors your infrastructure for misconfigurations, exposed databases, and security regressions. Get a free audit of your Firebase security posture today.

Start Free Audit