Securing Your Firestore Database: The 'Database as Backend' Philosophy
Many modern applications leverage cloud-native solutions, often opting for a direct client-to-database connection to reduce infrastructure overhead. Firebase Firestore is a prime example, allowing applications like Cuentopia—an Ionic + Angular app built to help families explain difficult topics to children—to communicate directly with the database. This approach eliminates the need for a custom backend server, saving development, maintenance, and operational costs. However, it fundamentally shifts the security paradigm. Your database, exposed via APIs like REST and gRPC, becomes the primary gatekeeper. The sole defense between your users' data and the public internet lies in the declarative security rules you write. This article delves into the principles behind constructing robust Firestore security rules, drawing from real-world development experience with an invented bird-watching app, and highlights common pitfalls that can cost significant development time.
The core principle is to never implicitly trust the client. Just because a user is authenticated doesn't mean they should have carte blanche access to your data. Instead, every read and write operation must be explicitly permitted by rules that validate the request against the data itself and the user's identity and permissions. This is akin to building a backend API where every endpoint has its own authorization logic, but implemented directly within Firestore's rules engine.
Default Firestore Rules: The Unspoken Permissions
When you create a new Firestore project, the default security rules are often very permissive, especially in development mode. These rules typically allow anyone to read and write any data. This is a deliberate choice by Firebase to facilitate rapid prototyping and development. However, it's critical to understand that these are not production-ready settings. Leaving them as default is equivalent to leaving your front door wide open.
The initial setup might look something like this:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
This rule set, common in early development, allows any authenticated user to read and write any document. While simple, it provides minimal protection. An authenticated user can modify any document, regardless of ownership or context. This is where the concept of treating your database as your backend truly begins: you must define granular permissions for every collection and document.
Building Granular Permissions: The Bird-Watching App Example
Consider an application for logging bird sightings. Users can create sightings, and other users can view them. The data model might involve collections like users, birds, and sightings. Each sighting document would likely contain the bird observed, the location, the time, and crucially, a reference to the user who logged it.
User Data Management
For the users collection, you typically want users to be able to read their own profile information and perhaps public details of other users. However, only the authenticated user should be able to write to their own user document.
match /users/{userId} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
This rule ensures that only the logged-in user can write their own profile. Anyone authenticated can read user profiles, which might be useful for displaying usernames associated with sightings. If you wanted to restrict read access further, you would add more conditions to the allow read statement.
Sighting Data Management
The sightings collection requires more nuanced rules. Users should be able to create new sightings, but only if they are authenticated. They should be able to read any sighting, but perhaps only write or delete sightings they themselves created.
Creating a sighting:
match /sightings/{sightingId} {
allow create: if request.auth != null;
// ... other rules for read, update, delete
}
Allowing users to read all sightings:
match /sightings/{sightingId} {
allow read: if request.auth != null;
// ... other rules
}
Now, for writing and deleting, we need to ensure the user requesting the action is the one who created the sighting. This requires storing the user's ID within the sighting document itself. Let's assume each sighting document has a userId field.
match /sightings/{sightingId} {
allow read: if request.auth != null;
allow create: if request.auth != null;
allow update, delete: if request.auth != null && resource.data.userId == request.auth.uid;
}
Here, resource.data refers to the existing data in the document before the write operation. This rule prevents a user from editing or deleting a sighting logged by someone else. The request.auth.uid is the unique identifier of the currently authenticated user.
Common Pitfalls and Debugging
Despite the declarative nature of Firestore rules, implementing them correctly can be tricky. Several common bugs can emerge, often related to incorrect assumptions about data structure or authentication state.
Bug 1: Forgetting to Validate Data on Creation
A common mistake is allowing document creation without validating the data being written. For instance, if your create rule only checks for authentication but doesn't ensure the userId field is present and correct, a malicious user could potentially create a sighting with a falsified userId, making it appear as if someone else logged it. Or worse, they could omit the userId entirely, breaking subsequent read or update rules.
The corrected rule for creation would look like this:
match /sightings/{sightingId} {
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.birdId is string && // Example: ensure birdId is a string
request.resource.data.timestamp is timestamp; // Example: ensure timestamp is a valid timestamp
allow read: if request.auth != null;
allow update, delete: if request.auth != null && resource.data.userId == request.auth.uid;
}
Notice the use of request.resource.data for create and update operations. This refers to the data being sent in the request. We also added checks for other fields like birdId and timestamp to ensure data integrity.
Bug 2: Misunderstanding `resource` vs. `get()`
Firestore rules allow you to read other documents using the get() function. This is powerful for cross-referencing data, but it's also a common source of bugs due to performance implications and incorrect usage. For example, if you wanted to ensure a user can only log a bird that actually exists in the birds collection, you might write:
allow create: if request.auth != null &&
get(/databases/$(database)/documents/birds/$(request.resource.data.birdId)).data.exists == true;
The potential bug here is that get() operations are charged as reads. If this rule is evaluated frequently, it can lead to unexpected costs. Furthermore, if the referenced bird document doesn't exist, the rule will fail, preventing the sighting from being created. A more robust approach might involve denormalization or checking existence within a batched write if possible, though Firestore rules primarily operate on individual document operations.
The key is that resource.data is available for create and update, while resource.data is the *old* data and request.resource.data is the *new* data for update operations. For read and delete, only resource.data (the existing data) is available.
Bug 3: Overly Broad Wildcards
Using wildcards like {document=**} without sufficient granularity can expose too much data. While convenient for initial setup, it's imperative to replace these with more specific path segments as your application matures. For instance, if you have subcollections, you need rules for them too.
Consider a scenario where users can have private notes associated with sightings. This might be implemented as a subcollection under a sighting:
match /sightings/{sightingId}/notes/{noteId} {
allow read: if request.auth != null && exists(/databases/$(database)/documents/sightings/$(sightingId));
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.timestamp is timestamp;
allow update, delete: if request.auth != null && resource.data.userId == request.auth.uid;
}
In this example, we first check if the parent sighting document exists to ensure the path is valid. Then, we apply rules for creating, updating, and deleting notes, ensuring that only the user who wrote the note can modify it, and that the note is associated with an existing sighting. It's crucial to ensure that the user creating the note is also the one associated with the parent sighting, or has a specific permission level. If the note is meant to be private to the user who logged the sighting, the rule would be:
match /sightings/{sightingId}/notes/{noteId} {
allow read, create, update, delete: if request.auth != null &&
resource.data.userId == request.auth.uid &&
get(/databases/$(database)/documents/sightings/$(sightingId)).data.userId == request.auth.uid;
}
This rule ensures that a user can only access their own notes, and that these notes are attached to sightings they themselves created.
The Unanswered Question: Scalability of Complex Rules
While these rules provide robust security, what remains largely unaddressed is the practical limit of complexity and performance for these declarative rules in very large-scale applications. As the number of collections, subcollections, and interdependencies grows, the rule set can become unwieldy and difficult to debug. Debugging tools for Firestore rules are improving, but complex rule interactions can still lead to performance bottlenecks or unexpected access denials. Developers must constantly balance granular security with the operational cost and complexity of managing an increasingly intricate ruleset. The question is, at what point does the complexity of declarative rules outweigh the benefits of not having a custom backend?
Conclusion: Treat Firestore Rules as Your Backend's Firewall
By adopting the mindset that your database is your backend, you are forced to implement security at the data layer. Firestore security rules are not just an optional add-on; they are the fundamental mechanism for protecting your application's data. They act as a sophisticated firewall, meticulously checking every request against your defined policies. Treating them with the same rigor you would apply to backend API security—validating inputs, checking ownership, and enforcing granular permissions—is essential for any production application. The initial investment in understanding and implementing these rules pays dividends in data integrity and security, preventing costly bugs and protecting user trust.
