Firestore security rules: the production audit every app owes its users

By Fatima Al-Rashid · 21 July 2026132 views
Firestore security rules: the production audit every app owes its users

A developer ships a Firebase application with security rules she is confident in. She tested them. They pass. Eighteen months later, the application has grown: new collections, new user roles, a team feature, and a data export function. The security rules have been updated incrementally — new rules added for new collections, but never reviewed holistically.

A penetration tester finds that authenticated users can read any document in the team-data collection regardless of which team they belong to. The rule was written for a collection that was initially team-scoped but then repurposed — and the rule was never updated.

The rule that existed on launch day was correct for the collection's initial purpose. It became incorrect when the collection's purpose changed. Nobody caught it because the change happened in application code, not in the security rules file. The security rules were not wrong when written; they were not updated when they should have been.

This is the pattern that penetration testers find repeatedly in Firebase applications. The security model that was correct at launch drifts as the application evolves.

Why Firestore security rules accumulate debt faster than other security controls

Firestore security rules are a single file that governs all database access. Unlike server-side authorization code, where access control logic lives close to the feature code and is reviewed together with feature changes, security rules are a separate artifact that developers consult only when rules fail or when explicitly reviewing security.

The result is one-directional updates: new collections get new rules when they are created. Existing rules are rarely updated when the collections they protect are repurposed or when the application's access model changes. Over time, the rules file becomes a historical record of access patterns as they were designed, not as they are now.

A systematic audit is not a one-time event. It is a scheduled review that should accompany major features that touch access patterns.

The audit structure

Step 1: Map what exists vs. what is protected

The first question in an audit is whether every collection has explicit rules. Firestore's default is to deny everything — but allow read, write: if true; (explicitly open) is worse than a missing rule.

# List all collections in Firestore (requires Admin SDK or Console)
firebase firestore:indexes --project my-project
# Note: This shows indexes but not collections directly

# Use Admin SDK to list all top-level collections:
# scripts/list-collections.ts
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

const db = getFirestore(initializeApp({ credential: cert('./service-account.json') }));
const collections = await db.listCollections();
console.log(collections.map(c => c.id));

Compare the collection list against the security rules file. Every collection should have explicit rules. Missing collections inherit the default (deny), which is safe — but the omission should be intentional.

Produce a coverage matrix: collections on one axis, access operations (read, write, create, update, delete, list) on the other. For each cell, document whether access is allowed, denied, or conditional. This matrix is the starting point for the audit — it makes gaps visible at a glance.

Step 2: Test every access pattern that should be denied

The rules testing tool (@firebase/rules-unit-testing) should test every deny case, not just allow cases:

import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
import { readFileSync } from 'fs';

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

describe('Team data access control', () => {
  // Set up test data
  beforeAll(async () => {
    const admin = testEnv.unauthenticatedContext();
    await admin.firestore().doc('teams/team-a').set({ name: 'Team A' });
    await admin.firestore().doc('team-data/doc-from-team-a').set({
      teamId: 'team-a',
      content: 'Private content'
    });
  });

  // DENY cases — these MUST fail
  test('unauthenticated user cannot read team data', async () => {
    const unauth = testEnv.unauthenticatedContext();
    await assertFails(
      unauth.firestore().doc('team-data/doc-from-team-a').get()
    );
  });

  test('user from team-b cannot read team-a data', async () => {
    const teamBUser = testEnv.authenticatedContext('user-from-team-b', {
      teamId: 'team-b'  // Custom claim
    });
    await assertFails(
      teamBUser.firestore().doc('team-data/doc-from-team-a').get()
    );
  });

  // ALLOW cases — these MUST succeed
  test('user from team-a can read team-a data', async () => {
    const teamAUser = testEnv.authenticatedContext('user-from-team-a', {
      teamId: 'team-a'
    });
    await assertSucceeds(
      teamAUser.firestore().doc('team-data/doc-from-team-a').get()
    );
  });
});

The test coverage target for security rules is different from application logic. For application logic, 80% coverage is often acceptable. For security rules, every explicitly-allowed access pattern should have a corresponding test, and every access pattern that should be denied should have a deny test. Missing deny tests are the gaps that penetration testers find.

Step 3: Check write rules for field-level validation

Write rules that validate only authentication (who is writing) but not content (what they are writing) are incomplete:

// INCOMPLETE: Validates who, not what
match /users/{userId} {
  allow update: if request.auth.uid == userId;
  // A user can add isAdmin: true to their own document
}

// COMPLETE: Validates both who and what
match /users/{userId} {
  allow update: if request.auth.uid == userId
    // Only allow updating specific user-modifiable fields
    && request.resource.data.keys().hasOnly(['displayName', 'photoURL', 'bio', 'settings'])
    // Cannot change server-set fields
    && request.resource.data.createdAt == resource.data.createdAt
    && request.resource.data.role == resource.data.role
    && request.resource.data.subscription == resource.data.subscription;
}

