If you're building an admin panel that talks to Firebase Cloud Functions, you need two things before anything else — permission checks and rate limiting. Without these, any authenticated user could call your admin endpoints, and a single bad actor could spam your functions into hitting billing limits.
Here's how I set this up in a real admin panel, not a tutorial demo.
The problem
I was building an admin panel for a mobile app. The admin needed to send push notifications, search users, and moderate content. All of these were Cloud Functions callable from the client.
The issue? Firebase callable functions don't have built-in admin role checks. Any authenticated user can call any callable function by default. That's terrifying.
Step 1: Store admin roles in Firestore
I keep a simple admins collection in Firestore:
// Firestore structure
// admins/{uid}
{
email: "admin@example.com",
role: "super_admin", // or "moderator", "viewer"
permissions: ["send_notifications", "manage_users", "view_analytics"],
createdAt: Timestamp
}
Step 2: The permission verification helper
Every admin Cloud Function calls this before doing anything:
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
interface AdminData {
uid: string;
role: string;
permissions: string[];
}
async function verifyAdminPermission(
context: functions.https.CallableContext,
requiredPermission: string
): Promise<AdminData> {
// Check if user is authenticated at all
if (!context.auth) {
throw new functions.https.HttpsError(
"unauthenticated",
"You must be logged in."
);
}
const uid = context.auth.uid;
// Check if user exists in admins collection
const adminDoc = await admin
.firestore()
.collection("admins")
.doc(uid)
.get();
if (!adminDoc.exists) {
throw new functions.https.HttpsError(
"permission-denied",
"You are not an admin."
);
}
const adminData = adminDoc.data() as AdminData;
// Check specific permission
if (
adminData.role !== "super_admin" &&
!adminData.permissions.includes(requiredPermission)
) {
throw new functions.https.HttpsError(
"permission-denied",
`You don't have the "${requiredPermission}" permission.`
);
}
return { uid, ...adminData };
}
The key design decision — super_admin bypasses all permission checks. Everyone else needs the specific permission listed in their Firestore document. This way I can have moderators who can only view reports but can't send notifications.
Step 3: Rate limiting with Firestore
I didn't want to add Redis or any external service just for rate limiting. Firestore works fine for admin-level traffic (not high-throughput APIs, but admin panels with a few users):
async function applyRateLimit(
uid: string,
action: string,
maxRequests: number,
windowMs: number
): Promise<void> {
const now = Date.now();
const windowStart = now - windowMs;
const rateLimitRef = admin
.firestore()
.collection("rateLimits")
.doc(`${uid}_${action}`);
const doc = await rateLimitRef.get();
if (doc.exists) {
const data = doc.data()!;
// Filter out old timestamps outside the window
const recentRequests = (data.timestamps as number[]).filter(
(t) => t > windowStart
);
if (recentRequests.length >= maxRequests) {
throw new functions.https.HttpsError(
"resource-exhausted",
`Rate limit exceeded. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`
);
}
// Add current timestamp
await rateLimitRef.update({
timestamps: [...recentRequests, now],
});
} else {
// First request
await rateLimitRef.set({
timestamps: [now],
});
}
}
Is this the most efficient rate limiter ever? No. But for an admin panel with 3-5 users, it works perfectly and costs basically nothing.
Step 4: Putting it together
Here's what a real admin Cloud Function looks like with both helpers:
export const sendAdminNotification = functions.https.onCall(
async (data, context) => {
// 1. Verify admin has notification permission
const adminUser = await verifyAdminPermission(
context,
"send_notifications"
);
// 2. Rate limit: max 10 notifications per minute
await applyRateLimit(adminUser.uid, "send_notification", 10, 60000);
// 3. Validate input
const { title, body, sendMode, filters } = data;
if (!title || !body) {
throw new functions.https.HttpsError(
"invalid-argument",
"Title and body are required."
);
}
// 4. Build the query based on send mode
let tokens: string[] = [];
if (sendMode === "broadcast") {
const usersSnapshot = await admin
.firestore()
.collection("users")
.where("fcmToken", "!=", null)
.get();
tokens = usersSnapshot.docs.map((doc) => doc.data().fcmToken);
} else if (sendMode === "filtered") {
let query: admin.firestore.Query = admin
.firestore()
.collection("users");
if (filters?.gender) {
query = query.where("gender", "==", filters.gender);
}
if (filters?.city) {
query = query.where("city", "==", filters.city);
}
if (filters?.tier) {
query = query.where("tier", "==", filters.tier);
}
const snapshot = await query.get();
tokens = snapshot.docs
.map((doc) => doc.data().fcmToken)
.filter(Boolean);
}
// 5. Send via FCM (batch if over 500)
const batchSize = 500;
let successCount = 0;
let failureCount = 0;
for (let i = 0; i < tokens.length; i += batchSize) {
const batch = tokens.slice(i, i + batchSize);
const response = await admin.messaging().sendEachForMulticast({
tokens: batch,
notification: { title, body },
});
successCount += response.successCount;
failureCount += response.failureCount;
}
// 6. Log the notification for history
await admin.firestore().collection("notificationHistory").add({
title,
body,
sendMode,
filters: filters || null,
sentBy: adminUser.uid,
totalTargeted: tokens.length,
successCount,
failureCount,
sentAt: admin.firestore.FieldValue.serverTimestamp(),
});
return { successCount, failureCount, totalTargeted: tokens.length };
}
);
Organizing Cloud Functions
When you have 10+ admin functions, a single index.ts file becomes unreadable. I split them into a folder structure:
functions/
src/
admin/
notifications.ts // sendAdminNotification, estimateAudience
users.ts // searchUsers, moderateUser
analytics.ts // getDashboardStats
index.ts // barrel file - re-exports everything
index.ts // main entry - exports from ./admin
The barrel file keeps imports clean:
// functions/src/admin/index.ts
export { sendAdminNotification, estimateAudience } from "./notifications";
export { searchUsers, moderateUser } from "./users";
export { getDashboardStats } from "./analytics";
// functions/src/index.ts
export * from "./admin";
Mistakes I made
1. Forgetting to handle expired FCM tokens
First time I sent a broadcast, 30% of tokens were stale. Firebase doesn't clean them for you. Now I delete tokens that return messaging/registration-token-not-registered errors after each send.
2. Not batching Firestore reads
I was doing individual getDoc() calls in a loop to check user profiles. Switched to a single where() query — went from 3 seconds to 200ms for 500 users.
3. Rate limit document growing forever
My first rate limiter appended timestamps but never cleaned old ones. After a month, each document had thousands of entries. The filter step in applyRateLimit above fixes this — it only keeps timestamps within the current window.
When to use this pattern
This setup works when:
- You have a small number of admin users (under 50)
- You want role-based permissions without adding a third-party auth service
- Your rate limiting needs are basic (not thousands of requests per second)
If you need high-throughput rate limiting, use Redis. If you need complex RBAC, use a proper auth provider. But for most admin panels, Firestore-based checks are simple, free, and good enough.
I'm Abhishek, a full-stack developer building SaaS products and mobile apps with Flutter, Next.js, and Node.js. I write about practical patterns from real projects.
Top comments (0)