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).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill firebase --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Firebase?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-firebase)More formats (shields.io, HTML) on the badges page.
---
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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!