Field-level write validation is particularly important for documents that mix user-editable and server-set fields. Without it, a user can write any field they want to their own document, including fields that control their role, subscription tier, or administrative access.

The test for field-level validation:

test('user cannot escalate their own role', async () => {
  const user = testEnv.authenticatedContext('user-123');
  await assertFails(
    user.firestore().doc('users/user-123').update({
      role: 'admin',  // Should not be user-updatable
    })
  );
});

test('user can update their display name', async () => {
  const user = testEnv.authenticatedContext('user-123');
  await assertSucceeds(
    user.firestore().doc('users/user-123').update({
      displayName: 'New Name',
    })
  );
});

Step 4: Review get() calls for cost and correctness

get() calls in security rules perform additional Firestore reads. Review every get() call for:

  1. Is the referenced document guaranteed to exist?
  2. Is there a way to avoid the read by denormalizing the data?
// EXPENSIVE: get() on every read of team-data
match /team-data/{docId} {
  allow read: if request.auth != null
    && get(/databases/$(database)/documents/userTeams/$(request.auth.uid)).data.teamId
       == resource.data.teamId;
}

// CHEAPER: Use a custom claim for teamId (no additional read)
// (Set the claim via Cloud Function on user creation/team assignment)
match /team-data/{docId} {
  allow read: if request.auth != null
    && request.auth.token.teamId == resource.data.teamId;
}

Custom claims require a Cloud Function to set them on the user's JWT:

// Cloud Function — set teamId claim when user joins a team
export const onTeamMemberCreated = onDocumentCreated('teams/{teamId}/members/{userId}', async (event) => {
  const { teamId, userId } = event.params;

  await getAuth().setCustomUserClaims(userId, {
    teamId,
    joinedAt: new Date().toISOString(),
  });
});

The claim is then immediately available in security rules as request.auth.token.teamId. This eliminates the get() call and reduces Firestore reads on every access — important at scale where security rule reads count against quotas.

Step 5: Verify subcollection rules are not bypassed by collection group queries

Collection group queries access all subcollections with the same name across all parent documents. A rule that limits access to subcollections by parent document must be verified against collection group queries:

// POTENTIAL GAP: This rule protects individual document reads
match /teams/{teamId}/members/{memberId} {
  allow read: if request.auth != null
    && request.auth.token.teamId == teamId;
}

// But a collection group query:
// db.collectionGroup('members').get()
// Also goes through this rule — the teamId wildcard is matched correctly
// Verify this by testing collection group access with a user from a different team
test('user cannot read other teams\' members via collection group query', async () => {
  const teamBUser = testEnv.authenticatedContext('user-b', { teamId: 'team-b' });

  // Collection group query — should only return team-b members
  const result = await teamBUser.firestore()
    .collectionGroup('members')
    .get();

  // Every returned document should be from team-b
  result.docs.forEach(doc => {
    // If this assertion fails, the rule has a gap
    expect(doc.ref.parent.parent!.id).toBe('team-b');
  });
});

Step 6: Audit for time-based and state-based access patterns

Security rules can reference server-side timestamps and document state. Verify that these patterns are correctly implemented:

// Pattern: Users can only read documents within 30 days of creation
match /reports/{reportId} {
  allow read: if request.auth != null
    && request.auth.uid == resource.data.ownerId
    && request.time < resource.data.createdAt + duration.value(30, 'd');
}

Test the boundary conditions:

test('report is readable within 30 days', async () => {
  const user = testEnv.authenticatedContext('user-123');
  // Set createdAt to 15 days ago
  const fifteenDaysAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000);
  await testEnv.unauthenticatedContext().firestore().doc('reports/recent').set({
    ownerId: 'user-123',
    createdAt: fifteenDaysAgo,
  });

  await assertSucceeds(user.firestore().doc('reports/recent').get());
});

test('report is not readable after 30 days', async () => {
  const user = testEnv.authenticatedContext('user-123');
  // Set createdAt to 31 days ago
  const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
  await testEnv.unauthenticatedContext().firestore().doc('reports/expired').set({
    ownerId: 'user-123',
    createdAt: thirtyOneDaysAgo,
  });

  await assertFails(user.firestore().doc('reports/expired').get());
});

Embedding the audit in the development process

The penetration tester found the team-data gap because the team that expanded the collection repurposed it without reviewing the security rules. Adding a security rule review step to the PR template for any change that touches collections, adds new user roles, or modifies access patterns — not just changes to the rules file itself — creates the systematic review that protects against this category of oversight.

A simple checklist for the PR template:

## Security Rules Review (check if applicable)

Does this PR:
- [ ] Add or modify a Firestore collection or document structure?
- [ ] Add a new user role or permission level?
- [ ] Change who can read or write existing data?
- [ ] Add a new user type (team member, admin, guest)?

