← Back to DevBytes

Scaling Firebase Auth: From Prototype to Production

Scaling Firebase Auth: From Prototype to Production

Firebase Authentication is one of the fastest ways to add user sign-in to a web or mobile application. With a few lines of code, you can enable email/password, OAuth providers, phone authentication, and anonymous sign-in. However, what works for a prototype with a hundred users often breaks down when you reach tens of thousands of concurrent users, complex role-based access control, and strict compliance requirements. This tutorial walks you through the journey of scaling Firebase Auth from a quick prototype to a robust production system.

What Is Firebase Authentication?

Firebase Authentication is a managed identity service provided by Google as part of the Firebase platform. It handles user sign-up, sign-in, password reset, account linking, session management, and token verification. Because it is fully managed, you do not need to run your own identity server or worry about securely storing password hashes. Firebase Auth integrates natively with other Firebase services such as Firestore, Realtime Database, and Cloud Storage through its security rules.

At its core, Firebase Auth issues JSON Web Tokens (JWTs) to authenticated clients. These tokens are signed by Google and contain the user's UID, provider information, and custom claims. Your backend services can verify these tokens to authenticate API requests without managing sessions themselves.

Why Scaling Matters

During prototyping, Firebase Auth feels almost magical. You call signInWithEmailAndPassword and everything just works. But as your application grows, several challenges emerge:

Addressing these challenges early prevents painful migrations later. The rest of this tutorial covers practical strategies for each of these areas.

Setting Up Firebase Auth

Let us start with a clean client-side setup using the Firebase modular SDK. This example uses a web application with email/password and Google sign-in.

// firebase.js
import { initializeApp } from "firebase/app";
import {
  getAuth,
  GoogleAuthProvider,
  setPersistence,
  browserLocalPersistence,
} from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);
const auth = getAuth(app);

// Persist sessions in local storage for better UX
setPersistence(auth, browserLocalPersistence);

const googleProvider = new GoogleAuthProvider();
googleProvider.setCustomParameters({ prompt: "select_account" });

export { auth, googleProvider };

With the setup in place, you can implement sign-in and sign-up functions. Notice how we wrap Firebase calls in async functions with structured error handling, which is essential for production-grade code.

// auth-service.js
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signInWithPopup,
  signOut,
  updateProfile,
} from "firebase/auth";
import { auth, googleProvider } from "./firebase";

export async function registerWithEmail(email, password, displayName) {
  try {
    const credential = await createUserWithEmailAndPassword(
      auth,
      email,
      password
    );
    if (displayName) {
      await updateProfile(credential.user, { displayName });
    }
    return { user: credential.user, error: null };
  } catch (err) {
    return { user: null, error: mapAuthError(err) };
  }
}

export async function loginWithEmail(email, password) {
  try {
    const credential = await signInWithEmailAndPassword(
      auth,
      email,
      password
    );
    return { user: credential.user, error: null };
  } catch (err) {
    return { user: null, error: mapAuthError(err) };
  }
}

export async function loginWithGoogle() {
  try {
    const credential = await signInWithPopup(auth, googleProvider);
    return { user: credential.user, error: null };
  } catch (err) {
    return { user: null, error: mapAuthError(err) };
  }
}

export async function logout() {
  await signOut(auth);
}

function mapAuthError(err) {
  const codeMap = {
    "auth/email-already-in-use": "An account with this email already exists.",
    "auth/invalid-email": "The email address is not valid.",
    "auth/weak-password": "Password should be at least 6 characters.",
    "auth/user-not-found": "No account found with this email.",
    "auth/wrong-password": "Incorrect password.",
    "auth/too-many-requests": "Too many attempts. Try again later.",
  };
  return codeMap[err.code] || "An unexpected error occurred.";
}

The mapAuthError function is a small but important production detail. Raw Firebase error codes leak implementation details and are not user-friendly. Mapping them to human-readable messages improves both UX and security.

Verifying Tokens on the Backend

In production, your frontend will call your own backend APIs, and those APIs need to verify the user's identity. Firebase Admin SDK makes this straightforward by verifying the ID token signature against Google's public keys.

// backend/auth-middleware.js
const admin = require("firebase-admin");

const serviceAccount = require("./service-account.json");

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });
}

const tokenCache = new Map();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

async function verifyIdToken(idToken) {
  const cacheKey = idToken.slice(-32);
  const cached = tokenCache.get(cacheKey);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
    return cached.decodedToken;
  }

  const decodedToken = await admin.auth().verifyIdToken(idToken);

  tokenCache.set(cacheKey, {
    decodedToken,
    timestamp: Date.now(),
  });

  // Prevent unbounded memory growth
  if (tokenCache.size > 10000) {
    const oldestKey = tokenCache.keys().next().value;
    tokenCache.delete(oldestKey);
  }

  return decodedToken;
}

