# How to Test Firestore Security Rules

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Firebase CLI v13+, Node.js 18+, @firebase/rules-unit-testing v3+
- Last updated: March 2026

## TL;DR

To test Firestore security rules, use the @firebase/rules-unit-testing library alongside the Firebase Emulator Suite. Create authenticated and unauthenticated test contexts with initializeTestEnvironment(), then verify rule behavior with assertSucceeds() for operations that should be allowed and assertFails() for operations that should be denied. Write tests for every CRUD operation, every user role, and every edge case to catch permission gaps before deploying to production.

## Testing Firestore Security Rules with Automated Tests

Firestore security rules are your last line of defense against unauthorized data access. Deploying untested rules risks exposing user data or breaking your app. This tutorial covers writing a comprehensive automated test suite that verifies your rules allow the right access patterns and deny everything else, using the @firebase/rules-unit-testing library and the Firebase Emulator Suite.

## Before you start

- Firebase CLI installed and a project initialized with firebase init
- Firestore emulator configured (firebase init emulators, select Firestore)
- A firestore.rules file with rules to test
- Node.js 18+ and a test runner (vitest, jest, or mocha)

## Step-by-step guide

### 1. Install the testing library and set up your test file

Install @firebase/rules-unit-testing as a dev dependency. This package provides the initializeTestEnvironment function that creates a test harness connected to your local Firestore emulator. Import the assertion helpers assertSucceeds and assertFails along with Firestore operations from the firebase/firestore package.

```
# Install dependencies
npm install --save-dev @firebase/rules-unit-testing vitest

# Create test file
# tests/firestore.rules.test.ts
```

**Expected result:** The testing library is installed and ready for your test file.

### 2. Initialize the test environment with your rules file

In your test setup, call initializeTestEnvironment() with your project ID and the path to your firestore.rules file. The function reads your rules and loads them into the Firestore emulator. Use beforeAll to set up the environment once, afterEach to clear data between tests, and afterAll to clean up resources.

```
import {
  initializeTestEnvironment,
  assertSucceeds,
  assertFails,
  RulesTestEnvironment
} from '@firebase/rules-unit-testing';
import { doc, getDoc, setDoc, updateDoc, deleteDoc, collection, getDocs } from 'firebase/firestore';
import { readFileSync } from 'fs';

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: 'rules-test-project',
    firestore: {
      rules: readFileSync('firestore.rules', 'utf8'),
      host: '127.0.0.1',
      port: 8080
    }
  });
});

afterEach(async () => {
  await testEnv.clearFirestore();
});

afterAll(async () => {
  await testEnv.cleanup();
});
```

**Expected result:** The test environment is initialized with your rules loaded into the local Firestore emulator.

### 3. Test authenticated read and write operations

Use testEnv.authenticatedContext('uid') to create a Firestore client that acts as an authenticated user with the specified UID. Write tests that verify authenticated users can perform the operations your rules allow. For each test, create the context, get the Firestore instance, and wrap the operation in assertSucceeds() or assertFails().

```
describe('User profiles', () => {
  test('authenticated user can read their own profile', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertSucceeds(getDoc(doc(db, 'users', 'alice')));
  });

  test('authenticated user cannot read another user profile', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertFails(getDoc(doc(db, 'users', 'bob')));
  });

  test('authenticated user can create their own profile', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertSucceeds(
      setDoc(doc(db, 'users', 'alice'), {
        email: 'alice@test.com',
        displayName: 'Alice',
        role: 'member'
      })
    );
  });

  test('user cannot create profile for another user', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertFails(
      setDoc(doc(db, 'users', 'bob'), {
        email: 'bob@test.com',
        displayName: 'Bob',
        role: 'member'
      })
    );
  });
});
```

**Expected result:** Tests confirm that users can only read and write their own profile documents.

### 4. Test unauthenticated access and data validation

Use testEnv.unauthenticatedContext() to simulate requests without any authentication. These tests verify that your rules properly deny access to unauthenticated users. Also test data validation rules by passing invalid data and verifying the write is denied.

```
describe('Unauthenticated access', () => {
  test('unauthenticated user cannot read any profile', async () => {
    const unauth = testEnv.unauthenticatedContext();
    const db = unauth.firestore();
    await assertFails(getDoc(doc(db, 'users', 'alice')));
  });

  test('unauthenticated user cannot create a post', async () => {
    const unauth = testEnv.unauthenticatedContext();
    const db = unauth.firestore();
    await assertFails(
      setDoc(doc(db, 'posts', 'post-1'), { title: 'Spam' })
    );
  });
});

describe('Data validation', () => {
  test('user cannot set role to admin', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertFails(
      setDoc(doc(db, 'users', 'alice'), {
        email: 'alice@test.com',
        displayName: 'Alice',
        role: 'admin' // Should be denied
      })
    );
  });

  test('post title must be a non-empty string', async () => {
    const alice = testEnv.authenticatedContext('alice');
    const db = alice.firestore();
    await assertFails(
      setDoc(doc(db, 'posts', 'post-1'), {
        title: '', // Empty string should be denied
        authorId: 'alice'
      })
    );
  });
});
```

