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.
# ❌ 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
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
// 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
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)
// 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,
})
}
// 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
// ❌ 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
# 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'
userIdfields - 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
# 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
// 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
- NEVER
allow read, write: if truein production — this is the most common Firebase breach - Service account keys = production passwords — use ADC or secret manager
- Custom claims are server-side only — clients cannot set their own roles
- Validate data in rules — don't rely on client-side validation alone
request.resource.datavsresource.data— incoming vs existing data- Test with Firebase Emulator before deploying rules
- App Check in enforcement mode — monitoring mode does nothing to block abuse
- Catch-all deny rule at the bottom —
allow read, write: if false - Audit
get()calls in rules — they count toward your read quota - Rules Playground in Firebase Console — test rules without deploying