If any box is checked: review `firestore.rules` and add tests for:
- What the new user/role can access
- What the new user/role cannot access
- Whether existing rules still correctly handle the changed data structure

The audit itself should be scheduled — not as a reaction to a security report, but as a quarterly practice. The 30-minute investment in reviewing the coverage matrix and running the security rules emulator against the current rule file is far cheaper than a post-incident investigation of a data exposure.

Common Firestore security rule antipatterns

Overly broad write rules for admin functions.

Server-side admin operations are often implemented as Cloud Functions, which use the Admin SDK and bypass security rules entirely. When a developer needs a collection to be writable by admin users, the temptation is to add an admin check to the security rule. The correct approach for operations that should only be performed server-side is to keep the rule restrictive and perform the operation in a Cloud Function:

// WRONG: Opens the collection to direct client writes with an admin check
// Admins can also write from the client, which bypasses server-side validation
match /admin-actions/{docId} {
  allow write: if request.auth.token.role == 'admin';
}

// CORRECT: Lock the collection entirely; perform admin writes in a Cloud Function
match /admin-actions/{docId} {
  allow read, write: if false;  // No client writes — admin SDK only
}

The Cloud Function that writes to admin-actions uses the Admin SDK, which bypasses the allow write: if false rule. This ensures all writes go through the server-side validation logic, not a client-callable path.

Missing authentication check at the top level.

A security rule that checks for team membership without first checking authentication:

// VULNERABLE: If request.auth is null, request.auth.token.teamId throws
match /team-data/{docId} {
  allow read: if request.auth.token.teamId == resource.data.teamId;
}

// CORRECT: Check authentication before accessing token claims
match /team-data/{docId} {
  allow read: if request.auth != null
    && request.auth.token.teamId == resource.data.teamId;
}

Firestore evaluates rules sequentially. If request.auth is null and the rule accesses request.auth.token, the rule throws a runtime error. The error causes the rule to evaluate as false (deny), which is actually safe behavior — but it hides the missing authentication check and makes debugging confusing.

Using exists() when get() is needed.

exists() checks whether a document exists; get() retrieves the document data. Confusing them produces rules that compile but do not enforce the intended restriction:

// WRONG: Checks if the user document exists, not whether they have the claim
match /premium-content/{docId} {
  allow read: if request.auth != null
    && exists(/databases/$(database)/documents/users/$(request.auth.uid));
    // Any authenticated user passes this — it only checks that the user doc exists
}

// CORRECT: Retrieve the document and check the subscription field
match /premium-content/{docId} {
  allow read: if request.auth != null
    && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.subscription == 'premium';
}

The tests for these cases should verify that users without the required subscription are denied, not just that the rule can be applied without error.

Running the security rules emulator in CI

The Firebase Local Emulator Suite runs locally and in CI, allowing security rules tests to run without a live Firestore project:

# .github/workflows/security-rules.yml
name: Firestore Security Rules Tests

on:
  push:
    paths:
      - 'firestore.rules'
      - 'src/__tests__/security-rules/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Install Firebase CLI
        run: npm install -g firebase-tools

      - name: Run security rules tests
        run: |
          firebase emulators:exec \
            --only firestore \
            --project test-project \
            "npx jest src/__tests__/security-rules --forceExit"
        env:
          FIRESTORE_EMULATOR_HOST: localhost:8080

Running security rules tests in CI on every change to firestore.rules catches regressions immediately. A change that opens an unintended access path fails CI before it is deployed.

The emulator also supports a coverage report — a map of which rules paths were exercised by the test suite. Rules that are never exercised are either dead code (the collection no longer exists) or untested code (a real collection with no test coverage):

// After running tests, get the coverage report
const coverageReport = testEnv.coverageReportUrl;
console.log(`Coverage report: ${coverageReport}`);
// Open in browser to see which rules lines were not covered

Zero untested rule lines is the target. Any rule line that is never evaluated by the test suite is a potential security gap.

Security rules are one of the few places where the default (deny all) is the safe fallback. Unlike most application code, where bugs cause incorrect behavior, security rules bugs cause data exposure — which is invisible until discovered by an attacker or penetration tester. The cost asymmetry justifies a higher investment in testing than most other code receives. Every collection, every access pattern, every edge case in the access model should have a corresponding test. A rules file that is 100 lines and has 50 test cases is undertested; 150 test cases is a more appropriate target, covering every allow and deny case explicitly.

The developer who shipped with a gap in the team-data rules had tested what she thought needed testing. The gap was in what she did not think to test: the access pattern of authenticated users from a different team. Property-based testing and systematic coverage matrices exist to surface exactly this kind of oversight — not because the developer was careless, but because the space of access patterns is large and human intuition reliably misses edge cases.

Comments

No comments yet. Be the first!

Sign in to leave a comment.