# Firebase Security Expert

> Firebase Security Rules auditing, Service Account protection, and App Check integration. Use whenever the user is working with Firebase security — Firestore rules, Realtime Database rules, Storage rules, App Check, service account key management, or Firebase Auth security. Trigger on mentions of Firebase, Firestore, Firebase Security Rules, App Check, service account JSON, Firebase Admin SDK, or when the user asks to review or write Firebase rules. Also trigger for Firebase project security audits.

- Skill: `roedyrustam/firebase-security-expert-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add roedyrustam/firebase-security-expert-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/roedyrustam/firebase-security-expert-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: roedyrustam (https://skillmd.com/u/roedyrustam)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/roedyrustam/firebase-security-expert-2

---


# Firebase Security Expert

Comprehensive Firebase security: rules, App Check, service accounts, and auditing.

---

## The #1 Firebase Security Mistake

**Exposed service account JSON in client code or public repositories.**

```bash
# ❌ DO NOT commit service account keys
firebase-admin-key.json
service-account.json
*-firebase-adminsdk-*.json

# Add to .gitignore immediately
echo "*.json" >> .gitignore  # Too broad — use specific names
echo "service-account*.json" >> .gitignore
echo "*-adminsdk-*.json" >> .gitignore

# If already committed: rotate the key immediately in Firebase Console
# Google Cloud → IAM → Service Accounts → Your SA → Keys → Delete old, Add new
```

---

## Firestore Security Rules

### Rule Fundamentals
```javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Helper functions
    function isAuth() {
      return request.auth != null;
    }

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

    function hasRole(role) {
      return isAuth() &&
        get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == role;
    }

    function isOrgMember(orgId) {
      return isAuth() &&
        exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
    }

    // Users can only read/write their own profile
    match /users/{userId} {
      allow read: if isOwner(userId);
      allow create: if isOwner(userId)
        && request.resource.data.keys().hasOnly(['name', 'email', 'createdAt'])
        && request.resource.data.email == request.auth.token.email;
      allow update: if isOwner(userId)
        && request.resource.data.diff(resource.data).affectedKeys()
           .hasOnly(['name', 'avatarUrl', 'updatedAt']); // can't change email/role via client
      allow delete: if false; // no self-deletion — use Cloud Function
    }

    // Organization-scoped documents
    match /orgs/{orgId} {
      allow read: if isOrgMember(orgId);
      allow create: if isAuth();
      allow update: if isOrgMember(orgId) && hasRole('admin');
      allow delete: if false;

      // Sub-collection inherits org membership requirement
      match /projects/{projectId} {
        allow read: if isOrgMember(orgId);
        allow write: if isOrgMember(orgId)
          && get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid))
             .data.role in ['admin', 'member'];
      }
    }

    // Public read, authenticated write
    match /posts/{postId} {
      allow read: if resource.data.published == true || isOwner(resource.data.authorId);
      allow create: if isAuth()
        && request.resource.data.authorId == request.auth.uid
        && request.resource.data.title is string
        && request.resource.data.title.size() > 0
        && request.resource.data.title.size() <= 200;
      allow update: if isOwner(resource.data.authorId)
        && request.resource.data.authorId == resource.data.authorId; // can't change author
      allow delete: if isOwner(resource.data.authorId);
    }

    // Deny everything else
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
```

### Data Validation in Rules
```javascript
// Validate field types and values in rules
match /orders/{orderId} {
  allow create: if isAuth()
    && validOrder(request.resource.data);

  function validOrder(data) {
    return data.keys().hasAll(['userId', 'items', 'total', 'status'])
      && data.userId == request.auth.uid
      && data.items is list
      && data.items.size() > 0
      && data.total is number
      && data.total > 0
      && data.status == 'pending'; // clients can only create pending orders
  }
}
```

---

## Firebase Storage Rules

```javascript
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {

    // User avatars — users can only write their own
    match /avatars/{userId}/{allPaths=**} {
      allow read: if true; // public avatars OK
      allow write: if request.auth != null
        && request.auth.uid == userId
        && request.resource.size < 5 * 1024 * 1024  // 5MB max
        && request.resource.contentType.matches('image/.*');
    }

    // Private user documents
    match /users/{userId}/documents/{docId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // Organization files
    match /orgs/{orgId}/{allPaths=**} {
      allow read: if isOrgMember(orgId);
      allow write: if isOrgMember(orgId) && hasOrgRole(orgId, 'member');

      function isOrgMember(orgId) {
        return request.auth != null
          && firestore.exists(/databases/(default)/documents/orgs/$(orgId)/members/$(request.auth.uid));
      }

      function hasOrgRole(orgId, minRole) {
        let member = firestore.get(/databases/(default)/documents/orgs/$(orgId)/members/$(request.auth.uid));
        return member.data.role in ['admin', 'owner']; // simplified
      }
    }

    // Deny everything else
    match /{allPaths=**} {
      allow read, write: if false;
    }
  }
}
```

---

## App Check (Anti-Abuse)

App Check ensures only your legitimate apps can access Firebase.

### Setup (Next.js + reCAPTCHA v3)
```typescript
// lib/firebase/app-check.ts
import { initializeApp } from "firebase/app"
import { initializeAppCheck, ReCaptchaV3Provider } from "firebase/app-check"

