Firebase Security Rules are the only thing protecting your data from unauthorised access. This guide covers how to write rules that actually secure your app.
Understanding the Basics
Firebase Security Rules work by matching paths and applying conditions. If the condition evaluates to true, the request is allowed. If false, it's denied.
Cloud Firestore Rules Structure
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Your rules go here
match /collection/{document} {
allow read, write: if <condition>;
}
}
}
Realtime Database Rules Structure
{
"rules": {
"path": {
".read": "<condition>",
".write": "<condition>"
}
}
}
Key Concepts
Match blocks: Define which paths the rule applies to
Allow statements: Specify what operations are permitted
Conditions: Boolean expressions that grant or deny access
Variables:
request(incoming request data) andresource(existing data)
Rule Methods
Firestore rules support granular methods:
read: Covers bothget(single document) andlist(queries)write: Coverscreate,update, anddeleteget: Read a single documentlist: Read queries and collectionscreate: Write new documentsupdate: Modify existing documentsdelete: Remove documents
// Granular control
match /posts/{postId} {
allow get: if true; // Anyone can read a single post
allow list: if request.auth != null; // Only authenticated users can query
allow create: if request.auth != null; // Only authenticated users can create
allow update: if request.auth.uid == resource.data.authorId; // Only author can update
allow delete: if request.auth.uid == resource.data.authorId; // Only author can delete
}
Common Secure Patterns
Pattern 1: User Can Only Access Their Own Data
Use case: User profiles, private documents, personal settings
Firestore:
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
Realtime Database:
{
"rules": {
"users": {
"$userId": {
".read": "$userId === auth.uid",
".write": "$userId === auth.uid"
}
}
}
}
Pattern 2: Public Read, Authenticated Write
Use case: Blog posts, public content, product listings
Firestore:
match /posts/{postId} {
allow read: if true;
allow create: if request.auth != null;
allow update, delete: if request.auth != null
&& request.auth.uid == resource.data.authorId;
}
Realtime Database:
{
"rules": {
"posts": {
"$postId": {
".read": true,
".write": "auth != null && (!data.exists() || data.child('authorId').val() === auth.uid)"
}
}
}
}
Pattern 3: Role-Based Access Using Custom Claims
Use case: Admin panels, multi-role applications
Setup custom claims (server-side):
const admin = require('firebase-admin');
// Set custom claims
await admin.auth().setCustomUserClaims(uid, { admin: true });
Firestore rules:
match /adminData/{document} {
allow read, write: if request.auth.token.admin == true;
}
match /posts/{postId} {
allow read: if true;
allow write: if request.auth.token.editor == true
|| request.auth.token.admin == true;
}
Realtime Database:
{
"rules": {
"adminData": {
".read": "auth.token.admin === true",
".write": "auth.token.admin === true"
}
}
}
Pattern 4: Data Validation
Use case: Ensuring data format and required fields
Firestore:
match /posts/{postId} {
allow create: if request.auth != null
&& request.resource.data.keys().hasAll(['title', 'content', 'authorId'])
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() < 200
&& request.resource.data.authorId == request.auth.uid;
allow update: if request.auth != null
&& request.auth.uid == resource.data.authorId
&& request.resource.data.authorId == resource.data.authorId; // Prevent changing author
}
Realtime Database:
{
"rules": {
"posts": {
"$postId": {
".write": "auth != null && newData.hasChildren(['title', 'content', 'authorId'])",
"title": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length < 200"
},
"authorId": {
".validate": "newData.val() === auth.uid && (!data.exists() || data.val() === newData.val())"
}
}
}
}
}
Pattern 5: Attribute-Based Access (Data-Driven Roles)
Use case: Shared documents, team access, permission-based systems
Firestore:
match /projects/{projectId} {
allow read: if request.auth != null
&& request.auth.uid in resource.data.members;
allow write: if request.auth != null
&& request.auth.uid in resource.data.admins;
}
Realtime Database:
{
"rules": {
"projects": {
"$projectId": {
".read": "auth != null && data.child('members').child(auth.uid).exists()",
".write": "auth != null && data.child('admins').child(auth.uid).exists()"
}
}
}
}
Using Functions for Reusable Logic
Functions make rules more maintainable and readable.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Check if user is authenticated
function isSignedIn() {
return request.auth != null;
}
// Check if user owns the resource
function isOwner(userId) {
return request.auth.uid == userId;
}
// Check if user has a specific role
function hasRole(role) {
return isSignedIn() && request.auth.token[role] == true;
}
// Validate required fields
function hasRequiredFields(fields) {
return request.resource.data.keys().hasAll(fields);
}
// Use the functions
match /users/{userId} {
allow read: if isSignedIn();
allow write: if isOwner(userId);
}
match /posts/{postId} {
allow create: if isSignedIn()
&& hasRequiredFields(['title', 'content', 'authorId'])
&& isOwner(request.resource.data.authorId);
allow update: if isOwner(resource.data.authorId);
allow delete: if isOwner(resource.data.authorId) || hasRole('admin');
}
}
}
Handling Subcollections
In Firestore, rules don't cascade to subcollections. You must explicitly define rules for each level.
match /users/{userId} {
allow read: if request.auth.uid == userId;
// Subcollection requires its own rules
match /privateData/{document} {
allow read, write: if request.auth.uid == userId;
}
// Another subcollection
match /posts/{postId} {
allow read: if true; // Public read
allow write: if request.auth.uid == userId; // Only owner can write
}
}
Important: A match like /users/{userId}/{document=**} will match ALL nested subcollections recursively. Use this carefully.
// This matches /users/{userId}/anything/at/any/depth
match /users/{userId}/{document=**} {
allow read: if request.auth.uid == userId;
}
Realtime Database: Cascading Rules
In Realtime Database, rules CASCADE. Parent rules override child rules.
{
"rules": {
"users": {
// This grants read access to all user data
".read": "auth != null",
"$userId": {
// This CANNOT restrict the read access granted above
".read": "$userId === auth.uid", // This is IGNORED
".write": "$userId === auth.uid"
}
}
}
}
Correct approach: Don't grant broad access at parent levels.
{
"rules": {
"users": {
"$userId": {
".read": "$userId === auth.uid",
".write": "$userId === auth.uid"
}
}
}
}
Testing Your Rules
Use FireScan
Try out my purpose built tool for auditing firebase infrastructure. It’s completely free, open-source and available for anyone to use. Check it out here.
Use the Firebase Emulator
Install and run locally:
npm install -g firebase-tools
firebase init emulators
firebase emulators:start
Use the Rules Simulator in Firebase Console
Navigate to Firestore/Realtime Database → Rules → Playground
Select operation type (get, list, create, etc.)
Choose authenticated or unauthenticated
Specify the path
Run simulation
This is useful for quick checks but not a substitute for proper testing.
Common Mistakes to Avoid
1. Using if true in Production
// NEVER DO THIS
match /{document=**} {
allow read, write: if true;
}
2. Relying Only on request.auth != null
// This allows ANY authenticated user to access ANY data
match /users/{userId} {
allow read, write: if request.auth != null; // Too permissive
}
// Better: verify the user matches
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
3. Forgetting Realtime Database Cascade Rules
{
"rules": {
"data": {
".read": true, // Grants read to everything below
"private": {
".read": false // This is IGNORED, read was already granted above
}
}
}
}
4. Not Validating Data on Create/Update
// Bad: No validation
match /posts/{postId} {
allow create: if request.auth != null;
}
// Good: Validate required fields and author
match /posts/{postId} {
allow create: if request.auth != null
&& request.resource.data.keys().hasAll(['title', 'content', 'authorId'])
&& request.resource.data.authorId == request.auth.uid;
}
5. Allowing Field Modification That Shouldn't Change
// Bad: User can change the author
match /posts/{postId} {
allow update: if request.auth.uid == resource.data.authorId;
}
// Good: Prevent changing the author field
match /posts/{postId} {
allow update: if request.auth.uid == resource.data.authorId
&& request.resource.data.authorId == resource.data.authorId;
}
6. Overusing get() and exists()
Each get() or exists() call in your rules counts as a read operation and costs money. You're also limited to 10 calls per request.
// Bad: Multiple get() calls
match /posts/{postId} {
allow read: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'reader'
|| get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
}
// Better: Use custom claims or structure data differently
match /posts/{postId} {
allow read: if request.auth.token.reader == true
|| request.auth.token.admin == true;
}
Version Control Your Rules
Keep your rules in source control alongside your code.
Add to .gitignore if needed:
# Don't ignore rules files
!firestore.rules
!database.rules.json
Example firestore.rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// All your rules here
}
}
Deploy with Firebase CLI:
firebase deploy --only firestore:rules
firebase deploy --only database
Deployment Checklist
Before deploying rules to production:
Remove all
if trueorif falsetest rulesVerify authentication checks on all sensitive paths
Test rules using the emulator with unit tests
Check for cascading rule issues (Realtime Database)
Validate required fields on create/update operations
Ensure users can't modify fields they shouldn't (like
authorId)Review
get()andexists()usage (limit of 10 per request)Test with authenticated and unauthenticated contexts
Version control your rules
Use
firebase deploy --only firestore:rules(don't deploy everything)
Complete Example: Blog Application
Here's a complete, production-ready ruleset for a blog app:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper functions
function isSignedIn() {
return request.auth != null;
}
function isOwner(uid) {
return isSignedIn() && request.auth.uid == uid;
}
function isAdmin() {
return isSignedIn() && request.auth.token.admin == true;
}
// User profiles
match /users/{userId} {
allow read: if isSignedIn();
allow create: if isOwner(userId)
&& request.resource.data.keys().hasAll(['displayName', 'email'])
&& request.resource.data.email == request.auth.token.email;
allow update: if isOwner(userId)
&& request.resource.data.email == resource.data.email; // Prevent email change
allow delete: if isOwner(userId) || isAdmin();
}
// Blog posts
match /posts/{postId} {
allow read: if resource.data.published == true || isOwner(resource.data.authorId) || isAdmin();
allow create: if isSignedIn()
&& request.resource.data.keys().hasAll(['title', 'content', 'authorId', 'published', 'createdAt'])
&& isOwner(request.resource.data.authorId)
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 200
&& request.resource.data.createdAt == request.time;
allow update: if isOwner(resource.data.authorId)
&& request.resource.data.authorId == resource.data.authorId // Prevent author change
&& request.resource.data.createdAt == resource.data.createdAt; // Prevent timestamp change
allow delete: if isOwner(resource.data.authorId) || isAdmin();
// Comments subcollection
match /comments/{commentId} {
allow read: if true;
allow create: if isSignedIn()
&& request.resource.data.keys().hasAll(['text', 'authorId', 'createdAt'])
&& isOwner(request.resource.data.authorId)
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 1000;
allow update: if isOwner(resource.data.authorId)
&& request.resource.data.authorId == resource.data.authorId;
allow delete: if isOwner(resource.data.authorId) || isAdmin();
}
}
}
}
Final Thoughts
Default to denying access. Only grant permissions where specifically needed.
Always verify authentication with
request.auth != nulland check user ownership.Validate data on create and update operations.
Prevent field tampering by ensuring critical fields don't change on update.
Use custom claims for roles instead of repeated
get()calls.Test your rules with the emulator and unit tests before deploying.
Version control your rules and review changes like code.
Understand cascading (Realtime Database) vs explicit subcollection rules (Firestore).
Firebase Security Rules are powerful but require careful implementation. Take the time to write them correctly, test them thoroughly, and audit them regularly.
Your rules are the only thing standing between your data and unauthorised access. Make them count.
Top comments (0)