# Firebase

> Use when building on Firebase — Firestore data modeling, Security Rules, Auth and custom claims, Cloud Functions, Storage, modular Web/Admin SDK imports — including symptoms like a database open to the internet, a query rejected by rules, or a doc stuck at ~1 write/sec. NOT managed-Postgres BaaS with SQL and RLS (that is supabase).

- Skill: `ericrisco/firebase` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add ericrisco/firebase`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/firebase/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/firebase

---


# Firebase — Firestore, Rules, Auth, Functions, Storage

The Firebase product surface that sits on top of GCP, on the modular Web SDK (v12) and the Admin SDK.
The whole skill exists to stop two failure modes: dragging relational/SQL habits into a NoSQL
document store, and leaving the database open to the internet.

Two facts drive everything below:

- **It is a NoSQL document store, shaped for the read path.** No joins, no server-side `OR` across
  different fields without a composite index, no `SELECT *` across collections. Denormalize and
  fan-out so a screen is one cheap query — reads are what you pay for and what users wait on.
- **Rules ARE the access control.** Firestore is reachable directly from untrusted clients. There is
  no app server in the trust path by default — `firestore.rules` (CEL) is the only thing between a
  browser and your data. App Check attests the request even came from your app before Rules evaluate.

Not this skill:

| Instead of Firebase | Go to |
|---|---|
| Relational schema, SQL, EXPLAIN, indexing a SQL engine | `../postgresdb/SKILL.md` |
| Managed Postgres BaaS (SQL + Postgres RLS + PostgREST) — the most-confused sibling: same "backend-as-a-service" shape, completely different data model and rules language | `supabase` |
| AWS document/key-value store with its own capacity model | `dynamodb` |
| Self-hosted Mongo document modeling | `mongodb` |
| Generic GCP project/IAM/billing not specific to a Firebase product | `gcp-essentials` |
| React/Next.js component or rendering work that merely calls Firebase | `react` / `../nextjs/SKILL.md` |

## Data modeling

Firestore charges and waits on reads. Model so the common screen is one query against one collection.

**Collection vs subcollection vs root + denormalized field — decide by access pattern:**

| Shape | Use when | Why |
|---|---|---|
| Subcollection (`rooms/{id}/messages`) | Child list is only ever read inside its parent, can grow unbounded | Subcollections don't bloat the parent doc; deleting a parent does NOT delete them (handle that) |
| Separate root collection + foreign id | Child must be queried across all parents (collection-group query) | A `collectionGroup('messages')` query needs the docs in same-named subcollections OR a root collection |
| Denormalized field on the parent | A few values are shown alongside the parent and rarely change | Avoids a second read; you accept writing the copy on every change |

**Hard limits — design around them, don't discover them in prod:**

- A document maxes out at **1 MiB (1,048,576 bytes)**. Don't accumulate an unbounded array (chat
  messages, audit log) inside one doc — it will hit the wall and every read pays for the whole blob.
  Use a subcollection.
- A single document tolerates only **~1 sustained write/sec**. Monotonic IDs and indexed sequential
  timestamps create a hotspot on one index range. Use scattered auto-IDs (`doc(collection(db,'x'))`),
  and for high-frequency counters use a **sharded counter** (N shard docs, sum on read).

**Query reality:** no joins; range/inequality filters on a field plus an `orderBy` on another field
require a **composite index**; `in` / `array-contains-any` are capped (~30 values). If a query needs
an index, declare it in `firestore.indexes.json` — see the emulator gotcha below.

```ts
// Bad — unbounded array inside one doc; hits 1 MiB, every read pays for all of it
await setDoc(doc(db, "rooms", roomId), { messages: [...allMessages, newMsg] });

