DEV Community

Cover image for Building a Multi-Tenant Firebase Application: Environment Setup and Role-Based Access Control
hello world_leo
hello world_leo

Posted on

Building a Multi-Tenant Firebase Application: Environment Setup and Role-Based Access Control

Recently, I worked on deploying a multi-tenant Progressive Web App (PWA) using Firebase as the backend platform. The application needed to support multiple organizations with different user roles, work offline, and maintain separate development, staging, and production environments.

Here's what I learned about Firebase infrastructure setup, custom claims for role-based access control, and multi-environment deployment strategies.

The Requirements

The application needed:

  • Multi-tenant architecture - Multiple organizations using one platform
  • Role-based access control - Different permission levels per organization
  • Multi-environment deployment - Separate dev, staging, and production
  • Offline-first capability - Works without internet connection
  • Real-time updates - Data syncs instantly across devices
  • Automated backups - Production data protection

Tech Stack Overview

  • Frontend: React + TypeScript
  • Backend: Firebase (Authentication, Firestore, Cloud Storage, Hosting)
  • Build System: pnpm workspaces
  • CI/CD: GitHub Actions + Firebase CLI
  • Backup: Google Cloud Console

Multi-Environment Firebase Setup

I created three separate Firebase projects for proper environment isolation:

Environment Purpose Deployment
Development Local testing with emulator Manual
Staging Pre-production testing Automated via GitHub Actions
Production Live users Automated via GitHub Actions

Each project has completely isolated:

  • Firebase Authentication users
  • Firestore database
  • Cloud Storage bucket
  • Hosting deployment

Why separate projects instead of one project with different databases?

Firebase doesn't support multiple databases per project for the free tier, and separating projects provides:

  • Complete isolation (no risk of staging affecting production)
  • Independent security rules per environment
  • Separate usage quotas
  • Different service account permissions

Environment Configuration Pattern

Managing environment variables correctly was critical. Here's the structure:

app/
├── .env                    # Development (uses emulator)
├── .env.staging            # Staging deployment
├── .env.production         # Production deployment
└── src/
    └── config/
        └── firebase.ts     # Firebase initialization
Enter fullscreen mode Exit fullscreen mode

Development (.env):

REACT_APP_FIREBASE_PROJECT_ID=my-app-dev
REACT_APP_USE_EMULATOR=true
REACT_APP_FIREBASE_API_KEY=your-dev-api-key
REACT_APP_FIREBASE_AUTH_DOMAIN=my-app-dev.firebaseapp.com
Enter fullscreen mode Exit fullscreen mode

Production (.env.production):

REACT_APP_FIREBASE_PROJECT_ID=my-app-production
REACT_APP_USE_EMULATOR=false
REACT_APP_FIREBASE_API_KEY=your-prod-api-key
REACT_APP_FIREBASE_AUTH_DOMAIN=my-app-production.firebaseapp.com
Enter fullscreen mode Exit fullscreen mode

Firebase Custom Claims for Multi-Tenant RBAC

The key challenge was implementing role-based access control (RBAC) that works across multiple organizations.

Why Custom Claims?

Instead of storing user roles in Firestore and checking them on every request, I used Firebase Custom Claims:

Built into ID tokens - No extra database reads
Enforced at security rules level - Server-side validation
Multi-tenant support - One user, multiple organizations, different roles
Works offline - Cached in the client

Custom Claims Structure

interface CustomClaims {
  isAdmin?: boolean;                       // Platform super-admin
  organizations?: Record<OrgId, Role>;     // Org-specific roles
}