async function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing or invalid token" });
  }

  const idToken = authHeader.split("Bearer ")[1];

  try {
    const decodedToken = await verifyIdToken(idToken);
    req.user = {
      uid: decodedToken.uid,
      email: decodedToken.email,
      role: decodedToken.role || "user",
      tenantId: decodedToken.tenantId || null,
    };
    next();
  } catch (err) {
    console.error("Token verification failed:", err.message);
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

module.exports = { authMiddleware, admin };

The token cache above is a critical optimization. Without it, every API request triggers a network call to Google's public key endpoints. With caching, repeated requests from the same user are verified locally, dramatically reducing latency and backend load.

Managing Roles with Custom Claims

Firebase Auth does not have a built-in role system, but it supports custom claims that are embedded directly in the ID token. This means role checks can happen on both the client and the server without an extra database lookup. The trade-off is the 1000-byte limit, so you must keep claims minimal.

// backend/roles.js
const { admin } = require("./auth-middleware");

async function setUserRole(uid, role) {
  const validRoles = ["user", "editor", "admin", "superadmin"];
  if (!validRoles.includes(role)) {
    throw new Error(`Invalid role: ${role}`);
  }

  await admin.auth().setCustomUserClaims(uid, { role });

  // Force token refresh on the client by revoking existing tokens
  await admin.auth().revokeRefreshTokens(uid);

  // Optionally persist role in Firestore for querying
  await admin.firestore().collection("users").doc(uid).set(
    { role, roleUpdatedAt: admin.firestore.FieldValue.serverTimestamp() },
    { merge: true }
  );
}

async function grantTenantAccess(uid, tenantId, permissions) {
  const user = await admin.auth().getUser(uid);
  const existingClaims = user.customClaims || {};

  const tenantClaims = existingClaims.tenants || {};
  tenantClaims[tenantId] = permissions;

  const newClaims = {
    ...existingClaims,
    tenants: tenantClaims,
  };

  const claimSize = Buffer.byteLength(JSON.stringify(newClaims), "utf8");
  if (claimSize > 1000) {
    throw new Error(
      `Custom claims exceed 1000 bytes (${claimSize} bytes). ` +
      "Use a Firestore permissions collection instead."
    );
  }

  await admin.auth().setCustomUserClaims(uid, newClaims);
}

module.exports = { setUserRole, grantTenantAccess };

Notice the explicit size check before setting claims. This prevents silent failures and prompts you to move permissions to Firestore when they grow too large. A common pattern is to store a compact role identifier in claims and fetch detailed permissions from Firestore when needed.

Handling Rate Limits and Quotas

Firebase Auth enforces several quotas. The most commonly hit limits in production are the email/password sign-up rate, the phone verification SMS rate, and the ID token verification rate on the Admin SDK. Hitting these limits returns errors that can cascade into poor user experiences.

The first defense is exponential backoff with jitter on the client side. The second is queueing expensive operations such as bulk user imports during off-peak hours.

// client/retry.js
export async function withRetry(fn, options = {}) {
  const {
    maxAttempts = 5,
    baseDelayMs = 500,
    maxDelayMs = 10000,
  } = options;

  let lastError;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      const isRetryable =
        err.code === "auth/too-many-requests" ||
        err.code === "auth/network-request-failed";

      if (!isRetryable || attempt === maxAttempts - 1) {
        throw err;
      }

      const exponentialDelay = Math.min(
        baseDelayMs * Math.pow(2, attempt),
        maxDelayMs
      );
      const jitter = Math.random() * 250;
      const totalDelay = exponentialDelay + jitter;

      await new Promise((resolve) => setTimeout(resolve, totalDelay));
    }
  }
  throw lastError;
}

// Usage
import { withRetry } from "./retry";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "./firebase";

const result = await withRetry(() =>
  signInWithEmailAndPassword(auth, email, password)
);

For bulk operations such as migrating existing users into Firebase, use the Admin SDK's batch import methods instead of creating users one by one. This is orders of magnitude faster and avoids hitting per-request quotas.

// backend/bulk-import.js
const { admin } = require("./auth-middleware");

