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).
Installs into .claude/skills of the current project.
Are you the author of Firebase?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-firebase)
---
name: firebase
description: "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)."
tags: [firebase, firestore, security-rules, cloud-functions, auth]
recommends: [secure-coding, gcp-essentials, nextjs]
origin: risco
---
# 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.