Firestore Security Rules with Custom Claims: Role-Based Access Control in Firebase
Use Firebase Auth custom claims to embed roles in ID tokens, then enforce access in Firestore security rules via request.auth.token.role—avoiding extra document reads and quota costs. Includes Admin SDK setup, rule patterns, emulator testing, and deployment rollback.
30 Aug 2025, 17:36 UTC

The Problem: Authorization Without Extra Reads
Firestore security rules run on every read and write. If you store roles in a users/{uid} document and check get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role, every request incurs an extra document read—adding latency and quota cost. Custom claims solve this by embedding the role directly in the user's ID token, so rules can evaluate request.auth.token.role without any additional Firestore access.
How Custom Claims Work
Custom claims are key-value pairs you attach to a user's authentication token using the Firebase Admin SDK. They propagate to the client's ID token on the next sign-in or token refresh (maximum 1-hour TTL). The payload is limited to 1000 bytes, so keep it minimal—typically just a role string or small array.
Setting a Claim (Node.js Admin SDK)
// Run in a trusted environment: Cloud Functions, Admin backend, or local script with service account
const admin = require('firebase-admin');
admin.initializeApp();
async function setAdminRole(uid) {
await admin.auth().setCustomUserClaims(uid, { role: 'admin' });
// Optional: write a mirror document for UI display only
await admin.firestore().doc(`users/${uid}`).set({ role: 'admin' }, { merge: true });
}
// Usage: setAdminRole('user-uid-here');
Where to run: Any Node.js environment with the Admin SDK initialized using a service account that has firebaseauth.users.update permission (Editor/Owner roles or custom role with auth.users.update).
Expected check: Call admin.auth().getUser(uid) after setting claims; customClaims.role should equal 'admin'.
Risk: Claims persist until overwritten. Revoking access requires setting the claim to null or a different value and waiting for token refresh (or forcing re-auth with admin.auth().revokeRefreshTokens(uid)).
Security Rules That Read Claims
Rules access claims via request.auth.token.<claim>. The token object contains all custom claims plus standard fields (uid, email, email_verified, etc.).
Worked Example: Article Collection with Admin/Editor/Viewer Roles
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper: true if token has a specific role
function hasRole(role) {
return request.auth != null && request.auth.token.role == role;
}
match /articles/{articleId} {
// Anyone signed in can read published articles
allow read: if request.auth != null && resource.data.status == 'published';
// Editors and admins can read drafts
allow read: if hasRole('editor') || hasRole('admin');
// Only editors and admins can create/update
allow create, update: if hasRole('editor') || hasRole('admin');
// Only admins can delete
allow delete: if hasRole('admin');
}
// Users can read/write their own profile (mirror document)
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}
Key points:
request.authis null for unauthenticated requests—always guard withrequest.auth != nullbefore accessing.token.- The
hasRole()function avoids repetition and makes rules readable. - Rules cannot call async functions or external APIs; all logic must be in the token or pre-computed.
Testing with the Emulator Suite
The Firebase Emulator Suite lets you unit-test rules against simulated auth tokens before deploying.
Setup (run once per project)
# Install CLI and testing library
npm i -g firebase-tools
npm i -D @firebase/rules-unit-testing
# Initialize emulators (select Firestore, Auth)
firebase init emulators
Unit Test Example (Jest)
const { initializeTestEnvironment, assertSucceeds, assertFails } = require('@firebase/rules-unit-testing');
const firebase = require('firebase/app');
require('firebase/firestore');
let testEnv;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'demo-project',
firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') },
});
});
afterAll(async () => await testEnv.cleanup());
test('admin can delete article', async () => {
const adminDb = testEnv.authenticatedContext('admin-uid', { role: 'admin' }).firestore();
const ref = adminDb.collection('articles').doc('article-1');
await assertSucceeds(ref.delete());
});
test('viewer cannot delete article', async () => {
const viewerDb = testEnv.authenticatedContext('viewer-uid', { role: 'viewer' }).firestore();
const ref = viewerDb.collection('articles').doc('article-1');
await assertFails(ref.delete());
});
Run: firebase emulators:exec --only firestore "npm test" (starts emulators, runs tests, shuts down).
Verification: Tests pass only if rules allow/deny exactly as written. The emulator does not enforce production quotas—verify cost with real usage.
Deployment and Rollback
# Deploy rules only (faster than full deploy)
firebase deploy --only firestore:rules
Rules are versioned in the Firebase Console (Firestore → Rules tab). Each deploy creates a new version; you can roll back to any previous version from the console or with firebase firestore:rules:release <version>.
Rollback is necessary because a bad rule deploy immediately affects production traffic. Keep the previous version tested and ready.
Limits and Common Mistakes
| Limit / Mistake | Impact | Mitigation |
|---|---|---|
| 1000-byte claim payload | Cannot store large objects or arrays | Store only role identifiers; keep UI data in separate documents |
| 1-hour token TTL | Claim changes take up to 1 hour to propagate | Call admin.auth().revokeRefreshTokens(uid) for immediate effect, or force client re-auth |
Rules cannot read Firestore except via exists()/get() | Extra reads cost quota and latency | Use claims for authorization; mirror documents only for display |
Overly permissive rules (e.g., allow read: if true) | Data exposure | Default-deny; test every rule path with emulator |
Forgetting request.auth != null guard | Rules error on unauthenticated requests | Always check auth existence before accessing .token |
| Emulator doesn't enforce all production quotas | False confidence on cost/performance | Load-test against a real project with monitoring enabled |
Practical Verification Checklist
- Create a test Firebase project; enable Authentication (Email/Password) and Firestore.
- Deploy the rules above via
firebase deploy --only firestore:rules. - Use Admin SDK to set
{ role: 'editor' }on a test UID. - Sign in as that UID in a browser; open DevTools → Application → Local Storage → Firebase ID token; decode (jwt.io) and confirm
claims.role === 'editor'. - In the Firebase Console → Firestore → Rules → Simulator, simulate a
deleteon/articles/xyzwith that UID—expect denial. - Change claim to
admin, revoke refresh tokens, re-sign-in, re-simulate—expect allow. - Monitor Cloud Logging: filter
resource.type="firestore_database" severity>=INFOto see rule evaluations in production.
Summary
Custom claims move authorization decisions into the auth token, eliminating per-request Firestore reads for role checks. Set claims server-side with the Admin SDK, read them in rules via request.auth.token.role, test exhaustively with the emulator, and deploy with versioned rollback ready. The 1000-byte limit and 1-hour TTL are the main operational constraints—plan claim updates and revocation accordingly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.