DEV Community

Jacob Alcock
Jacob Alcock

Posted on

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>;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Realtime Database Rules Structure

{
  "rules": {
    "path": {
      ".read": "<condition>",
      ".write": "<condition>"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Realtime Database:

{
  "rules": {
    "users": {
      "$userId": {
        ".read": "$userId === auth.uid",
        ".write": "$userId === auth.uid"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Realtime Database:

{
  "rules": {
    "posts": {
      "$postId": {
        ".read": true,
        ".write": "auth != null && (!data.exists() || data.child('authorId').val() === auth.uid)"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Realtime Database:

{
  "rules": {
    "adminData": {
      ".read": "auth.token.admin === true",
      ".write": "auth.token.admin === true"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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())"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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()"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Correct approach: Don't grant broad access at parent levels.

{
  "rules": {
    "users": {
      "$userId": {
        ".read": "$userId === auth.uid",
        ".write": "$userId === auth.uid"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example firestore.rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // All your rules here
  }
}
Enter fullscreen mode Exit fullscreen mode

Deploy with Firebase CLI:

firebase deploy --only firestore:rules
firebase deploy --only database
Enter fullscreen mode Exit fullscreen mode

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();
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)