async function bulkImportUsers(users) {
  const batchSize = 1000;
  const results = { success: 0, failed: 0, errors: [] };

  for (let i = 0; i < users.length; i += batchSize) {
    const batch = users.slice(i, i + batchSize);

    const userRecords = batch.map((u) => ({
      uid: u.id,
      email: u.email,
      emailVerified: u.emailVerified || false,
      displayName: u.name,
      passwordHash: Buffer.from(u.passwordHash, "base64"),
      passwordSalt: Buffer.from(u.passwordSalt, "base64"),
      customClaims: { role: u.role || "user" },
    }));

    try {
      const importResult = await admin
        .auth()
        .importUsers(userRecords, {
          hash: { algorithm: "HMAC_SHA256", key: Buffer.from(process.env.IMPORT_KEY) },
        });

      results.success += importResult.successCount;
      results.failed += importResult.failureCount;

      importResult.errors.forEach((err) => {
        results.errors.push({
          index: err.index,
          error: err.error.message,
        });
      });
    } catch (err) {
      console.error(`Batch ${i / batchSize} failed:`, err.message);
      results.failed += batch.length;
    }
  }

  return results;
}

module.exports = { bulkImportUsers };

Securing Firestore with Auth Context

One of the most powerful features of Firebase Auth is its integration with Firestore security rules. In production, you should never rely solely on frontend code to protect data. Security rules are the last line of defense and must be written carefully.

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    function isSignedIn() {
      return request.auth != null;
    }

    function hasRole(role) {
      return isSignedIn() && request.auth.token.role == role;
    }

    function isOwner(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }

    function userTenantMatches(docTenantId) {
      return isSignedIn()
        && request.auth.token.tenants != null
        && request.auth.token.tenants[docTenantId] != null;
    }

    // Users can read and update their own profile
    match /users/{userId} {
      allow read: if isOwner(userId) || hasRole("admin") || hasRole("superadmin");
      allow create: if isOwner(userId);
      allow update: if isOwner(userId)
        || (hasRole("admin") && request.resource.data.role == resource.data.role);
      allow delete: if hasRole("superadmin");
    }

    // Tenant-scoped documents
    match /tenants/{tenantId}/{document=**} {
      allow read, write: if hasRole("superadmin") || userTenantMatches(tenantId);
    }

    // Public read-only content
    match /content/{docId} {
      allow read: if true;
      allow write: if hasRole("editor") || hasRole("admin") || hasRole("superadmin");
    }
  }
}

Notice how the rules reference request.auth.token.role and request.auth.token.tenants. These are the custom claims we set earlier. Because claims travel with the token, Firestore can enforce access control without any additional database lookups.

Implementing Session Management

By default, Firebase Auth tokens expire after one hour and are refreshed automatically by the client SDK. However, in production you often need more control. For example, you may want to force logout when a user's role changes, or implement a "remember me" feature with a longer session.

// backend/session-management.js
const { admin } = require("./auth-middleware");

async function revokeUserSessions(uid) {
  await admin.auth().revokeRefreshTokens(uid);
  const user = await admin.auth().getUser(uid);
  console.log(`Tokens revoked for ${uid} at ${user.tokensValidAfterTime.toISOString()}`);
}

async function verifySessionCookie(sessionCookie, checkRevoked = true) {
  try {
    const claims = await admin
      .auth()
      .verifySessionCookie(sessionCookie, checkRevoked);
    return { claims, error: null };
  } catch (err) {
    return { claims: null, error: err.message };
  }
}

async function createSessionCookie(idToken, expiresInDays = 14) {
  const expiresIn = expiresInDays * 24 * 60 * 60 * 1000;
  const sessionCookie = await admin
    .auth()
    .createSessionCookie(idToken, { expiresIn });
  return sessionCookie;
}

module.exports = { revokeUserSessions, verifySessionCookie, createSessionCookie };

Session cookies are particularly useful for traditional server-rendered applications where you want to authenticate users on the server without exposing ID tokens to client-side JavaScript. The revokeRefreshTokens method invalidates all existing refresh tokens for a user, forcing them to re-authenticate on their next request.

Building an Audit Trail

Firebase Auth does not provide detailed audit logs by default. For compliance and security, you should build your own audit trail using Cloud Functions triggered by authentication events.

// functions/index.js
const functions = require("firebase-functions");
const admin = require("firebase-admin");

admin.initializeApp();

