Setting Up Role-Based Access in Firebase

When I first built an app with Firebase, I made a rookie mistake I stored user roles inside their own documents. A friend pointed out that users could technically modify their own roles if they knew how. That wake-up call sent me down a rabbit hole of Firebase Security Rules. Here is what I figured out about implementing proper role-based access control.

The Mistake That Started It All

My initial data structure looked like this:

// users/{userId}
{
  name: "John Doe",
  email: "john@example.com",
  roles: ["user", "editor"]  // Anyone could edit this!
}

I thought I was being clever by storing everything in one place. Then my friend showed me how easy it was to manipulate the client-side code and grant himself admin privileges. Not good.

Separating Roles from User Data

After some research, I restructured my database. Now I have two collections:

// users/{userId} - Public user data
{
  name: "John Doe",
  email: "john@example.com",
  createdAt: timestamp
}

// roles/{userId} - Private role assignments (only admins can write)
{
  roles: ["user", "editor"],
  updatedAt: timestamp,
  updatedBy: "adminUserId"
}

When roles live inside the user document, anyone with document access can manipulate their own roles. Separating roles into their own collection with stricter rules means only admins can modify role assignments:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ff6b6b', 'primaryTextColor': '#fff', 'primaryBorderColor': '#c92a2a', 'lineColor': '#495057', 'secondaryColor': '#51cf66', 'tertiaryColor': '#fff'}}}%%
flowchart TB
    subgraph INSECURE["Insecure: Roles in User Document"]
        U1[users/john]
        U1 -->|contains| R1["roles: [user, editor]"]
        U1 -->|contains| D1[name, email, etc]
        H1[Hacker] -->|modifies client code| R1
    end
    
    subgraph SECURE["Secure: Roles Separated"]
        U2[users/john] --> D2[public profile data]
        R2[roles/john] --> R2D["roles: [user, editor]"]
        H2[Hacker] -.->|blocked by rules| R2
        A2[Admin Only] -->|can write| R2
    end
    
    INSECURE -.->|Restructure| SECURE
    
    style INSECURE fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px
    style SECURE fill:#d3f9d8,stroke:#2b8a3e,stroke-width:2px
    style R1 fill:#ff8787,stroke:#c92a2a
    style R2 fill:#69db7c,stroke:#2b8a3e

Users can update their profile info, but they cannot touch their roles because those live in a completely different collection with stricter rules.

My Complete Security Rules

Each request flows through the rules engine like this:

sequenceDiagram
    autonumber
    participant Client as Client App
    participant SDK as Firebase SDK
    participant Rules as Firestore Rules
    participant DB as Firestore DB
    
    Client->>SDK: db.collection('posts').doc('123').get()
    SDK->>Rules: Request: read /posts/123
    activate Rules
    
    Note over Rules: Evaluate conditions:
    Note over Rules: isLoggedIn()? ✓
    Note over Rules: resource.data.status == "published"? ✓
    
    alt Condition Passes
        Rules->>DB: Allow read operation
        DB->>SDK: Return document data
        SDK->>Client: Promise resolves with data
    else Condition Fails
        Rules->>SDK: Deny with 'Missing or insufficient permissions'
        SDK->>Client: Promise rejects with error
    end
    deactivate Rules

Here is the firestore.rules file I ended up with after a few iterations:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    
    // Helper function: Check if user is authenticated
    function isLoggedIn() {
      return request.auth != null;
    }
    
    // Helper function: Check if user has any of the required roles
    function hasAnyRole(requiredRoles) {
      return isLoggedIn() && 
        get(/databases/$(database)/documents/roles/$(request.auth.uid)).data.roles.hasAny(requiredRoles);
    }
    
    // Helper function: Check if user is admin
    function isAdmin() {
      return hasAnyRole(["admin"]);
    }
    
    // Helper function: Check if user owns the document
    function isOwner(userId) {
      return isLoggedIn() && request.auth.uid == userId;
    }
    
    // Users collection rules
    match /users/{userId} {
      // Anyone can read user profiles
      allow read: if isLoggedIn();
      
      // Users can create their own document on signup
      allow create: if isOwner(userId) && 
        request.resource.data.keys().hasAll(["name", "email", "createdAt"]);
      
      // Users can update only their own profile, and only specific fields
      allow update: if isOwner(userId) && 
        request.resource.data.diff(resource.data).affectedKeys().hasOnly(["name", "email", "avatar"]);
      
      // Only admins can delete users
      allow delete: if isAdmin();
    }
    
    // Roles collection - The sensitive stuff
    match /roles/{userId} {
      // Only admins can read roles (keep them private)
      allow read: if isAdmin() || isOwner(userId);
      
      // Only admins can create, update, or delete roles
      allow write: if isAdmin();
    }
    
    // Posts collection - Example content
    match /posts/{postId} {
      function isValidPost() {
        return request.resource.data.keys().hasAll(["title", "content", "authorId", "status", "createdAt"]) &&
          request.resource.data.authorId == request.auth.uid &&
          request.resource.data.status in ["draft", "published"];
      }
      
      function isValidUpdate() {
        let affectedKeys = request.resource.data.diff(resource.data).affectedKeys();
        return affectedKeys.hasOnly(["title", "content", "status", "updatedAt"]) &&
          (resource.data.authorId == request.auth.uid || isAdmin());
      }
      
      // Anyone can read published posts, admins can read all
      allow read: if resource.data.status == "published" || isAdmin();
      
      // Authenticated users can create posts if valid
      allow create: if isLoggedIn() && isValidPost();
      
      // Authors can update their own posts, editors can update any, admins can do everything
      allow update: if isValidUpdate() && 
        (resource.data.authorId == request.auth.uid || hasAnyRole(["editor", "admin"]));
      
      // Only admins or the author can delete
      allow delete: if isAdmin() || resource.data.authorId == request.auth.uid;
    }
  }
}

