A Step-by-Step Guide to Writing Secure Firestore Rules

A Step-by-Step Guide to Writing Secure Firestore Rules
Firestore security rules are your primary defense against malicious data manipulation and unauthorised access. They live on Google’s servers and are enforced on every single request, making them a non-negotiable part of a secure application. By defining and enforcing a strict schema, you prevent data corruption, stop unexpected app behavior, and close potential security holes.
Never trust the client. Always assume a user will try to bypass your app’s UI and manipulate data directly. This guide provides a step-by-step process for building robust rules.
Step 1: Inventory Your Collections πΊοΈ
You can’t secure what you don’t know exists. The first step is to get a complete picture of your data model by listing every single collection in your Firestore database. This creates a clear checklist and ensures no part of your database is left unsecured.
Example:
users
projects
tasks
projectMemberships
appSettings
π‘ Tip: You can store document-level information (like
ownerId
, timestamps, or role assignments) directly in documents themselves. This avoids overcomplicating your rules and makes ownership checks much cleaner.
Step 2: Define Your User Roles π
Next, think about who will be accessing your data. These aren’t just individual users, but types or roles of users with different permission levels.
Common Roles:
- Unauthenticated User: Someone not logged in.
- Authenticated User: Any user who is signed in.
- Resource Owner: The user who created a specific document (e.g., the owner of a project).
- Admin: A user with special privileges, often defined by a custom claim or a field in their
users
document. - Team Member: A user who is part of a group and has permissions within that group’s resources.
Step 3: Write Reusable Helper Functions π οΈ
To keep your rules clean, readable, and easy to maintain (DRY - Don’t Repeat Yourself), use functions for common checks. The most common checks are for authentication status and user roles.
Example:
Step 4: Define Field-Level Access π¬
Now, get granular. For each collection, create a table that specifies who can read and write each individual field. This becomes your high-level permission plan.
Example: projects
Collection Schema
Field Name | Data Type | Who can READ? | Who can UPDATE? |
---|---|---|---|
name | string | Project Members | Project Owner |
ownerId | string | Project Members | No one (immutable) |
createdAt | timestamp | Project Members | No one (immutable) |
isArchived | bool | Project Members | Project Owner, Admin |
Step 5: Enforce Your Schema π
Translate your schema plan into concrete validation rules. This is your defense against a user trying to add an unauthorized field like isAdmin: true
to their profile or writing data in the wrong format.
Key Validation Techniques:
Check Data Types: Use the
is
keyword to enforce types.request.resource.data.name is string
request.resource.data.memberCount is number
Validate Content: Check the actual value of the data.
- String size:
request.resource.data.name.size() > 0 && request.resource.data.name.size() < 50
- Number range:
request.resource.data.priority >= 1 && request.resource.data.priority <= 5
- Specific values:
request.resource.data.status in ['pending', 'active', 'complete']
- String size:
Enforce Structure: Ensure no unexpected fields are added. This is critically important.
- Use
.keys().hasOnly()
to specify the exact set of allowed fields. request.resource.data.keys().hasOnly(['name', 'ownerId', 'createdAt', 'isArchived'])
- Use
Confirm Immutability: For fields that should never change after creation, verify the “after” value matches the “before” value on updates. The
resource
variable holds the data before the update.request.resource.data.createdAt == resource.data.createdAt
Step 6: Secure Actions & Ask “What If?” π΅οΈ
Finally, combine your role functions (who), field access (what), and schema enforcement (how) into rules for each action (get
, list
, create
, update
, delete
). For every rule, think like an attacker.
create
Rule:- Rule: A user can create a project if they are signed in, the data matches the schema exactly, and they set themselves as the owner.
- What if? What if they add an extra field
isPaid: true
? The.hasOnly()
check will block it. - What if? What if they set
name
to a number? Theis string
check will block it.
update
Rule:- Rule: A user can update a project only if they are the owner, the fields they are changing are valid, and they aren’t changing immutable fields like
ownerId
. - What if? What if an owner tries to change the
createdAt
timestamp? The immutability check (request.resource.data.createdAt == resource.data.createdAt
) will block it.
- Rule: A user can update a project only if they are the owner, the fields they are changing are valid, and they aren’t changing immutable fields like
β οΈ Note: Using
get()
orexists()
inside your rules counts as a document read. This can impact both performance and billing if used heavily, so try to minimise cross-document lookups when designing your security model.
Putting It All Together: A Final Rule Example
This example for the projects
collection now includes strict schema validation.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isProjectOwner(projectId) {
return exists(/databases/$(database)/documents/projects/$(projectId)) &&
get(/databases/$(database)/documents/projects/$(projectId)).data.ownerId == request.auth.uid;
}
match /projects/{projectId} {
allow get: if isProjectOwner(projectId);
allow create: if isSignedIn()
&& request.resource.data.ownerId == request.auth.uid
&& request.resource.data.keys().hasOnly(['name', 'ownerId', 'createdAt', 'isArchived'])
&& request.resource.data.name is string
&& request.resource.data.name.size() > 0 && request.resource.data.name.size() < 50
&& request.resource.data.createdAt == request.time
&& request.resource.data.isArchived == false;
allow update: if isProjectOwner(projectId)
&& request.resource.data.name is string
&& request.resource.data.isArchived is bool
&& request.resource.data.ownerId == resource.data.ownerId
&& request.resource.data.createdAt == resource.data.createdAt;
allow delete: if isProjectOwner(projectId);
}
}
}