Skip to main content

Command Palette

Search for a command to run...

How to Write Secure Firebase Rules

Step-by-step guide to writing secure Firebase Security Rules for Cloud Firestore and Realtime Database with real code examples and best practices.

Updated
9 min read
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

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) 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

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

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.

Secure Firebase Rules Guide