Core Concepts: Security Rules
Understanding Firestore security rules and why they're critical
Before we implement security rules, let's understand why client-side validation isn't enough and how Firestore security rules protect your data. This isn't optional — without proper rules, your app's data is vulnerable to attacks.
What Are Firestore Security Rules?
Firestore security rules are server-side rules that control who can read and write data in your Firestore database. They're written in a special rules language and run on Google's servers, not in your app.
Think of security rules as a bouncer at a club:
- Your React app is like a person trying to enter
- Security rules check ID and permissions
- If rules allow it, the operation proceeds
- If rules deny it, Firestore rejects the request
Where rules live:
- Not in your React code
- Not in your browser
- On Google's servers, protecting your Firestore database
What rules control:
- Who can read documents (get, list)
- Who can write documents (create, update, delete)
- What data is valid (field validation)
Why Security Rules Matter
The Problem: Client-Side Code is NOT Secure
Here's a critical truth: Anything in your React app can be bypassed.
Your app might have:
- Login requirements
- Protected routes
- UserId filters in queries
- Form validation
But all of this runs in the browser, which users control.
A malicious user can:
- Open browser DevTools
- Inspect your code
- Find your Firebase config
- Write their own JavaScript to access Firestore directly
- Read/write/delete ANY data if you don't have rules
Example attack without security rules:
// Your app's query (trying to be secure):
const q = query(
collection(db, 'todos'),
where('userId', '==', currentUser.uid) // Client-side filter
);
// Attacker's code (bypasses your app entirely):
const q = query(collection(db, 'todos')); // No filter!
const allTodos = await getDocs(q); // Gets EVERYONE's todosWithout security rules, the attacker's code works. They can read all users' data.
What Could Go Wrong
Real-world scenarios without security rules:
Privacy breach:
- User A reads User B's personal todos
- Leaked email addresses, private notes, sensitive data
- GDPR violations, lawsuits, reputation damage
Data manipulation:
- Attacker modifies other users' data
- Deletes competitors' content
- Changes account balances (in a financial app)
Spam and abuse:
- Automated scripts flood your database
- Millions of fake documents created
- Firestore bill skyrockets (pay per read/write)
Complete data loss:
- Malicious script deletes all documents
- No undo, no recovery
- Your app's data is gone
Example from the real world:
In 2019, a fitness app left Firestore rules wide open. Within hours:
- Attackers scraped 100,000+ user profiles
- Personal health data, locations, workout routines exposed
- Company faced fines, lawsuits, and shut down
This is why security rules are NOT optional.
Server-Side vs Client-Side Validation
Client-Side (Your React App)
What it does:
- Improves user experience
- Provides instant feedback
- Prevents accidental errors
- Makes app feel responsive
What it CANNOT do:
- Stop malicious users
- Enforce security
- Protect data from direct API access
- Prevent bypassing via DevTools
Example (client-side):
// In your React component
if (!title) {
setError('Please enter a title');
return; // Stops submission
}
await addDoc(collection(db, 'todos'), { title, userId });Problem: A user can skip your component entirely and call addDoc() from the browser console with no validation.
Server-Side (Firestore Security Rules)
What it does:
- Enforces security on Google's servers
- Cannot be bypassed by users
- Validates every single Firestore request
- Protects against direct API access
What it checks:
- Is the user authenticated?
- Do they own this data?
- Are required fields present?
- Are field values valid types?
Example (server-side):
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /todos/{todoId} {
allow read, write: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
}
}
}Result: Even if attacker calls Firestore API directly, server checks rules first. No authentication or wrong userId? Request rejected.
Visual Diagram: Client vs Server Validation
CLIENT-SIDE (Can be bypassed)
┌─────────────────────────────────────────────────┐
│ React Component │
│ ┌─────────────────────────────────────────────┐ │
│ │ if (!title) return; │ │
│ │ ❌ User opens DevTools │ │
│ │ ❌ Calls addDoc() directly │ │
│ │ ❌ Skips validation │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│
▼
┌──────────────────────────┐
│ Firestore API │
│ (Accepts anything if │
│ no security rules) │
└──────────────────────────┘
SERVER-SIDE (Cannot be bypassed)
┌─────────────────────────────────────────────────┐
│ Any Request (from app, DevTools, scripts) │
└──────────────────┬──────────────────────────────┘
│
▼
┌──────────────────────────┐
│ Firestore Security Rules │
│ (Runs on Google servers) │
└──────────┬───────────────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
✅ Allowed ❌ Denied
(Rules pass) (Rules fail)
│ │
▼ ▼
Executes Returns error
Operation "Permission denied"Principle of Least Privilege
Least privilege means: Give users the minimum permissions they need, nothing more.
Bad (too permissive):
// Allow all authenticated users to read/write everything
allow read, write: if request.auth != null;Problem: User A can read and modify User B's todos. Everyone can access everything.
Good (least privilege):
// Allow users to only read/write their own data
allow read, write: if request.auth != null
&& resource.data.userId == request.auth.uid;Result: User A can only access their own todos. User B's data is private.
Why this matters:
- Limits damage from compromised accounts
- Prevents accidental data corruption
- Reduces attack surface
- Meets privacy regulations (GDPR, CCPA)
Real-world analogy:
Imagine a hospital:
- Bad: Every doctor can access every patient's records
- Good: Doctors can only access their own patients' records
If a doctor's account is hacked, the attacker only gets access to that doctor's patients, not the entire hospital.
Request vs Resource in Rules
Firestore security rules give you two key objects:
request
What it is: The incoming request trying to access Firestore
Available properties:
request.auth— User's authentication inforequest.auth.uid— User's unique IDrequest.auth.token.email— User's email
request.resource.data— Data being written (for create/update)request.time— Server timestamp of request
Use cases:
- Check if user is logged in:
request.auth != null - Validate userId matches:
request.resource.data.userId == request.auth.uid - Ensure required fields:
request.resource.data.title is string
resource
What it is: The existing document in Firestore (for read/update/delete)
Available properties:
resource.data— Current document's fieldsresource.data.userId— Who owns this documentresource.data.completed— Document's current status
resource.id— Document ID
Use cases:
- Check ownership before read:
resource.data.userId == request.auth.uid - Prevent changing userId:
request.resource.data.userId == resource.data.userId
When to Use Which
| Operation | request | resource |
|---|---|---|
| Create | ✅ Use request.resource.data (new data being written) | ❌ No resource (document doesn't exist yet) |
| Read | ✅ Use request.auth (who's reading?) | ✅ Use resource.data (what are they reading?) |
| Update | ✅ Use request.resource.data (new data) | ✅ Use resource.data (old data) |
| Delete | ✅ Use request.auth (who's deleting?) | ✅ Use resource.data (what are they deleting?) |
Visual Diagram: Request vs Resource
CREATE (new document)
┌─────────────────────────────────────────────────────────┐
│ request.auth.uid: "user123" │
│ request.resource.data: { │
│ title: "New todo", │
│ userId: "user123", │
│ completed: false │
│ } │
└─────────────────────────────────────────────────────────┘
│
▼ Check: request.resource.data.userId == request.auth.uid
│
▼
┌─────────────────────────────────────────────────────────┐
│ Firestore │
│ (No existing resource) │
└─────────────────────────────────────────────────────────┘
READ / UPDATE / DELETE (existing document)
┌─────────────────────────────────────────────────────────┐
│ request.auth.uid: "user123" │
│ request.resource.data: { ... new data ... } │
└─────────────────────────────────────────────────────────┘
│
▼ Check: resource.data.userId == request.auth.uid
│
▼
┌─────────────────────────────────────────────────────────┐
│ resource (existing document) │
│ resource.data: { │
│ title: "Existing todo", │
│ userId: "user123", │
│ completed: true │
│ } │
└─────────────────────────────────────────────────────────┘Rule Syntax: allow, match, if
Firestore rules use a specific syntax:
Basic Structure
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Rules go here
}
}rules_version = '2'— Use latest rules syntaxservice cloud.firestore— Defines Firestore rulesmatch /databases/{database}/documents— Matches all documents
match — Target Specific Paths
match defines which documents the rule applies to.
Examples:
// Match all documents in todos collection
match /todos/{todoId} {
// Rules for todos
}
// Match specific user's profile
match /users/{userId} {
// Rules for user profiles
}
// Match subcollections
match /users/{userId}/posts/{postId} {
// Rules for posts inside user documents
}Wildcards:
{todoId}— Captures the document ID into a variable- Can use the variable:
{todoId}becomes accessible astodoId
allow — Define Permissions
allow specifies what operations are permitted.
Operations:
read— Includesget(single document) andlist(query)write— Includescreate,update,delete- Can specify individually:
allow get, list, create, update, delete
Examples:
// Allow all authenticated users to read
allow read: if request.auth != null;
// Allow all authenticated users to write
allow write: if request.auth != null;
// Separate read/write
allow read: if true; // Anyone can read
allow write: if request.auth != null; // Only logged-in users can writeif — Conditions
if defines the condition that must be true for the rule to allow access.
Common conditions:
// User must be logged in
if request.auth != null
// User must own the data
if request.resource.data.userId == request.auth.uid
// For existing documents, check ownership
if resource.data.userId == request.auth.uid
// Combine conditions
if request.auth != null
&& request.resource.data.userId == request.auth.uidComplete Example
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Todos collection
match /todos/{todoId} {
// Read: User must be logged in AND own the todo
allow read: if request.auth != null
&& resource.data.userId == request.auth.uid;
// Create: User must be logged in AND set userId to their own
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
// Update: User must own the todo AND not change userId
allow update: if request.auth != null
&& resource.data.userId == request.auth.uid
&& request.resource.data.userId == resource.data.userId;
// Delete: User must own the todo
allow delete: if request.auth != null
&& resource.data.userId == request.auth.uid;
}
}
}Testing Rules Safely
Firestore provides a Rules Playground to test your rules before deploying.
Why Test First
Without testing:
- Deploy rules
- Break production app
- Users can't access data
- Panic, roll back, debug
With testing:
- Write rules
- Test in playground
- Fix issues
- Deploy confidently
- Users happy
Rules Playground
Firebase Console → Firestore → Rules → Rules Playground
What you can test:
- Choose operation (get, list, create, update, delete)
- Specify collection path
- Set authenticated user (simulate login)
- Provide data for create/update
- Click "Run" to simulate request
Test scenarios:
Scenario 1: Logged-in user reads their own todo
- Operation: get
- Path:
/todos/abc123 - Auth: Authenticated as
user123 - Simulate:
resource.data.userId = "user123" - Expected: ✅ Allowed
Scenario 2: Logged-in user reads someone else's todo
- Operation: get
- Path:
/todos/xyz789 - Auth: Authenticated as
user123 - Simulate:
resource.data.userId = "user456" - Expected: ❌ Denied
Scenario 3: Not logged in
- Operation: get
- Path:
/todos/abc123 - Auth: Unauthenticated
- Expected: ❌ Denied
Testing in Your App
After deploying rules, test in your actual app:
- Read your own data — Should work
- Try to read others' data — Should fail (check browser console for "Permission denied")
- Create new data — Should work
- Update your data — Should work
- Delete your data — Should work
- Log out and try to access — Should fail
How It All Fits Together: Complete Security Rules Example
Let's see a complete example for a todo app with validation.
The Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Todos collection rules
match /todos/{todoId} {
// Helper function: Check if user owns this todo
function isOwner() {
return request.auth != null
&& resource.data.userId == request.auth.uid;
}
// Helper function: Validate todo data
function isValidTodo() {
let data = request.resource.data;
return data.title is string
&& data.title.size() > 0
&& data.title.size() <= 200
&& data.completed is bool
&& data.userId is string
&& data.userId == request.auth.uid
&& data.createdAt is timestamp;
}
// Read: Must be logged in and own the todo
allow read: if isOwner();
// Create: Must be logged in and provide valid data
allow create: if request.auth != null
&& isValidTodo();
// Update: Must own the todo, provide valid data, and not change userId or createdAt
allow update: if isOwner()
&& isValidTodo()
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.createdAt == resource.data.createdAt;
// Delete: Must own the todo
allow delete: if isOwner();
}
}
}What These Rules Enforce
Authentication:
- ✅ All operations require login (
request.auth != null) - ❌ Unauthenticated users blocked completely
Ownership:
- ✅ Users can only read their own todos
- ✅ Users can only update/delete their own todos
- ❌ Can't read, modify, or delete others' data
Data Validation:
- ✅ Title required (non-empty string)
- ✅ Title max 200 characters
- ✅ Completed must be boolean
- ✅ UserId must match authenticated user
- ✅ CreatedAt must be timestamp
Immutability:
- ✅ UserId cannot be changed after creation
- ✅ CreatedAt cannot be changed after creation
- ❌ Can't reassign todos to other users
- ❌ Can't tamper with creation date
Attack Scenarios (All Blocked)
Attack 1: Read others' data
// Attacker tries to query all todos
const q = query(collection(db, 'todos'));
const docs = await getDocs(q);
// BLOCKED: Firestore returns only attacker's todos, not others'Attack 2: Create todo for another user
await addDoc(collection(db, 'todos'), {
title: 'Spam',
userId: 'victim123', // Try to attribute to someone else
completed: false,
createdAt: Timestamp.now()
});
// DENIED: userId must match request.auth.uidAttack 3: Modify someone else's todo
const todoRef = doc(db, 'todos', 'victim-todo-id');
await updateDoc(todoRef, { completed: true });
// DENIED: resource.data.userId doesn't match request.auth.uidAttack 4: Delete someone else's todo
const todoRef = doc(db, 'todos', 'victim-todo-id');
await deleteDoc(todoRef);
// DENIED: resource.data.userId doesn't match request.auth.uidAttack 5: Create invalid data
await addDoc(collection(db, 'todos'), {
title: '', // Empty title
completed: 'yes', // String instead of boolean
userId: currentUser.uid
});
// DENIED: title.size() must be > 0, completed must be boolVisual Diagram: Security Flow
┌───────────────────────────────────────────────────────────┐
│ User Action: Update todo │
└──────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ React Component │
│ updateDoc(todoRef, │
│ { completed: true })│
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Firestore API │
│ (Google's servers) │
└──────────┬───────────┘
│
▼
┌──────────────────────────────────────┐
│ Security Rules Check │
│ │
│ 1. Is user logged in? │
│ → Check request.auth != null │
│ │
│ 2. Does user own this todo? │
│ → Check resource.data.userId │
│ == request.auth.uid │
│ │
│ 3. Is update data valid? │
│ → Check field types │
│ │
│ 4. Are immutable fields unchanged? │
│ → Check userId, createdAt │
└──────────┬───────────────────────────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
✅ All Pass ❌ Any Fail
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Execute │ │ Reject │
│ Update │ │ Request │
└────────────┘ └────────────┘
│ │
▼ ▼
Success Response Error: "Missing or
insufficient permissions"Why These Patterns Matter
Security rules are the difference between a hobby project and a production-ready app.
Without rules:
- ❌ Anyone can access any data
- ❌ Data can be corrupted
- ❌ Privacy violations
- ❌ Spam and abuse
- ❌ Massive Firestore bills
- ❌ Legal liability
With proper rules:
- ✅ Users can only access their own data
- ✅ Data integrity enforced server-side
- ✅ Privacy protected by default
- ✅ Abuse limited
- ✅ Predictable costs
- ✅ Compliant with regulations
Real-world impact:
Good security rules mean:
- Users trust your app with personal data
- No embarrassing breaches in the news
- No panicked 3am alerts about deleted data
- No lawsuits
- Sleep peacefully
This is not optional for any app that stores user data.
What You'll Build
In this slice, you'll create:
- ✅ Firebase CLI installed and configured
- ✅ Firebase project initialized locally
- ✅
firestore.rulesfile with security rules - ✅ Rules for todos collection (read, create, update, delete)
- ✅ User data isolation (userId-based ownership)
- ✅ Field validation (required fields, types)
- ✅ Immutability rules (userId, createdAt can't change)
- ✅ Rules tested in Playground
- ✅ Rules deployed to Firestore
- ✅ App tested with rules enabled
By the end, your app will be secure. Users can only access their own data, and all data is validated server-side. No attacker can bypass your security, even if they try.
Next Steps
Now that you understand the concepts, let's start building: