← All posts
·22 min read

Claude Code with Firebase: Firestore, Auth, Functions, Storage

Claude CodeFirebaseBackendWorkflow
Claude Code with Firebase: Firestore, Auth, Functions, Storage

Why Firebase needs strong CLAUDE.md scaffolding

Firebase is one of the most widely used backend-as-a-service platforms. It ships Firestore, Authentication, Cloud Functions, Storage, Hosting, and Realtime Database as a unified suite, and the combination lets a solo developer or small team move fast. That breadth is also the source of most of the problems Claude Code produces without explicit guidance.

Firebase has been around long enough to accumulate two distinct programming models sitting side by side in the docs. The namespaced SDK (firebase/app, firebase/firestore, import firebase from 'firebase/app') is the v8 and earlier pattern. The modular SDK (import { getFirestore, doc, getDoc } from 'firebase/firestore') is v9 and v10. Official docs, Stack Overflow answers, and blog posts mix both freely. Claude, trained on that full corpus, produces whichever pattern appeared most recently in the context window, and it will silently combine both in the same file.

The Admin SDK confusion is worse. Firebase offers two completely separate SDKs: the client SDK runs in the browser or a React Native app; the Admin SDK runs in a privileged server environment (Node.js, Cloud Functions, a backend API route) and bypasses all Firestore security rules. Claude knows both. What it cannot infer is which environment it is generating code for, so it sometimes imports firebase-admin inside a client component and firebase/firestore inside a Cloud Function that needs elevated privileges. The first produces a bundle error. The second produces a silent security bypass that is hard to audit.

Add to that the v1 vs v2 Cloud Functions syntax, the way Auth custom claims propagate (or fail to propagate) to ID tokens, and the Firestore security rules patterns that prevent IDOR, and Firebase is a stack where Claude's default output looks plausible but is often wrong in ways that surface only at runtime or under adversarial conditions.

This guide covers the CLAUDE.md configuration that anchors Claude Code to one coherent Firebase implementation. If you are new to Claude Code, the Claude Code setup guide covers installation first. For an alternative BaaS stack with a similar CLAUDE.md model, Claude Code with Supabase covers the Postgres-backed option.

The Firebase CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Firebase application it needs to declare: SDK version and import style, which SDK boundary applies (client vs Admin), where environment variables live, which Firebase services are in use, emulator configuration, and the hard rules that prevent the most common failure modes.

# Firebase project rules

## Stack
- Firebase JS SDK 10.x (modular imports only, no namespaced v8 API)
- firebase-admin 12.x (server/functions only)
- Cloud Functions for Firebase (v2 syntax, firebase-functions/v2)
- TypeScript 5.x strict
- Node.js 20 (functions runtime)

