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:
- Set
isAdmin: trueon their own profile - Write 10MB blobs that blow your Firestore quota
- Inject fields that crash your client app
- Overwrite fields with unexpected types, breaking your code
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:
nameandemailare stringsemailcontains an @ sign (basic format check)roleis one of two allowed values (not "admin")- No extra fields (like "isAdmin") are being written
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
- Use custom claims (
request.auth.token.role) instead ofget()for role checks when possible. Custom claims are embedded in the auth token and cost nothing to evaluate. - Cache at the client — if the client already fetched a document, do not re-check it in rules. Consider using client-side logic for non-critical checks.
- Limit to create-only — use
get()only oncreateoperations, not onreadorupdate.
// 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:
- Go to Firestore > Rules > Rules Playground
- Set the simulated auth state (authenticated as a specific UID, or unauthenticated)
- Test each operation: read, write, create, update, delete
- 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
- Start deny-all. Never start with open rules and try to lock things down later. Start closed, open only what you need.
- Validate everything. Use
request.resource.datato check types, formats, allowed values, and field names on every write operation. - Separate create, update, delete. Do not use blanket
writepermissions. Each operation has different security implications. - Use custom claims for roles. They are faster and cheaper than
get()calls to a roles document. - Document every subcollection. Every path in your database must have an explicit rule. Use the Firebase Console to audit your rules.
- Test before deploy. Use the Firebase Emulator Suite and Rules Playground to verify every operation.
- Monitor rule evaluation errors. Firestore logs permission-denied errors. Monitor them in Firebase Crashlytics or Cloud Logging to catch legitimate users being blocked.
- Review rules on every deploy. Security rules should be part of your code review process, just like any other code change.
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