These helper functions cascade down through the collections:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#339af0', 'primaryTextColor': '#fff', 'primaryBorderColor': '#1971c2', 'lineColor': '#495057', 'secondaryColor': '#ffd43b', 'tertiaryColor': '#fff'}}}%%
flowchart TB
    subgraph HELPER["Helper Functions"]
        F1["isLoggedIn()<br/>request.auth != null"]
        F2["isOwner(userId)<br/>request.auth.uid == userId"]
        F3["hasAnyRole(roles)<br/>get(/roles/$(uid)).data.roles.hasAny(roles)"]
        F4["isAdmin()<br/>hasAnyRole(['admin'])"]
    end
    
    subgraph USERS_COL["/users/{userId}"]
        U_READ["read: isLoggedIn()"]
        U_CREATE["create: isOwner(userId)"]
        U_UPDATE["update: isOwner(userId)"]
        U_DELETE["delete: isAdmin()"]
    end
    
    subgraph ROLES_COL["/roles/{userId}"]
        R_READ["read: isAdmin() OR isOwner(userId)"]
        R_WRITE["write: isAdmin()"]
    end
    
    subgraph POSTS_COL["/posts/{postId}"]
        P_READ["read: isPublished OR isAdmin()"]
        P_CREATE["create: isLoggedIn()"]
        P_UPDATE["update: isAuthor OR hasAnyRole(['editor','admin'])"]
        P_DELETE["delete: isAuthor OR isAdmin()"]
    end
    
    F1 --> U_READ
    F2 --> U_CREATE
    F2 --> U_UPDATE
    F4 --> U_DELETE
    
    F4 --> R_READ
    F2 -.-> R_READ
    F4 --> R_WRITE
    
    F1 -.-> P_READ
    F4 -.-> P_READ
    F1 --> P_CREATE
    F2 -.-> P_UPDATE
    F3 -.-> P_UPDATE
    F2 -.-> P_DELETE
    F4 -.-> P_DELETE
    
    style F4 fill:#fa5252,stroke:#c92a2a,color:#fff
    style F3 fill:#ffd43b,stroke:#f08c00
    style F2 fill:#69db7c,stroke:#2b8a3e
    style F1 fill:#339af0,stroke:#1971c2,color:#fff

Legend:

  • 🔴 isAdmin() - Most restrictive, admin-only operations
  • 🟡 hasAnyRole() - Role-based access for editors/moderators
  • 🟢 isOwner() - User owns the resource
  • 🔵 isLoggedIn() - Any authenticated user

This breaks down to the following permissions matrix:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#339af0', 'primaryTextColor': '#fff', 'primaryBorderColor': '#1971c2', 'lineColor': '#495057', 'secondaryColor': '#51cf66', 'tertiaryColor': '#fff'}}}%%
flowchart LR
    subgraph LEGEND["Legend"]
        A[✅ Allowed]
        D[❌ Denied]
    end
    
    subgraph USERS_COL["users/{userId}"]
        direction TB
        U1["Read"]
        U2["Create own"]
        U3["Update own"]
        U4["Update other"]
        U5["Delete"]
    end
    
    subgraph ANON["Anonymous"]
        direction TB
        AN1[❌]
        AN2[❌]
        AN3[❌]
        AN4[❌]
        AN5[❌]
    end
    
    subgraph USER["Regular User"]
        direction TB
        RU1[✅]
        RU2[✅]
        RU3[✅]
        RU4[❌]
        RU5[❌]
    end
    
    subgraph EDITOR["Editor"]
        direction TB
        ED1[✅]
        ED2[✅]
        ED3[✅]
        ED4[❌]
        ED5[❌]
    end
    
    subgraph ADMIN["Admin"]
        direction TB
        AD1[✅]
        AD2[✅]
        AD3[✅]
        AD4[✅]
        AD5[✅]
    end
    
    USERS_COL --- ANON
    USERS_COL --- USER
    USERS_COL --- EDITOR
    USERS_COL --- ADMIN
    
    style RU4 fill:#ffe3e3,stroke:#c92a2a
    style RU5 fill:#ffe3e3,stroke:#c92a2a
    style ED4 fill:#ffe3e3,stroke:#c92a2a
    style ED5 fill:#ffe3e3,stroke:#c92a2a