const app = initializeApp(firebaseConfig)

// Client-side only
if (typeof window !== "undefined") {
  if (process.env.NODE_ENV === "development") {
    // Use debug token in dev
    (window as any).FIREBASE_APPCHECK_DEBUG_TOKEN = process.env.NEXT_PUBLIC_APPCHECK_DEBUG_TOKEN
  }

  initializeAppCheck(app, {
    provider: new ReCaptchaV3Provider(process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!),
    isTokenAutoRefreshEnabled: true,
  })
}
```

```javascript
// Firestore rules: enforce App Check
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Require valid App Check token
      allow read, write: if request.app.token.valid;
    }
  }
}
```

---

## Service Account Best Practices

### Use Application Default Credentials (ADC) — Not Key Files
```typescript
// ❌ Don't do this in production
import admin from "firebase-admin"
const serviceAccount = require("./service-account.json") // KEY FILE
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) })

// ✅ Use ADC (works on Cloud Run, GCE, Cloud Functions automatically)
admin.initializeApp()
// Or via environment variable:
// GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json (for local dev only)

// ✅ Or use individual env vars (safest for most Node.js hosts)
admin.initializeApp({
  credential: admin.credential.cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
  }),
})
```

### Principle of Least Privilege
```bash
# In Google Cloud Console → IAM:
# Don't use "Owner" or "Editor" for service accounts
# Use specific roles:
roles/firebase.sdkAdminServiceAgent  # Firebase Admin SDK
roles/datastore.user                 # Firestore read/write
roles/storage.objectAdmin            # Storage admin
# NOT: roles/owner or roles/editor
```

---

## Security Audit Checklist

### Firestore Rules
- [ ] Rules are NOT in test mode (`allow read, write: if true`)
- [ ] `/{document=**}` catch-all DENIES by default
- [ ] All user-writable fields validated (type, size, allowed values)
- [ ] Users cannot write other users' `userId` fields
- [ ] Sensitive fields (role, plan, stripeId) cannot be written by clients
- [ ] Rules deployed and tested with Firebase Emulator + Rules Playground

### Authentication
- [ ] Email enumeration protection enabled (consistent error messages)
- [ ] Email verification required for sensitive actions
- [ ] Custom claims set server-side (never client-side)
- [ ] ID token verification on every Admin SDK call

### Service Accounts
- [ ] No key files committed to git (`git log --all -- "*.json"`)
- [ ] Minimum necessary IAM roles assigned
- [ ] Keys rotated regularly (or using ADC — no keys at all)
- [ ] Service account keys in secret manager (not plain env files)

### App Check
- [ ] App Check enabled in Firebase Console
- [ ] Enforcement mode ON (not monitoring mode) for production
- [ ] Debug tokens for development only, not in production builds

---

## Testing Rules with Firebase Emulator

```bash
# Install and run emulator
npm install -g firebase-tools
firebase emulators:start --only firestore,auth

# Run rules tests
npm install --save-dev @firebase/rules-unit-testing
```

```typescript
// firestore.rules.test.ts
import { initializeTestEnvironment, assertFails, assertSucceeds } from "@firebase/rules-unit-testing"

const env = await initializeTestEnvironment({
  projectId: "test-project",
  firestore: { rules: fs.readFileSync("firestore.rules", "utf8") },
})

test("users can only read own profile", async () => {
  const alice = env.authenticatedContext("alice")
  const bob = env.authenticatedContext("bob")

  await assertSucceeds(alice.firestore().doc("users/alice").get())
  await assertFails(bob.firestore().doc("users/alice").get())
})

test("unauthenticated users cannot read any data", async () => {
  const anon = env.unauthenticatedContext()
  await assertFails(anon.firestore().doc("users/alice").get())
})
```

---

## Key Rules

1. **NEVER `allow read, write: if true`** in production — this is the most common Firebase breach
2. **Service account keys = production passwords** — use ADC or secret manager
3. **Custom claims are server-side only** — clients cannot set their own roles
4. **Validate data in rules** — don't rely on client-side validation alone
5. **`request.resource.data` vs `resource.data`** — incoming vs existing data
6. **Test with Firebase Emulator** before deploying rules
7. **App Check in enforcement mode** — monitoring mode does nothing to block abuse
8. **Catch-all deny rule** at the bottom — `allow read, write: if false`
9. **Audit `get()` calls in rules** — they count toward your read quota
10. **Rules Playground in Firebase Console** — test rules without deploying