// Good — one doc per message in a subcollection, scattered auto-ID, no hotspot
await addDoc(collection(db, "rooms", roomId, "messages"), {
  text, authorId, createdAt: serverTimestamp(),
});
```

Denormalization recipes, counter sharding, cursor pagination, `getCountFromServer`, collection-group
queries, and composite-index design live in `references/data-modeling.md`.

## Security Rules — the load-bearing section

**Rules are NOT filters.** A query is rejected outright unless the rules can guarantee *every* matched
document is readable — Firestore will not silently drop the docs you can't see. So a `list` rule and
the query that runs against it must agree: if the rule allows reading only your own docs, the query
must itself be constrained (`where("ownerId","==",uid)`), or the whole query fails.

```javascript
// Bad — the entire database is readable AND writable by anyone on the internet
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} { allow read, write: if true; }
  }
}

// Good — default-deny, ownership-scoped, with create-time validation
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postId} {
      allow get:    if resource.data.ownerId == request.auth.uid;
      allow list:   if request.auth != null;            // query MUST add where(ownerId == uid)
      allow create: if request.auth.uid == request.resource.data.ownerId;
      allow update, delete: if resource.data.ownerId == request.auth.uid;
    }
    // everything else: no rule = denied
  }
}
```

Rules to internalize:

- **Default-deny.** No matching `allow` = denied. Never add a `/{document=**}` catch-all with
  `if true`. That single line is the "open to the internet" headline risk.
- **`request.auth`** is the authenticated identity (null when signed out); `request.auth.token`
  carries **custom claims** for RBAC (e.g. `request.auth.token.admin == true`).
- **`resource.data`** is the existing doc; **`request.resource.data`** is the incoming write. Validate
  the incoming write on `create`/`update` (types, immutable `ownerId`, no privilege escalation).
- **`get()` / `exists()`** read another doc for cross-document checks (e.g. role lookup) — each costs
  a billed read and counts against rule-evaluation limits, so keep them shallow.
- **`get` vs `list`** are distinct: a single-doc read vs a query. `read` = both; split them so a
  query can't leak documents a single `get` would also have blocked.

Set custom claims with the Admin SDK, never from the client. Add **App Check** in production so Rules
only run for requests that provably came from your real app.

Full CEL patterns — RBAC via claims, ownership, validation functions, time-based throttling, and the
complete `@firebase/rules-unit-testing` recipe — are in `references/security-rules.md`.

## Auth

- **Client sign-in** with `getAuth()` + a provider; the SDK manages the refresh of the ID token.
- **Server-side, verify the ID token** with `getAuth(adminApp).verifyIdToken(idToken)` before trusting
  any caller. A raw UID from the client is not proof of anything.
- **Custom claims for RBAC:** `getAuth(adminApp).setCustomUserClaims(uid, { admin: true })`. Claims
  land in `request.auth.token` in Rules and in the decoded token on the server. They refresh on the
  client's next token refresh, not instantly — force a refresh if you need it immediately.
- **Session cookies** (`createSessionCookie`) suit SSR / server-rendered apps where you want an
  httpOnly cookie instead of shipping the ID token to every request — pairs with `../nextjs/SKILL.md`.

## Cloud Functions (2nd gen)

2nd gen is the default and the only generation that runs **Node.js 22**. Use `firebase-functions` v7
modular triggers and `firebase-admin`.

```ts
import { onDocumentWritten } from "firebase-functions/v2/firestore";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

const STRIPE_KEY = defineSecret("STRIPE_KEY"); // never hard-code secrets

export const onPostWrite = onDocumentWritten(
  { document: "posts/{postId}", region: "europe-west1" },
  async (event) => {
    // Background events deliver AT-LEAST-ONCE — make this idempotent.
    const eventId = event.id; // dedupe on this (e.g. a processed/{eventId} marker doc)
  }
);

export const setAdminClaim = onCall(async (request) => {
  if (request.auth?.token.admin !== true) throw new HttpsError("permission-denied", "admins only");
  // ... verify, then setCustomUserClaims via admin SDK
});
```

- **Callable** (`onCall`) gives you `request.auth` already verified; raw **`onRequest`** HTTPS does not
  — you must verify the ID token yourself.
- **Idempotency is mandatory** for background triggers (`onDocumentWritten` etc.): events can fire more
  than once, so guard side effects with the event id.
- Pin **region**, set **secrets** with `defineSecret` (not env literals), and tune concurrency for cost.
- Functions require the **Blaze** plan; outbound networking from a function also requires Blaze.

Trigger catalogue, callable-vs-HTTPS auth, idempotency keys, cold-start/cost, Auth blocking functions,
and region pinning are in `references/cloud-functions.md`.

## Cloud Storage

Storage paths are gated by their own Rules; clients can hit them directly.

```javascript
// Bad — any signed-in user can overwrite any other user's avatar
match /avatars/{fileName} { allow write: if request.auth != null; }