// Example: User with different roles in two organizations
{
  "isAdmin": false,
  "organizations": {
    "org_abc123": "admin",
    "org_xyz789": "viewer"
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting Custom Claims (Firebase Admin SDK)

I created a helper script for managing custom claims:

// scripts/set-user-claims.js
const admin = require('firebase-admin');

admin.initializeApp({
  credential: admin.credential.cert('./service-account.json')
});

async function setUserClaims(email, organizations) {
  try {
    const user = await admin.auth().getUserByEmail(email);

    await admin.auth().setCustomUserClaims(user.uid, {
      isAdmin: false,
      organizations: organizations
    });

    console.log(`✅ Claims updated for ${email}`);
  } catch (error) {
    console.error('❌ Error:', error.message);
  }
}

// Usage
setUserClaims('user@example.com', {
  'org_abc123': 'admin'
});
Enter fullscreen mode Exit fullscreen mode

Important: Custom claims have a 1,000 byte limit per user. For large-scale multi-tenant systems, consider storing detailed permissions in Firestore and using claims only for organization IDs.

Firestore Security Rules with Custom Claims

Security rules enforce multi-tenant isolation using custom claims:

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

    // Helper function to check organization access
    function hasOrgAccess(orgId, allowedRoles) {
      return request.auth != null && 
             request.auth.token.organizations[orgId] in allowedRoles;
    }

    // Organization data - only accessible by members
    match /organizations/{orgId} {
      allow read: if hasOrgAccess(orgId, ['admin', 'editor', 'viewer']);
      allow write: if hasOrgAccess(orgId, ['admin']);
    }

    // Organization documents - different access levels
    match /organizations/{orgId}/documents/{docId} {
      allow read: if hasOrgAccess(orgId, ['admin', 'editor', 'viewer']);
      allow create, update: if hasOrgAccess(orgId, ['admin', 'editor']);
      allow delete: if hasOrgAccess(orgId, ['admin']);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing security rules: Always use Firebase Console → Firestore → Rules → Rules Playground before deploying to production.

GitHub Actions Deployment Pipeline

Automated deployment to staging when pushing to the staging branch:

# .github/workflows/deploy-staging.yml
name: Deploy to Staging

on:
  push:
    branches: [ staging ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install pnpm
        run: npm install -g pnpm

      - name: Install dependencies
        run: pnpm install

      - name: Build for staging
        run: |
          cp .env.staging .env
          pnpm build

      - name: Deploy to Firebase Hosting
        uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: '${{ secrets.GITHUB_TOKEN }}'
          firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_STAGING }}'
          projectId: my-app-staging
          channelId: live
Enter fullscreen mode Exit fullscreen mode

Pro tip: Store Firebase service account JSON as GitHub secrets, not in your repository.

Production Backup Strategy

I implemented three backup layers for production Firestore:

1. Daily Automated Backups (7-day retention)

gcloud firestore backups schedules create \
  --database='(default)' \
  --recurrence=daily \
  --retention=7d
Enter fullscreen mode Exit fullscreen mode

2. Weekly Automated Backups (8-week retention)

gcloud firestore backups schedules create \
  --database='(default)' \
  --recurrence=weekly \
  --retention=8w \
  --day-of-week=SUN
Enter fullscreen mode Exit fullscreen mode

3. Monthly Exports to Cloud Storage (365-day retention)

# Create storage bucket
gsutil mb -l us-central1 gs://my-app-backups

# Create Cloud Scheduler job for monthly exports
gcloud scheduler jobs create http firestore-monthly-export \
  --location=us-central1 \
  --schedule="0 2 1 * *" \
  --uri="https://firestore.googleapis.com/v1/projects/my-app-production/databases/(default):exportDocuments" \
  --http-method=POST \
  --oauth-service-account-email=backup-sa@my-app-production.iam.gserviceaccount.com \
  --headers="Content-Type=application/json" \
  --message-body='{"outputUriPrefix":"gs://my-app-backups/monthly"}'
Enter fullscreen mode Exit fullscreen mode

Cost: Native backups are free (pay only on restore). Cloud Storage exports cost ~$0.02-0.05/GB/month. Total estimated cost: under $5/month for most applications.

Five Key Lessons Learned

1. Environment Variables Must Be Explicit

Don't rely on build scripts to automatically switch .env files. Always explicitly copy the correct environment file:

# WRONG - relies on environment detection
pnpm build

# RIGHT - explicit environment file
cp .env.production .env && pnpm build
Enter fullscreen mode Exit fullscreen mode

I learned this the hard way when staging accidentally connected to production Firebase.

2. Firebase Emulator is Essential

The Firebase Emulator Suite is not optional for development:

firebase emulators:start --only auth,firestore,storage
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • No API quota consumption
  • Fast reset (just restart)
  • Offline development
  • No accidental production writes

3. Custom Claims Require Admin SDK

Firebase Console has no UI for custom claims. You must use Firebase Admin SDK or create helper scripts.

I maintain a firebase-admin-scripts/ directory with:

  • set-claims.js - Set user custom claims
  • get-claims.js - View user's current claims
  • list-users.js - List all users with claims

4. Security Rules Need Comprehensive Testing

Write security rules tests alongside your rules:

// firestore.test.js (using @firebase/rules-unit-testing)
describe('Organization access', () => {
  it('should allow org member to read documents', async () => {
    const db = getFirestore(testEnv, {
      uid: 'user123',
      token: { organizations: { org_abc: 'viewer' } }
    });

    const doc = db.collection('organizations').doc('org_abc');
    await assertSucceeds(doc.get());
  });

  it('should deny non-member access', async () => {
    const db = getFirestore(testEnv, { uid: 'user456' });

    const doc = db.collection('organizations').doc('org_abc');
    await assertFails(doc.get());
  });
});
Enter fullscreen mode Exit fullscreen mode

5. Document Your Deployment Process

Create a DEPLOYMENT.md file documenting:

  • How to deploy to each environment
  • Environment variable setup
  • Service account configuration
  • Rollback procedures
  • Common troubleshooting steps

Your future self (and team members) will thank you.

Key Takeaways

If you're building a multi-tenant Firebase application, here's what matters most:

  1. Use separate Firebase projects per environment - Don't share databases between dev/staging/prod
  2. Leverage custom claims for RBAC - More secure than Firestore lookups, works offline
  3. Implement multiple backup layers - Daily, weekly, and monthly with different retention periods
  4. Test security rules rigorously - Use Rules Playground and unit tests
  5. Automate deployments - GitHub Actions or similar CI/CD
  6. Use Firebase Emulator for development - Saves quota and prevents production accidents
  7. Create Admin SDK helper scripts - Manual custom claims management gets tedious fast

Conclusion

Firebase is powerful for building multi-tenant applications, but proper infrastructure setup is crucial. The key is treating Firebase like any production infrastructure:

  • Multiple isolated environments
  • Automated deployments
  • Comprehensive backups
  • Strong security rules
  • Clear documentation

Invest time upfront in environment configuration, custom claims architecture, and backup automation. It pays off when you need to debug authentication issues, restore data, or onboard new team members.


Have you built multi-tenant applications with Firebase? What challenges did you face with custom claims or security rules? Share your experience in the comments!


Useful Resources


Questions about Firebase infrastructure or DevOps? Connect with me on LinkedIn or visit my portfolio.

Top comments (0)