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 Edited on Originally published at leo-rio.com

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

I built a multi-tenant PWA on Firebase this year: three environments, role-based access across organizations, offline support. Firebase makes the front-end wiring easy. What took real thought was splitting environments so a staging accident could never touch prod, and getting RBAC to work offline without an extra Firestore read on every request.

Three Firebase projects, not one

Firebase's free tier caps you at one database per project. Trying to run dev, staging, and prod on a single project is the fastest way to have your staging misconfigure and start writing to production collections.

I use three completely separate Firebase projects: my-app-dev, my-app-staging, my-app-production. Each has its own auth users, Firestore, storage bucket, and hosting deployment. Isolated security rules, isolated usage quotas, isolated service account permissions.

Environment Purpose Deployment
Development Local testing with emulator Manual
Staging Pre-production testing GitHub Actions on staging push
Production Live users GitHub Actions on main push

Environment config: explicit, not clever

The layout:

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

My worst near-miss was letting the build script guess which environment file to load. Something upstream flipped the environment detection, staging built with production credentials pointed at the production Firestore, and I didn't catch it for hours.

Red flag: never let the build script guess the environment. Copy the file explicitly in the deploy step. If the command doesn't say the environment name in plain text, you have a foot-gun waiting to fire.

Right:

cp .env.production .env && pnpm build
Enter fullscreen mode Exit fullscreen mode

Custom claims for multi-tenant RBAC

The classic pattern is storing roles in Firestore and reading them on every request. It works, but every protected page then loads two documents: the data you actually want, and the role check that guards it. It also breaks the moment the network drops.

Firebase Custom Claims live inside the JWT ID token. The client caches the token. Firestore security rules read the claims directly with no extra document lookup. And they work offline because the token is already on the device.

Claims structure for a user who admins one organization and views another:

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

{
  "isAdmin": false,
  "organizations": {
    "org_abc123": "admin",
    "org_xyz789": "viewer"
  }
}
Enter fullscreen mode Exit fullscreen mode

There is no Firebase Console UI for custom claims. You set them via the Admin SDK. I keep a small script in firebase-admin-scripts/:

// 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);
  }
}

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

Lesson learned: custom claims cap at 1000 bytes per user. Enough for organization IDs and roles. For fine-grained per-resource permissions, keep those in Firestore and use claims only for the org membership set. Overload the claim and Firebase silently rejects future writes.

Security rules that use the claims

The rule below reads the org role straight from the token and applies different access per role, no extra Firestore read.

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

    function hasOrgAccess(orgId, allowedRoles) {
      return request.auth != null &&
             request.auth.token.organizations[orgId] in allowedRoles;
    }

    match /organizations/{orgId} {
      allow read: if hasOrgAccess(orgId, ['admin', 'editor', 'viewer']);
      allow write: if hasOrgAccess(orgId, ['admin']);
    }

    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

Test rules before deploying. Firebase Console → Firestore → Rules → Rules Playground simulates a user with specific claims against a specific path. I also keep unit tests using @firebase/rules-unit-testing:

describe('Organization access', () => {
  it('allows an 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('denies 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

Skip these tests and you'll ship a rule that permits more than you think. There is no runtime warning, just quiet exposure.

Deployment pipeline

Push to staging, GitHub Actions builds and pushes to the staging Firebase project:

# .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

Store the Firebase service account JSON as a GitHub secret. Never commit it. The account has full write access to the Firestore database it belongs to.

Backup strategy for production

Three layers, each covering a different failure mode.

Daily backups with 7-day retention catch accidents you notice within the week: dropped collection, bad migration you spot on Monday.

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

Weekly backups with 8-week retention catch slow-burn corruption you don't notice for a month: a bad migration that only affects new writes.

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

Monthly exports to Cloud Storage with 365-day retention are the everything-is-on-fire recovery: full portable dump, retrievable a year out.

gsutil mb -l us-central1 gs://my-app-backups

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

Native daily and weekly backups are free. You pay only when you restore. Cloud Storage exports run about $0.02-0.05/GB/month. For most apps under a few GB, all three layers combined stay under $5 a month.

Firebase Emulator: not optional

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

Skip this and you'll burn through your Firebase read quota on trivial dev work, or worse, write test data into production because you forgot to swap credentials. The emulator gives you zero API quota consumption, instant reset by restarting the process, offline development, and no chance of a stray test write hitting production.

What actually matters

The Firebase parts people underestimate are the ones that survive an incident, not the ones that make the app feel snappy.

Separate projects per environment eliminate the entire class of "staging leaked into prod" bugs. Custom claims give you RBAC that works offline and doesn't cost a Firestore read per page load, but remember the 1000-byte cap. Three-tier backups cover three failure modes for the price of a coffee per month. And the Firebase Emulator is the difference between working locally without thinking and getting surprised by a bill.

References I keep bookmarked:


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

Top comments (0)