exports.onUserCreated = functions.auth.user().onCreate(async (user) => {
  const auditEntry = {
    eventType: "USER_CREATED",
    uid: user.uid,
    email: user.email,
    provider: user.providerData[0]?.providerId || "unknown",
    timestamp: admin.firestore.FieldValue.serverTimestamp(),
    ip: null, // Not available in auth triggers
  };

  await admin.firestore().collection("auditLogs").add(auditEntry);

  await admin.firestore().collection("users").doc(user.uid).set({
    email: user.email,
    displayName: user.displayName || "",
    photoURL: user.photoURL || "",
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
    role: "user",
    lastLoginAt: null,
  }, { merge: true });
});

exports.onUserDeleted = functions.auth.user().onDelete(async (user) => {
  const auditEntry = {
    eventType: "USER_DELETED",
    uid: user.uid,
    email: user.email,
    timestamp: admin.firestore.FieldValue.serverTimestamp(),
  };

  await admin.firestore().collection("auditLogs").add(auditEntry);

  // Anonymize user data for GDPR compliance
  await admin.firestore().collection("users").doc(user.uid).set({
    email: admin.firestore.FieldValue.delete(),
    displayName: "[deleted]",
    photoURL: admin.firestore.FieldValue.delete(),
    deletedAt: admin.firestore.FieldValue.serverTimestamp(),
  }, { merge: true });
});

exports.onTokenRevoked = functions.firestore
  .document("users/{uid}")
  .onUpdate(async (change, context) => {
    const before = change.before.data();
    const after = change.after.data();

    if (before.role !== after.role) {
      await admin.auth().revokeRefreshTokens(context.params.uid);

      await admin.firestore().collection("auditLogs").add({
        eventType: "ROLE_CHANGED",
        uid: context.params.uid,
        oldRole: before.role,
        newRole: after.role,
        timestamp: admin.firestore.FieldValue.serverTimestamp(),
      });
    }
  });

This setup gives you a complete audit trail of user creation, deletion, and role changes. The role change trigger also automatically revokes existing sessions, ensuring that a demoted user loses elevated access immediately rather than waiting for their token to expire.

Monitoring and Alerting

In production, you need visibility into authentication health. Google Cloud Monitoring provides metrics for Firebase Auth, but you should also build custom dashboards for application-specific signals.

// backend/monitoring.js
const { admin } = require("./auth-middleware");

async function getAuthHealthMetrics() {
  const metrics = {
    timestamp: Date.now(),
    totalUsers: 0,
    activeUsers7d: 0,
    newUsers24h: 0,
    failedSignIns1h: 0,
  };

  // Total user count
  const listUsersResult = await admin.auth().listUsers(1);
  metrics.totalUsers = listUsersResult.users.length > 0 ? "available" : 0;

  // Active users in last 7 days (requires lastLoginAt field in Firestore)
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  const activeSnapshot = await admin
    .firestore()
    .collection("users")
    .where("lastLoginAt", ">=", sevenDaysAgo)
    .count()
    .get();
  metrics.activeUsers7d = activeSnapshot.data().count;

  // New users in last 24 hours
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
  const newSnapshot = await admin
    .firestore()
    .collection("users")
    .where("createdAt", ">=", oneDayAgo)
    .count()
    .get();
  metrics.newUsers24h = newSnapshot.data().count;

  // Failed sign-in attempts from audit logs
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  const failedSnapshot = await admin
    .firestore()
    .collection("auditLogs")
    .where("eventType", "==", "SIGN_IN_FAILED")
    .where("timestamp", ">=", oneHourAgo)
    .count()
    .get();
  metrics.failedSignIns1h = failedSnapshot.data().count;

  return metrics;
}

async function checkAndAlert() {
  const metrics = await getAuthHealthMetrics();

  if (metrics.failedSignIns1h > 100) {
    console.warn(
      `ALERT: ${metrics.failedSignIns1h} failed sign-in attempts in the last hour`
    );
    // Send alert to Slack, email, or PagerDuty here
  }

  return metrics;
}

module.exports = { getAuthHealthMetrics, checkAndAlert };

Schedule the checkAndAlert function with Cloud Scheduler to run every few minutes. This gives you early warning of brute-force attacks, quota issues, or unexpected spikes in sign-up activity.

Best Practices for Production

Conclusion

Scaling Firebase Auth from prototype to production is less about replacing Firebase and more about building the right patterns around it. The core authentication primitives are solid and scale well, but the surrounding infrastructure—token caching, role management, audit logging, rate limiting, and monitoring—is what separates a prototype from a production system. By implementing the strategies in this tutorial, you can confidently grow your application from a handful of test users to a large-scale production deployment while maintaining security, performance, and observability. Start with the basics, instrument everything, and iterate on your auth architecture as your user base and business requirements evolve.

— Ad —

Google AdSense will appear here after approval

← Back to all articles