**Expected result:** Tests confirm that unauthenticated access is denied and data validation rules reject invalid inputs.

### 5. Seed test data for rules that check existing documents

Some rules check existing document data (resource.data) to decide access — for example, only the post author can delete it. To test these rules, you need to seed data before the test. Use testEnv.withSecurityRulesDisabled() to get a Firestore client that bypasses all rules, write the seed data, then run your actual test with rules enabled.

```
describe('Owner-based access', () => {
  test('post author can delete their own post', async () => {
    // Seed a post owned by alice
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore();
      await setDoc(doc(db, 'posts', 'post-1'), {
        title: 'Alice Post',
        authorId: 'alice',
        content: 'Hello'
      });
    });

    // Now test as alice — should be allowed to delete
    const alice = testEnv.authenticatedContext('alice');
    await assertSucceeds(deleteDoc(doc(alice.firestore(), 'posts', 'post-1')));
  });

  test('non-author cannot delete the post', async () => {
    // Seed a post owned by alice
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore();
      await setDoc(doc(db, 'posts', 'post-1'), {
        title: 'Alice Post',
        authorId: 'alice',
        content: 'Hello'
      });
    });

    // Test as bob — should be denied
    const bob = testEnv.authenticatedContext('bob');
    await assertFails(deleteDoc(doc(bob.firestore(), 'posts', 'post-1')));
  });
});
```

**Expected result:** Tests verify that only the document owner can delete it, using seeded data to check existing document rules.

### 6. Test custom claims and role-based rules

If your rules use custom claims (request.auth.token.role), pass claims as the second argument to authenticatedContext(). This simulates a user whose ID token contains the specified custom claims, allowing you to test admin access, editor permissions, and other role-based patterns.

```
describe('Admin access', () => {
  test('admin can read any user profile', async () => {
    const admin = testEnv.authenticatedContext('admin-1', { role: 'admin' });
    const db = admin.firestore();
    await assertSucceeds(getDoc(doc(db, 'users', 'alice')));
    await assertSucceeds(getDoc(doc(db, 'users', 'bob')));
  });

  test('admin can delete any post', async () => {
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      await setDoc(doc(ctx.firestore(), 'posts', 'post-1'), {
        title: 'Test',
        authorId: 'alice'
      });
    });

    const admin = testEnv.authenticatedContext('admin-1', { role: 'admin' });
    await assertSucceeds(deleteDoc(doc(admin.firestore(), 'posts', 'post-1')));
  });

  test('regular user cannot access admin endpoints', async () => {
    const user = testEnv.authenticatedContext('user-1', { role: 'member' });
    const db = user.firestore();
    await assertFails(getDocs(collection(db, 'admin-logs')));
  });
});
```

**Expected result:** Tests confirm that admin users have elevated access while regular users are restricted.

## Complete code example

File: `firestore.rules.test.ts`