// Good — path-scoped to the owner, with a size/type guard
match /avatars/{uid}/{fileName} {
  allow read:  if true;                          // public avatars
  allow write: if request.auth.uid == uid
               && request.resource.size < 5 * 1024 * 1024
               && request.resource.contentType.matches('image/.*');
}
```

For server-issued time-limited access (private downloads), generate a **signed URL** from the Admin
SDK rather than loosening the Rules.

## SDK & project mechanics

Use the **modular** SDK so the bundler tree-shakes unused Firebase code. The old namespaced
`firebase.firestore()` API is gone in v9+.

```ts
// Bad — pulls the entire SDK; defeats tree-shaking (and the compat/namespaced API is legacy)
import firebase from "firebase";
firebase.firestore().collection("posts").get();

// Good — named imports, only what you use ships (Web SDK v12)
import { initializeApp } from "firebase/app";
import { getFirestore, collection, getDocs } from "firebase/firestore";
const db = getFirestore(initializeApp(config));
const snap = await getDocs(collection(db, "posts"));
```

- `firebase.json` configures emulators, rules/index file paths, and hosting; `firestore.indexes.json`
  declares composite indexes.
- Run the **Local Emulator Suite** (`firebase emulators:start`) for local dev and tests.
- **Emulator gotcha:** the Firestore emulator does NOT enforce composite indexes — it runs any valid
  query. So "works in the emulator, fails in prod with *requires an index*" is expected. Verify index
  coverage separately by keeping `firestore.indexes.json` in sync and deploying it.
- The Firebase API key in client config is **not a secret** (it identifies the project, not authorizes
  access — Rules + App Check do that). Service-account JSON keys ARE secrets; keep them server-side.

## Anti-patterns

| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
| `allow read, write: if true;` catch-all | Whole DB is open to the internet | Default-deny; scope each `match` to `request.auth` + ownership |
| Treating rules as query filters | Query is rejected, not filtered — it fails entirely | Constrain the query to match what `list` allows |
| Unbounded array in one document | Hits the 1 MiB limit; every read pays for the whole blob | Subcollection, one doc per item |
| Monotonic IDs / sequential indexed timestamps | Index hotspot → ~1 write/sec/doc wall | Scattered auto-IDs; sharded counters for high write rate |
| Trusting client writes for sensitive fields | Client can set `role: "admin"` on itself | Validate `request.resource` in Rules; set claims via Admin SDK only |
| No App Check in production | Rules run for any caller, including scripts/scrapers | Enable App Check (reCAPTCHA / Play Integrity / App Attest) |
| Service-account key in client / repo | Full admin access leaks | Keep service-account JSON server-side; client API key is fine |
| Namespaced/compat SDK (`firebase.firestore()`) | Legacy, not tree-shakeable, gone in modular | Modular named imports from `firebase/firestore` |
| No emulator / rules tests | Open or broken rules ship silently | `@firebase/rules-unit-testing` via `firebase emulators:exec` |
| Background trigger with non-idempotent side effects | At-least-once delivery double-charges/double-writes | Dedupe on `event.id` |

## Verify

`scripts/verify.sh` is read-only and runs from your project root. It locates `firestore.rules` and
fails if a root `match /{document=**}` carries an `allow read, write: if true;` catch-all or the rules
file is empty; validates `firestore.indexes.json` parses as JSON; and, when the Firebase CLI is
present, points at the `firebase emulators:exec` rules-test path. It exits 0 and skips cleanly when no
Firebase artifacts are in the working directory — not every repo has them.