CollectionOperationAnonymousUser (Self)User (Other)EditorAdmin
usersRead
Create✅ (own)✅ (any)
Update✅ (own)✅ (any)
Delete
rolesRead✅ (own only)✅ (own only)
Write
postsRead published
Read drafts✅ (own)
Create
Update✅ (own)✅ (any)
Delete✅ (own)

Real-World Usage Examples

Here is how I use these rules in my Flutter app:

// Fetching user profile (any logged-in user can do this)
Future<UserProfile> getUserProfile(String userId) async {
  final doc = await FirebaseFirestore.instance
      .collection('users')
      .doc(userId)
      .get();
  return UserProfile.fromFirestore(doc);
}

// Creating a post (respects the create rules)
Future<void> createPost(String title, String content) async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) throw Exception('Not authenticated');
  
  await FirebaseFirestore.instance.collection('posts').add({
    'title': title,
    'content': content,
    'authorId': user.uid,
    'status': 'draft',
    'createdAt': FieldValue.serverTimestamp(),
  });
}

// Admin assigning a role (only admins can write to /roles)
Future<void> assignRole(String userId, List<String> roles) async {
  final adminId = FirebaseAuth.instance.currentUser?.uid;
  
  await FirebaseFirestore.instance
      .collection('roles')
      .doc(userId)
      .set({
        'roles': roles,
        'updatedAt': FieldValue.serverTimestamp(),
        'updatedBy': adminId,
      });
}

Testing Your Rules

I learned the hard way that you should test your security rules. Firebase provides the Rules Playground in the console, but I also use the emulator suite:

// test.rules.js
const { initializeTestEnvironment, assertFails, assertSucceeds } = require('@firebase/rules-unit-testing');

describe('Firestore security rules', () => {
  let testEnv;
  
  beforeAll(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: 'my-test-project',
      firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') }
    });
  });
  
  it('should not let users read other users roles', async () => {
    const unauthenticated = testEnv.unauthenticatedContext();
    
    await assertFails(
      getDoc(doc(unauthenticated.firestore(), 'roles', 'someUserId'))
    );
  });
  
  it('should let admins assign roles', async () => {
    const admin = testEnv.authenticatedContext('adminId', { roles: ['admin'] });
    
    await assertSucceeds(
      setDoc(doc(admin.firestore(), 'roles', 'newUser'), {
        roles: ['editor'],
        updatedAt: new Date(),
        updatedBy: 'adminId'
      })
    );
  });
});

Common Pitfalls I Hit

  1. Forgetting about list queries: Even if a document is protected, listing a collection might leak existence. Always add rules for list operations too.

  2. Time-based fields: I initially used DateTime.now() client-side, but users could manipulate their system clock. Now I always use FieldValue.serverTimestamp().

  3. Recursive reads: The hasAnyRole() function does a get() to the roles collection. Be careful if you call this in a loop or on many documents, you might hit the rule evaluation limits.

  4. Missing validation: Just checking if a user is an admin is not enough. Always validate the actual data being written with functions like isValidPost().

The Admin Creation Problem

One tricky question: how do you create the first admin? You cannot assign a role without an admin, but you need an admin to assign roles. Here is what I did:

// Special rule for initial setup (remove after first admin created)
match /roles/{userId} {
  allow create: if isLoggedIn() && 
    !exists(/databases/$(database)/documents/roles) && 
    request.resource.data.roles == ["admin"];
  allow write: if isAdmin();
}

This lets the first user claim admin privileges if no roles exist yet. I deployed the app, created my admin account, then removed that rule.

Conclusion

Implementing RBAC in Firestore felt overwhelming at first, but breaking it down into small helper functions made it manageable. The key lessons for me were:

  • Separate sensitive data (roles) from public data (profiles)
  • Write comprehensive validation functions
  • Test your rules before deploying
  • Start with the simplest rules that work, then add complexity

Now I sleep better knowing my users cannot just grant themselves admin access. The peace of mind is worth the initial setup time.

Random Fact

The 'QWERTY' keyboard layout was designed to slow down typing to prevent typewriter jams.