## SDK boundary (CRITICAL)
- Client SDK (firebase/*, 'firebase/firestore' etc.): browser, React Native, Expo
- Admin SDK (firebase-admin): Cloud Functions, server-side API routes ONLY
- NEVER import firebase-admin in client code
- NEVER import firebase/firestore in Cloud Functions, use admin.firestore() instead
- If unsure which environment: look at the enclosing file's directory
  - src/app/*, src/components/*, src/hooks/* → client SDK
  - functions/src/* → Admin SDK
  - app/api/* (Next.js route handlers) → Admin SDK

## Import style (v10 modular, always use this pattern)
- import { initializeApp, getApps, getApp } from 'firebase/app'
- import { getFirestore, collection, doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where, orderBy, limit, onSnapshot, serverTimestamp } from 'firebase/firestore'
- import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
- import { getStorage, ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'
- import { getFunctions, httpsCallable } from 'firebase/functions'
- NEVER use: import firebase from 'firebase/app' (namespaced v8 pattern)
- NEVER use: firebase.firestore(), firebase.auth() (chained v8 API)

## Firebase app initialisation (lib/firebase.ts)
- Check getApps().length before initialising to avoid duplicate-app error
- Export db, auth, storage, functions from lib/firebase.ts
- Client config from environment variables (NEXT_PUBLIC_FIREBASE_*)
- Admin initialised once in lib/firebase-admin.ts with application default credentials or service account

## Services in use
- Firestore (primary database)
- Authentication (Email/Password + Google OAuth)
- Cloud Functions v2 (callable + HTTP triggers)
- Storage (user file uploads, max 50 MB)
- Hosting (production deploy via firebase deploy --only hosting)

## Environment variables
- NEXT_PUBLIC_FIREBASE_API_KEY
- NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
- NEXT_PUBLIC_FIREBASE_PROJECT_ID
- NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
- NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
- NEXT_PUBLIC_FIREBASE_APP_ID
- FIREBASE_ADMIN_CREDENTIALS (server only, JSON string of service account key)
- Variables live in .env.local; NEXT_PUBLIC_ prefix exposes to client bundle

## Emulator suite
- Use emulators for all local development: auth (9099), firestore (8080), functions (5001), storage (9199)
- connectFirestoreEmulator, connectAuthEmulator, connectStorageEmulator, connectFunctionsEmulator
- Emulator connection in lib/firebase.ts gated by process.env.NEXT_PUBLIC_USE_EMULATOR === 'true'
- Never point client code at production Firebase during development

## Firestore security rules
- Rules file: firestore.rules
- Every collection requires explicit rules, no open defaults
- Authenticate before any read or write (request.auth != null)
- IDOR rule: every document read/write verifies request.auth.uid matches the document owner field
- Custom claims checked via request.auth.token.{claimKey}
- NEVER use allow read, write: if true in production rules

## Cloud Functions
- All functions in functions/src/, TypeScript, compiled before deploy
- Use v2 syntax: import { onRequest, onCall } from 'firebase-functions/v2/https'
- onCall functions: verify auth via context.auth, return error if unauthenticated
- onRequest functions: verify ID token manually via admin.auth().verifyIdToken()
- onDocumentCreated / onDocumentUpdated from 'firebase-functions/v2/firestore'
- Set region: europe-west1 (default for this project)
- Set memory and timeout explicitly on compute-heavy functions

## Auth custom claims
- Custom claims set via admin.auth().setCustomUserClaims() in Cloud Functions
- Claims propagate on NEXT ID token refresh, not immediately
- Force refresh on client: await currentUser.getIdToken(true)
- Check claims server-side via admin.auth().verifyIdToken(), do not trust client-sent claims
- Common claims: { role: 'admin' | 'member', tenantId: string }

## Hard rules
- NEVER import firebase-admin in a file under src/ (client code)
- NEVER import firebase/firestore inside functions/src/ (use admin.firestore())
- NEVER write Firestore security rules with allow read, write: if true
- NEVER trust request body for user identity, always derive from auth token
- NEVER skip emulator connection during local development
- ALWAYS check getApps().length before initializeApp()
- ALWAYS verify ID token on onRequest functions (not just onCall)

This template resolves the four most common failure modes by name: SDK boundary, import style, security rules, and emulator connection. Claude follows explicit boundaries more reliably than inferred ones. Naming the exact directories (functions/src/*, src/app/*) gives Claude a decision rule it can apply mechanically when writing or modifying any file.

Firestore reads, writes, and the modular import pattern

The most frequent Claude error on Firebase projects is mixing the v8 namespaced API with the v10 modular API. Both compile. Both appear in training data. Without an explicit constraint, Claude will use whichever pattern it saw most recently, and it may mix them in the same file.

Here is the canonical modular pattern for Firestore operations:

// lib/firebase.ts
import { initializeApp, getApps, getApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { getStorage } from 'firebase/storage'
import { getFunctions } from 'firebase/functions'

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,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}

// Prevent duplicate app initialisation in hot-reload environments
const app = getApps().length ? getApp() : initializeApp(firebaseConfig)

export const db = getFirestore(app)
export const auth = getAuth(app)
export const storage = getStorage(app)
export const functions = getFunctions(app, 'europe-west1')

// Connect to emulators in development
if (
  typeof window !== 'undefined' &&
  process.env.NEXT_PUBLIC_USE_EMULATOR === 'true' &&
  !('_isEmulator' in db)
) {
  const { connectFirestoreEmulator } = await import('firebase/firestore')
  const { connectAuthEmulator } = await import('firebase/auth')
  const { connectStorageEmulator } = await import('firebase/storage')
  const { connectFunctionsEmulator } = await import('firebase/functions')

  connectFirestoreEmulator(db, 'localhost', 8080)
  connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true })
  connectStorageEmulator(storage, 'localhost', 9199)
  connectFunctionsEmulator(functions, 'localhost', 5001)
}

The getApps().length check is the kind of detail Claude skips without the rule in CLAUDE.md. In a Next.js application with hot module replacement, initializeApp is called multiple times without this guard, and Firebase throws a duplicate-app error that only appears in development, not in the production build, which makes it easy to miss before shipping.

For reads and writes, the modular pattern looks like this:

// hooks/useDocument.ts
import { doc, getDoc, setDoc, updateDoc, serverTimestamp } from 'firebase/firestore'
import { db } from '@/lib/firebase'

// Read a single document
export async function getUser(uid: string) {
  const snap = await getDoc(doc(db, 'users', uid))
  if (!snap.exists()) return null
  return { id: snap.id, ...snap.data() }
}

// Write with server timestamp
export async function createUserProfile(uid: string, data: {
  displayName: string
  email: string
  role: 'admin' | 'member'
  tenantId: string
}) {
  await setDoc(doc(db, 'users', uid), {
    ...data,
    createdAt: serverTimestamp(),
    updatedAt: serverTimestamp(),
  })
}

// Partial update
export async function updateUserProfile(uid: string, patch: Partial<{
  displayName: string
  role: string
}>) {
  await updateDoc(doc(db, 'users', uid), {
    ...patch,
    updatedAt: serverTimestamp(),
  })
}

// Real-time listener
import { onSnapshot, query, collection, where, orderBy } from 'firebase/firestore'

export function subscribeToUserPosts(
  uid: string,
  callback: (posts: Post[]) => void
) {
  const q = query(
    collection(db, 'posts'),
    where('authorId', '==', uid),
    orderBy('createdAt', 'desc'),
  )
  return onSnapshot(q, (snap) => {
    callback(snap.docs.map((d) => ({ id: d.id, ...d.data() } as Post)))
  })
}

serverTimestamp() is the correct way to write a creation or modification timestamp. Claude sometimes writes new Date() or Date.now(), which stores the client clock. Client clocks can be wrong, spoofed, or skewed across timezones. serverTimestamp() uses Firebase's server time, which is consistent regardless of client environment. One line in CLAUDE.md rules prevents this across every write operation Claude generates.

Firestore security rules and IDOR prevention

Firestore security rules are where the most consequential errors live, and they are the area where Claude's defaults are least safe. Without explicit rules in CLAUDE.md, Claude will sometimes generate allow read, write: if request.auth != null as a baseline. That is better than if true, but it still allows any authenticated user to read or modify any other user's documents.

IDOR (insecure direct object reference) in Firestore means any authenticated user can call getDoc(doc(db, 'users', 'some-other-uid')) and read private data. The fix is a security rule that verifies the requesting user's UID matches the document they are accessing. Claude generates this correctly every time when the pattern is in CLAUDE.md.

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

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

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

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

    function isValidUserData() {
      return request.resource.data.keys().hasOnly([
        'displayName', 'email', 'role', 'tenantId', 'createdAt', 'updatedAt'
      ]);
    }

    // Users collection: owner read/write, admin read
    match /users/{uid} {
      allow read: if isOwner(uid) || hasRole('admin');
      allow create: if isOwner(uid) && isValidUserData();
      allow update: if isOwner(uid) && isValidUserData();
      allow delete: if hasRole('admin');
    }

    // Posts collection: owner writes, authenticated reads
    match /posts/{postId} {
      allow read: if isAuthenticated();
      allow create: if isAuthenticated()
        && request.resource.data.authorId == request.auth.uid;
      allow update: if isAuthenticated()
        && resource.data.authorId == request.auth.uid;
      allow delete: if isAuthenticated()
        && resource.data.authorId == request.auth.uid;
    }

    // Private subcollection: owner only
    match /users/{uid}/private/{doc} {
      allow read, write: if isOwner(uid);
    }

    // Admin-only collection
    match /adminData/{doc} {
      allow read, write: if hasRole('admin');
    }

    // Default deny: anything not matched above is denied
  }
}

The helper functions (isAuthenticated, isOwner, hasRole) are the pattern to put in CLAUDE.md. Once Claude sees them, it reuses them consistently across every new collection rule it generates. Without them, Claude writes inline checks on every rule and they drift: some rules check request.auth.uid, others check request.auth.token.sub, and the inconsistency produces gaps.

The hasRole function reads from request.auth.token.role. This is a custom claim set by the Admin SDK via admin.auth().setCustomUserClaims(). The claim lives in the Firebase ID token and is available to security rules without a Firestore read, which makes it the right place for role-based access control. Reading the role from a Firestore document in a security rule is possible but expensive and fragile. Custom claims are fast, authoritative, and consistent.

Add the security rules pattern to CLAUDE.md under a dedicated section:

## Firestore security rules patterns

### Helper functions (always define at top of rules)
function isAuthenticated() { return request.auth != null; }
function isOwner(uid) { return isAuthenticated() && request.auth.uid == uid; }
function hasRole(role) { return isAuthenticated() && request.auth.token.role == role; }

### IDOR rule
- Every document read/write must verify ownership: resource.data.ownerId == request.auth.uid
  OR use isOwner(documentId) when the document ID is the user's UID
- NEVER allow read/write scoped only to isAuthenticated() on user-specific data

### Custom claims in rules
- request.auth.token.role, reads the role custom claim
- request.auth.token.tenantId, reads the tenant custom claim
- Claims are set by Admin SDK and refresh on next ID token issue

Cloud Functions v2: onRequest and onCall

Firebase Cloud Functions have two major syntax generations. The v1 syntax (const functions = require('firebase-functions')) is still in heavy rotation in tutorials, Stack Overflow answers, and Claude's training data. The v2 syntax (import { onRequest, onCall } from 'firebase-functions/v2/https') is the current standard, with improved cold start performance, more configuration options, and the ability to set concurrency, memory, and CPU per function.

Claude generates v1 syntax on roughly half of Cloud Function scaffolds on projects without a CLAUDE.md rule. With the rule, it consistently generates v2.

// functions/src/index.ts
import { onRequest, onCall, HttpsError } from 'firebase-functions/v2/https'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
import * as admin from 'firebase-admin'

admin.initializeApp()
const db = admin.firestore()
const authAdmin = admin.auth()

// HTTP trigger, verify ID token manually
export const getUserStats = onRequest(
  { region: 'europe-west1', memory: '256MiB', timeoutSeconds: 30 },
  async (req, res) => {
    // Verify Authorization header
    const authHeader = req.headers.authorization
    if (!authHeader?.startsWith('Bearer ')) {
      res.status(401).json({ error: 'Missing bearer token' })
      return
    }

    let decodedToken
    try {
      decodedToken = await authAdmin.verifyIdToken(authHeader.split('Bearer ')[1])
    } catch {
      res.status(401).json({ error: 'Invalid token' })
      return
    }

    const { uid } = decodedToken
    const snap = await db.collection('users').doc(uid).get()

    if (!snap.exists) {
      res.status(404).json({ error: 'User not found' })
      return
    }

    res.json({ uid, data: snap.data() })
  },
)

// Callable function, auth verification is automatic via context
export const setUserRole = onCall(
  { region: 'europe-west1', memory: '256MiB' },
  async (request) => {
    // context.auth is verified by the SDK, throw if unauthenticated
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Must be logged in')
    }

    // Verify the caller is an admin (from custom claim)
    if (request.auth.token.role !== 'admin') {
      throw new HttpsError('permission-denied', 'Requires admin role')
    }

    const { targetUid, role } = request.data as { targetUid: string; role: string }
    if (!targetUid || !['admin', 'member'].includes(role)) {
      throw new HttpsError('invalid-argument', 'Invalid targetUid or role')
    }

    await authAdmin.setCustomUserClaims(targetUid, { role })

    // Revoke existing tokens so the new claim takes effect immediately
    await authAdmin.revokeRefreshTokens(targetUid)

    return { success: true, uid: targetUid, role }
  },
)

// Firestore trigger, runs when a new document is created
export const onNewUser = onDocumentCreated(
  { document: 'users/{uid}', region: 'europe-west1' },
  async (event) => {
    const uid = event.params.uid
    const data = event.data?.data()
    if (!data) return

    // Set default custom claim on new user creation
    await authAdmin.setCustomUserClaims(uid, {
      role: data.role ?? 'member',
      tenantId: data.tenantId,
    })
  },
)

The onCall vs onRequest distinction is worth making explicit in CLAUDE.md. Callable functions are the right choice for operations that your own client code calls because authentication is handled automatically: request.auth is populated and verified before your function body runs. HTTP triggers are the right choice for webhooks, third-party integrations, or any endpoint that non-Firebase clients need to reach. They require manual token verification with admin.auth().verifyIdToken(). Claude will sometimes skip the manual verification on onRequest functions when it treats them the same as onCall. The CLAUDE.md rule prevents this.

The revokeRefreshTokens call after setting custom claims matters. Without it, the user's existing tokens remain valid and carry the old claims until they naturally expire (up to an hour). Revocation forces the client to fetch a new ID token, which picks up the updated claims. Pair this with a forced getIdToken(true) on the client after calling setUserRole.

Add the Cloud Functions section to CLAUDE.md:

## Cloud Functions rules

### v2 syntax (always, not v1)
import { onRequest, onCall, HttpsError } from 'firebase-functions/v2/https'
import { onDocumentCreated } from 'firebase-functions/v2/firestore'

### onCall vs onRequest
- onCall: use for client-facing operations; request.auth is verified automatically
- onRequest: use for webhooks/third-party endpoints; verify Bearer token manually via admin.auth().verifyIdToken()
- NEVER skip token verification on onRequest functions

### Admin SDK in functions
- import * as admin from 'firebase-admin'
- admin.initializeApp() once at module top
- Use admin.firestore(), admin.auth(), admin.storage(), not the client SDK

### Errors
- Throw HttpsError (not plain Error) for callables: 'unauthenticated', 'permission-denied', 'invalid-argument', 'not-found', 'internal'
- Return structured JSON for onRequest endpoints

### Region and resource config
- region: 'europe-west1' (this project default)
- Set memory and timeoutSeconds on compute-heavy functions

Auth with custom claims and token refresh

Firebase Authentication is simpler than NextAuth or Clerk in that the core flow is handled entirely by the Firebase SDK. The complexity shows up in custom claims, because claims are what you use to encode roles and tenant membership into the ID token so that both your Cloud Functions and your Firestore security rules can access them without an extra database read.

The lifecycle has three steps. First, a Cloud Function sets claims via admin.auth().setCustomUserClaims(). Second, the client refreshes its ID token via currentUser.getIdToken(true). Third, the new token (with claims) is used for all subsequent Firestore reads, security rule evaluations, and API calls.

Without the forced refresh step, developers find that security rules checking request.auth.token.role deny access even after the claim was set, because the client is still presenting the old token. This is the single most common Firebase Auth question on Stack Overflow, and the answer is always the same: force a token refresh on the client after any claim change.

// lib/auth.ts, client-side auth helpers
import {
  getAuth,
  onAuthStateChanged,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signOut as firebaseSignOut,
  GoogleAuthProvider,
  signInWithPopup,
  type User,
} from 'firebase/auth'
import { httpsCallable } from 'firebase/functions'
import { auth, functions } from '@/lib/firebase'

// Sign in with email and password
export async function signIn(email: string, password: string) {
  const credential = await signInWithEmailAndPassword(auth, email, password)
  return credential.user
}

// Sign in with Google OAuth
export async function signInWithGoogle() {
  const provider = new GoogleAuthProvider()
  provider.setCustomParameters({ prompt: 'select_account' })
  const credential = await signInWithPopup(auth, provider)
  return credential.user
}

// Sign out
export async function signOut() {
  await firebaseSignOut(auth)
}

// Get current user's ID token (with optional force refresh)
export async function getIdToken(forceRefresh = false): Promise<string | null> {
  const user = auth.currentUser
  if (!user) return null
  return user.getIdToken(forceRefresh)
}

// Get decoded claims from the current token
export async function getCurrentClaims(): Promise<Record<string, unknown> | null> {
  const user = auth.currentUser
  if (!user) return null
  const result = await user.getIdTokenResult()
  return result.claims
}

// After a role change: force-refresh the token to pick up new claims
export async function refreshClaimsAfterRoleChange() {
  const user = auth.currentUser
  if (!user) return
  await user.getIdToken(true) // forces a network call to Firebase to get a fresh token
}

// Observable auth state for React
export function subscribeToAuthState(callback: (user: User | null) => void) {
  return onAuthStateChanged(auth, callback)
}

// Call the setUserRole Cloud Function (admin only)
export async function setUserRole(targetUid: string, role: 'admin' | 'member') {
  const setRole = httpsCallable<{ targetUid: string; role: string }, { success: boolean }>(
    functions,
    'setUserRole',
  )
  await setRole({ targetUid, role })
  // Force-refresh so the caller's own token reflects any self-change
  await refreshClaimsAfterRoleChange()
}

Add the Auth section to CLAUDE.md:

## Auth custom claims

### Setting claims (Admin SDK / Cloud Function only)
await admin.auth().setCustomUserClaims(uid, { role: 'admin', tenantId: 'acme' })
await admin.auth().revokeRefreshTokens(uid) // force immediate propagation

### Refreshing claims on client after a change
await currentUser.getIdToken(true) // true = force network refresh

### Reading claims client-side
const result = await currentUser.getIdTokenResult()
const role = result.claims.role

### Reading claims in Cloud Functions (from verified token)
const decoded = await admin.auth().verifyIdToken(idToken)
const role = decoded.role

### Reading claims in Firestore rules
request.auth.token.role == 'admin'

### Hard rules
- NEVER trust claims from the client request body, always verify via admin.auth().verifyIdToken()
- ALWAYS force-refresh the client token after any claim change
- NEVER read claims from Firestore, use the token claims

Storage: upload patterns and security rules

Firebase Storage is straightforward for basic file uploads, but Claude generates two recurring issues without guidance. The first is missing progress tracking on uploads, which is needed for any file larger than a few kilobytes. The second is open Storage security rules, which mirror the Firestore issue: Claude sometimes generates allow read, write: if request.auth != null without any size, content-type, or ownership check.

// lib/storage.ts
import {
  ref,
  uploadBytesResumable,
  getDownloadURL,
  deleteObject,
} from 'firebase/storage'
import { storage, auth } from '@/lib/firebase'

export async function uploadUserFile(
  file: File,
  onProgress?: (percent: number) => void,
): Promise<string> {
  const user = auth.currentUser
  if (!user) throw new Error('Must be signed in to upload')

  // Scope to user's UID in the path, mirrors the storage security rules
  const storageRef = ref(storage, `users/${user.uid}/${Date.now()}-${file.name}`)

  return new Promise((resolve, reject) => {
    const task = uploadBytesResumable(storageRef, file)

    task.on(
      'state_changed',
      (snapshot) => {
        const percent = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
        onProgress?.(Math.round(percent))
      },
      reject,
      async () => {
        const url = await getDownloadURL(task.snapshot.ref)
        resolve(url)
      },
    )
  })
}

export async function deleteUserFile(path: string) {
  const storageRef = ref(storage, path)
  await deleteObject(storageRef)
}

The Storage security rules that pair with this pattern:

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

    // User-scoped uploads: only the owner can read/write their files
    match /users/{uid}/{allPaths=**} {
      allow read: if request.auth != null && request.auth.uid == uid;
      allow write: if request.auth != null
        && request.auth.uid == uid
        && request.resource.size < 50 * 1024 * 1024 // 50 MB max
        && request.resource.contentType.matches('image/.*|application/pdf|video/.*');
    }

    // Public assets (avatars, logos): anyone can read, authenticated owner can write
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if request.auth != null
        && request.resource.size < 5 * 1024 * 1024 // 5 MB max for public assets
        && request.resource.contentType.matches('image/.*');
    }
  }
}

Add a Storage section to CLAUDE.md:

## Storage rules

### Path structure (always scope by UID)
- User files: users/{uid}/{filename}
- Public assets: public/{filename}

### Security rules must include
- request.auth.uid == uid (IDOR prevention)
- request.resource.size limit (prevent abuse)
- request.resource.contentType restriction (prevent arbitrary uploads)

### Upload pattern
- Use uploadBytesResumable (not uploadBytes) for files > 100 KB
- Track progress via task.on('state_changed', ...) callback
- Get download URL via getDownloadURL(task.snapshot.ref) in the complete callback

Common failure modes and how to prevent them

These are the patterns Claude generates without a CLAUDE.md, ranked by frequency and impact.

Admin SDK in client code. Claude imports firebase-admin inside a component or hook when it sees both files open in context. The error surfaces at build time (firebase-admin is a Node.js module, not browser-compatible), but only if the bundler catches the import. In some configurations the import is tree-shaken and never errors, leaving dead code in the bundle. The CLAUDE.md rule pointing to specific directories (functions/src/* = Admin, src/* = client) gives Claude a mechanical check to apply on every import.

Missing security rules on new collections. Every time Claude scaffolds a new Firestore collection, you should check that firestore.rules was updated to cover it. If there is no matching rule, Firestore's default-deny behaviour catches the gap, but the error shows up as an unexplained permission-denied at runtime rather than a visible missing rule. Add a rule to CLAUDE.md: "Any new collection added to the data model must have an explicit security rule in firestore.rules before the collection is used in code."

v1 Cloud Function syntax. The tells are const functions = require('firebase-functions'), functions.https.onCall, and functions.region('...'). Any of these in a new Cloud Function means Claude used the v1 API. The code runs but is deprecated and behaves differently under cold starts and concurrent requests. The CLAUDE.md rule naming the v2 import path eliminates this for the functions Claude writes from scratch; watch for it on code Claude modifies from existing v1 functions.

Missing getApps().length guard. In Next.js with hot module replacement, the Firebase app initialisation module is re-executed on every hot reload. Without the guard, each reload tries to initialise a second Firebase app with the same name and throws. This only appears in development; the production bundle initialises once. The guard is one line and the CLAUDE.md rule ensures it appears in every app initialisation.

Client-sent claims trusted server-side. Claude will sometimes generate a Cloud Function that reads request.data.role from the callable's input data and uses it to make authorization decisions. This is the same IDOR problem in a different form: the client can send any value for role. The only trustworthy source of the role is the decoded ID token (request.auth.token.role). The CLAUDE.md hard rule ("NEVER trust request body for user identity") covers this.

Missing token refresh after claim change. Covered in the Auth section above. If a user sets their own role (or an admin sets another user's role) and the client does not call getIdToken(true), the new claim will not appear in subsequent requests until the old token expires. For a role === 'admin' gate, this means the promoted user cannot access admin routes for up to an hour after promotion. The fix is one line on the client; the CLAUDE.md rule makes Claude generate it alongside every setCustomUserClaims call.

Firestore data modelling decisions

Firestore's document model influences query patterns in ways that Claude cannot infer from requirements. Two decisions compound: subcollections vs flat collections, and denormalization strategy.

Subcollections work well when you always query a child in the context of its parent (users/{uid}/posts/{postId}). They work poorly when you need to query across parents (all posts by date, regardless of author) because Firestore does not support cross-collection queries on subcollections in the same way as collection group queries. Add this to CLAUDE.md:

## Firestore data model rules

### Subcollections
- Use when child documents are ALWAYS accessed via the parent (user's private data, order line items)
- Use collection groups (collectionGroup()) when you need to query across all subcollection instances
- NEVER put data in a subcollection if you regularly need to list it across all parents

### Flat collections
- Use for entities you query independently (posts, comments, products)
- Always include the parent's ID as a field (authorId, tenantId) for filtering

### Denormalization
- Denormalize display names and avatars into documents that need them (avoids joins)
- Store only stable or slow-changing data in denormalized fields
- When the source changes (user updates display name), use a Firestore trigger to fan out

### Indexing
- Composite indexes needed for queries with multiple where() and orderBy() clauses
- Run firebase emulators:start and watch for "The query requires an index" errors during development
- Define indexes in firestore.indexes.json, committed to the repo

The denormalization guidance is the one Claude most often needs. Firestore does not support joins, so fetching a list of posts with their author names requires either one read per post (for the author) or storing the author's display name inside each post document. Claude defaults to the join pattern (extra reads) because it is the relational instinct. The CLAUDE.md guidance steers it toward the denormalization pattern, with a note to fan out changes via Firestore triggers when the source data changes.

For managing environment variables across Firebase environments (dev, staging, production), the patterns in Claude Code with environment variables cover .env.local, secret management, and the NEXT_PUBLIC_ prefix conventions that determine what reaches the client bundle. Firebase-specific env work, including FIREBASE_ADMIN_CREDENTIALS, follows the same principles: server-only variables have no NEXT_PUBLIC_ prefix and are never exposed to the browser.

For permission hooks that gate destructive Firebase scripts, the pattern in Claude Code permissions applies directly. Scripts that delete Firestore collections, revoke all user sessions, or redeploy security rules should be in the deny list in .claude/settings.local.json so Claude cannot run them without confirmation. The TypeScript patterns throughout this guide, including strict interfaces for Firestore document shapes, compose with the broader TypeScript configuration in Claude Code with TypeScript.

Building a Firebase project that fails closed

Firebase's flexibility is real and it is the reason developers choose it. It is also the reason Claude Code produces its widest variance on Firebase projects compared to more opinionated stacks. A Firebase project without a CLAUDE.md produces Claude that mixes v8 and v10 imports, imports firebase-admin in client components, writes security rules that check authentication but not ownership, generates v1 Cloud Functions, and trusts client-sent claims for authorization decisions.

A Firebase project with the CLAUDE.md in this guide produces the opposite: modular v10 imports throughout, Admin SDK confined to the server boundary, security rules with helper functions and IDOR checks on every collection, v2 Cloud Functions with explicit region and resource configuration, Auth with forced token refresh after claim changes, and Storage rules with size and content-type limits.

The underlying principle is the same as any framework integration: Claude Code operates at the level of context you give it. For an alternative backend that trades Firebase's NoSQL flexibility for a structured Postgres schema with similar BaaS conveniences, Claude Code with Supabase covers the equivalent CLAUDE.md patterns. For the authentication layer comparison, Claude Code with NextAuth shows how a similar CLAUDE.md approach anchors the callback ordering, session strategy, and multi-tenant scoping decisions that Firebase Auth handles differently.

For the mechanics of how CLAUDE.md is read at session start, the Claude Code setup guide covers the initialisation sequence. Claudify includes a Firebase-specific CLAUDE.md template, pre-configured for the modular SDK, v2 Cloud Functions, security rules patterns, and the Admin SDK boundary rules that prevent the most common Claude Code Firebase failures.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir