Setting Up Role-Based Access in Firebase
Contents
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
| Collection | Operation | Anonymous | User (Self) | User (Other) | Editor | Admin |
|---|---|---|---|---|---|---|
| users | Read | ❌ | ✅ | ✅ | ✅ | ✅ |
| Create | ❌ | ✅ (own) | ❌ | ❌ | ✅ (any) | |
| Update | ❌ | ✅ (own) | ❌ | ❌ | ✅ (any) | |
| Delete | ❌ | ❌ | ❌ | ❌ | ✅ | |
| roles | Read | ❌ | ✅ (own only) | ❌ | ✅ (own only) | ✅ |
| Write | ❌ | ❌ | ❌ | ❌ | ✅ | |
| posts | Read 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
-
Forgetting about list queries: Even if a document is protected, listing a collection might leak existence. Always add rules for
listoperations too. -
Time-based fields: I initially used
DateTime.now()client-side, but users could manipulate their system clock. Now I always useFieldValue.serverTimestamp(). -
Recursive reads: The
hasAnyRole()function does aget()to the roles collection. Be careful if you call this in a loop or on many documents, you might hit the rule evaluation limits. -
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.