A Step-by-Step Guide to Writing Secure Firestore Rules

daniel@flamesshield.com
4 min read
A Step-by-Step Guide to Writing Secure Firestore Rules

A Step-by-Step Guide to Writing Secure Firestore Rules

Firestore security rules are your primary defense against malicious data manipulation and unauthorised access. They live on Google’s servers and are enforced on every single request, making them a non-negotiable part of a secure application. By defining and enforcing a strict schema, you prevent data corruption, stop unexpected app behavior, and close potential security holes.

Never trust the client. Always assume a user will try to bypass your app’s UI and manipulate data directly. This guide provides a step-by-step process for building robust rules.

Step 1: Inventory Your Collections πŸ—ΊοΈ

You can’t secure what you don’t know exists. The first step is to get a complete picture of your data model by listing every single collection in your Firestore database. This creates a clear checklist and ensures no part of your database is left unsecured.

Example:

  • users
  • projects
  • tasks
  • projectMemberships
  • appSettings

πŸ’‘ Tip: You can store document-level information (like ownerId, timestamps, or role assignments) directly in documents themselves. This avoids overcomplicating your rules and makes ownership checks much cleaner.

Step 2: Define Your User Roles 🎭

Next, think about who will be accessing your data. These aren’t just individual users, but types or roles of users with different permission levels.

Common Roles:

  • Unauthenticated User: Someone not logged in.
  • Authenticated User: Any user who is signed in.
  • Resource Owner: The user who created a specific document (e.g., the owner of a project).
  • Admin: A user with special privileges, often defined by a custom claim or a field in their users document.
  • Team Member: A user who is part of a group and has permissions within that group’s resources.

Step 3: Write Reusable Helper Functions πŸ› οΈ

To keep your rules clean, readable, and easy to maintain (DRY - Don’t Repeat Yourself), use functions for common checks. The most common checks are for authentication status and user roles.

Example:

Step 4: Define Field-Level Access πŸ”¬

Now, get granular. For each collection, create a table that specifies who can read and write each individual field. This becomes your high-level permission plan.

Example: projects Collection Schema

Field NameData TypeWho can READ?Who can UPDATE?
namestringProject MembersProject Owner
ownerIdstringProject MembersNo one (immutable)
createdAttimestampProject MembersNo one (immutable)
isArchivedboolProject MembersProject Owner, Admin

Step 5: Enforce Your Schema πŸ“

Translate your schema plan into concrete validation rules. This is your defense against a user trying to add an unauthorized field like isAdmin: true to their profile or writing data in the wrong format.

Key Validation Techniques:

  • Check Data Types: Use the is keyword to enforce types.

    • request.resource.data.name is string
    • request.resource.data.memberCount is number
  • Validate Content: Check the actual value of the data.

    • String size: request.resource.data.name.size() > 0 && request.resource.data.name.size() < 50
    • Number range: request.resource.data.priority >= 1 && request.resource.data.priority <= 5
    • Specific values: request.resource.data.status in ['pending', 'active', 'complete']
  • Enforce Structure: Ensure no unexpected fields are added. This is critically important.

    • Use .keys().hasOnly() to specify the exact set of allowed fields.
    • request.resource.data.keys().hasOnly(['name', 'ownerId', 'createdAt', 'isArchived'])
  • Confirm Immutability: For fields that should never change after creation, verify the “after” value matches the “before” value on updates. The resource variable holds the data before the update.

    • request.resource.data.createdAt == resource.data.createdAt

Step 6: Secure Actions & Ask “What If?” πŸ•΅οΈ

Finally, combine your role functions (who), field access (what), and schema enforcement (how) into rules for each action (get, list, create, update, delete). For every rule, think like an attacker.

  • create Rule:

    • Rule: A user can create a project if they are signed in, the data matches the schema exactly, and they set themselves as the owner.
    • What if? What if they add an extra field isPaid: true? The .hasOnly() check will block it.
    • What if? What if they set name to a number? The is string check will block it.
  • update Rule:

    • Rule: A user can update a project only if they are the owner, the fields they are changing are valid, and they aren’t changing immutable fields like ownerId.
    • What if? What if an owner tries to change the createdAt timestamp? The immutability check (request.resource.data.createdAt == resource.data.createdAt) will block it.

⚠️ Note: Using get() or exists() inside your rules counts as a document read. This can impact both performance and billing if used heavily, so try to minimise cross-document lookups when designing your security model.

Putting It All Together: A Final Rule Example

This example for the projects collection now includes strict schema validation.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
  
    function isSignedIn() {
      return request.auth != null;
    }

    function isProjectOwner(projectId) {
      return exists(/databases/$(database)/documents/projects/$(projectId)) &&
             get(/databases/$(database)/documents/projects/$(projectId)).data.ownerId == request.auth.uid;
    }

    match /projects/{projectId} {
      allow get: if isProjectOwner(projectId);
      
      allow create: if isSignedIn()
                      && request.resource.data.ownerId == request.auth.uid
                      && request.resource.data.keys().hasOnly(['name', 'ownerId', 'createdAt', 'isArchived'])
                      && request.resource.data.name is string
                      && request.resource.data.name.size() > 0 && request.resource.data.name.size() < 50
                      && request.resource.data.createdAt == request.time
                      && request.resource.data.isArchived == false;

      allow update: if isProjectOwner(projectId)
                      && request.resource.data.name is string
                      && request.resource.data.isArchived is bool
                      && request.resource.data.ownerId == resource.data.ownerId
                      && request.resource.data.createdAt == resource.data.createdAt;

      allow delete: if isProjectOwner(projectId);
    }
  }
}

Protect your Firebase apps

Secure your Firebase applications today