DEV Community

Abhishek Singh
Abhishek Singh

Posted on

How I Built Secure Firebase Cloud Functions with Admin Permissions and Rate Limiting

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
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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)