# How to Write Secure Firebase Rules

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

```plaintext
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

```plaintext
{
  "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) and `resource` (existing data)
    

# Rule Methods

Firestore rules support granular methods:

* `read`: Covers both `get` (single document) and `list` (queries)
    
* `write`: Covers `create`, `update`, and `delete`
    
* `get`: Read a single document
    
* `list`: Read queries and collections
    
* `create`: Write new documents
    
* `update`: Modify existing documents
    
* `delete`: Remove documents
    

```plaintext
// 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**:

```plaintext
match /users/{userId} {
  allow read, write: if request.auth != null && request.auth.uid == userId;
}
```

**Realtime Database**:

```plaintext
{
  "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**:

```plaintext
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**:

```plaintext
{
  "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):

```plaintext
const admin = require('firebase-admin');

// Set custom claims
await admin.auth().setCustomUserClaims(uid, { admin: true });
```

**Firestore rules**:

```plaintext
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**:

```plaintext
{
  "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**:

```plaintext
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**:

```plaintext
{
  "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**:

```plaintext
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**:

```plaintext
{
  "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.

```plaintext
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.

```plaintext
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.

```plaintext
// 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.

```plaintext
{
  "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.

```plaintext
{
  "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](https://firescan.jacobalcock.co.uk/).

## Use the Firebase Emulator

Install and run locally:

```plaintext
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

```plaintext
// NEVER DO THIS
match /{document=**} {
  allow read, write: if true;
}
```

## 2\. Relying Only on `request.auth != null`

```plaintext
// 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

```plaintext
{
  "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

```plaintext
// 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

```plaintext
// 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.

```plaintext
// 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:

```plaintext
# Don't ignore rules files
!firestore.rules
!database.rules.json
```

**Example** `firestore.rules`:

```plaintext
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // All your rules here
  }
}
```

**Deploy with Firebase CLI**:

```plaintext
firebase deploy --only firestore:rules
firebase deploy --only database
```

# Deployment Checklist

Before deploying rules to production:

* Remove all `if true` or `if false` test rules
    
* Verify 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()` and `exists()` 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:

```plaintext
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

1. **Default to denying access**. Only grant permissions where specifically needed.
    
2. **Always verify authentication** with `request.auth != null` and check user ownership.
    
3. **Validate data** on create and update operations.
    
4. **Prevent field tampering** by ensuring critical fields don't change on update.
    
5. **Use custom claims** for roles instead of repeated `get()` calls.
    
6. **Test your rules** with the emulator and unit tests before deploying.
    
7. **Version control** your rules and review changes like code.
    
8. **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.