```typescript
// Complete Firestore security rules test suite
// Covers user profiles, posts, admin access, and data validation

import {
  initializeTestEnvironment,
  assertSucceeds,
  assertFails,
  RulesTestEnvironment
} from '@firebase/rules-unit-testing';
import {
  doc, getDoc, setDoc, updateDoc, deleteDoc,
  collection, getDocs
} from 'firebase/firestore';
import { readFileSync } from 'fs';

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: 'rules-test',
    firestore: {
      rules: readFileSync('firestore.rules', 'utf8'),
      host: '127.0.0.1',
      port: 8080
    }
  });
});

afterEach(async () => await testEnv.clearFirestore());
afterAll(async () => await testEnv.cleanup());

// Helper to seed data bypassing rules
async function seedDoc(path: string, data: Record<string, unknown>) {
  await testEnv.withSecurityRulesDisabled(async (ctx) => {
    await setDoc(doc(ctx.firestore(), path), data);
  });
}

describe('User profiles', () => {
  test('user reads own profile', async () => {
    const ctx = testEnv.authenticatedContext('u1');
    await assertSucceeds(getDoc(doc(ctx.firestore(), 'users', 'u1')));
  });

  test('user cannot read other profile', async () => {
    const ctx = testEnv.authenticatedContext('u1');
    await assertFails(getDoc(doc(ctx.firestore(), 'users', 'u2')));
  });

  test('user creates own profile', async () => {
    const ctx = testEnv.authenticatedContext('u1');
    await assertSucceeds(setDoc(doc(ctx.firestore(), 'users', 'u1'), {
      email: 'u1@test.com', displayName: 'User 1', role: 'member'
    }));
  });

  test('user cannot escalate role', async () => {
    const ctx = testEnv.authenticatedContext('u1');
    await assertFails(setDoc(doc(ctx.firestore(), 'users', 'u1'), {
      email: 'u1@test.com', displayName: 'User 1', role: 'admin'
    }));
  });
});

describe('Posts', () => {
  test('author can delete own post', async () => {
    await seedDoc('posts/p1', { title: 'Test', authorId: 'u1' });
    const ctx = testEnv.authenticatedContext('u1');
    await assertSucceeds(deleteDoc(doc(ctx.firestore(), 'posts', 'p1')));
  });

  test('non-author cannot delete post', async () => {
    await seedDoc('posts/p1', { title: 'Test', authorId: 'u1' });
    const ctx = testEnv.authenticatedContext('u2');
    await assertFails(deleteDoc(doc(ctx.firestore(), 'posts', 'p1')));
  });
});

describe('Admin', () => {
  test('admin reads any profile', async () => {
    const ctx = testEnv.authenticatedContext('a1', { role: 'admin' });
    await assertSucceeds(getDoc(doc(ctx.firestore(), 'users', 'u1')));
  });
});

describe('Unauthenticated', () => {
  test('no access to profiles', async () => {
    const ctx = testEnv.unauthenticatedContext();
    await assertFails(getDoc(doc(ctx.firestore(), 'users', 'u1')));
  });
});
```

## Common mistakes

- **Only testing positive cases (assertSucceeds) without testing that unauthorized access is properly denied** — undefined Fix: Write an assertFails test for every assertSucceeds test. If your rules allow authenticated users to read their own profile, also test that they cannot read someone else's profile.
- **Testing rules against production Firestore instead of the emulator, consuming free-tier quota and risking data changes** — undefined Fix: Always run tests against the local emulator. Use initializeTestEnvironment with host: '127.0.0.1' and port: 8080 to connect to the Firestore emulator.
- **Not testing rules that check existing document data (resource.data) because the test documents do not exist yet** — undefined Fix: Use testEnv.withSecurityRulesDisabled() to seed documents before the test, then test the actual operation with rules enabled.
- **Using the same test data across tests without clearing, causing tests to pass or fail based on execution order** — undefined Fix: Call testEnv.clearFirestore() in afterEach to reset all data between tests. This ensures complete test isolation.

## Best practices

- Test every rule with both assertSucceeds (should allow) and assertFails (should deny) for complete coverage
- Seed test data with withSecurityRulesDisabled when testing rules that check existing document values
- Use descriptive test names that state the expected behavior: 'authenticated user can read own profile'
- Clear Firestore data between tests with testEnv.clearFirestore() in afterEach for isolation
- Test custom claims by passing them as the second argument to authenticatedContext()
- Run rules tests in CI/CD using firebase emulators:exec to catch rule regressions
- Create a helper function for seeding data to reduce boilerplate across tests
- Test update operations separately from create operations since they may have different rules

## Frequently asked questions

### Do I need a real Firebase project to run rules tests?

No. The Firestore emulator runs entirely locally. You can use any string as the projectId in initializeTestEnvironment. No network connection or real Firebase project is required.

### How do I run rules tests in CI/CD?

Use firebase emulators:exec which starts the emulators, runs your test command, and shuts down automatically. Example: firebase emulators:exec 'npx vitest run tests/firestore.rules.test.ts'.

### Can I test Firestore rules without the emulator?

Not with the @firebase/rules-unit-testing library, which requires the emulator. The Firebase Console has a Rules Playground for manual testing, but it runs against production and does not support automated tests.

### How do I test list operations (getDocs on a collection)?

Create a test that calls getDocs on a collection query and wrap it in assertSucceeds or assertFails. Remember that Firestore rules evaluate list access separately from get access.

### What happens if my rules have a syntax error?

initializeTestEnvironment will throw an error when loading the rules file. Fix the syntax error in your firestore.rules file and re-run the tests.

### How do I test rules for subcollections?

Seed the parent document first with withSecurityRulesDisabled, then test CRUD operations on the subcollection path. The path format is collection/docId/subcollection/subDocId.

### Can RapidDev help write and maintain Firestore security rules tests?

Yes. RapidDev can create comprehensive security rules test suites, integrate them into your CI/CD pipeline, and help design rules that balance security with your application's access patterns.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-test-firestore-security-rules
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-test-firestore-security-rules
