Firebase Backend skill for Claude Code
Query-shaped Firestore models guarded by tested rules. Claude models Firestore collections from the app's actual queries, avoiding hotspots and unbounded documents, and writes Security Rules that check ownership, roles and the exact shape of every write. Rules are proven with allowed and denied cases on the Emulator Suite before deploy, and 2nd gen Cloud Functions dedupe retried events and keep privileged fields out of client reach.
- name
firebase-backend - size209 lines · ≈2,400 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Firebase backends - Firestore data modeling for the queries the app runs, composite indexes, hotspot and document-size limits, transactions and batched writes, Security Rules that validate auth and data shape, rules unit tests on the Emulator Suite, Cloud Functions (2nd gen) triggers with idempotency and secrets, App Check, and deploys with the Firebase CLI. Use when designing Firestore collections, writing or auditing firestore.rules or storage.rules, adding Cloud Functions triggers, testing with emulators, or debugging permission-denied and missing-index errors. Try asking
- Design the Firestore structure for a team chat app with these screens
- Our Firestore rules allow too much. Tighten them and add tests
- This Cloud Function trigger runs twice sometimes and double counts
Use it when
- Designing or restructuring Firestore collections
- Writing or auditing firestore.rules or storage.rules
- Adding Cloud Functions triggers or callable functions
- Hitting permission-denied or missing-index errors
Skip it when
- The backend is Postgres-based Supabase — use supabase-backend
- You are building an offline sync engine across platforms — use offline-sync
How to check the result
Rules tests pass under emulators:exec with denied cross-user and invalid-shape cases, compound queries have committed indexes, and duplicate trigger events produce one effect.
The common mistake it prevents
Expecting Security Rules to filter query results, when a query that could return any disallowed document fails entirely.
With Firebase, clients talk to the database directly, so Security Rules are the authorization layer and the data model is dictated by queries. A sound project models collections for its read patterns, validates every write in rules, tests rules on the emulator before deploying, and writes Cloud Functions that tolerate retries and duplicate events.
Workflow
- [ ] 1. Inspect firebase.json, .firebaserc (projects/aliases), firestore.rules, firestore.indexes.json, functions/
- [ ] 2. List the app's queries and screens; model collections from them
- [ ] 3. Write rules for each collection: auth, ownership, data validation
- [ ] 4. Rules unit tests on the emulator (allowed and denied cases)
- [ ] 5. Indexes for compound queries; commit firestore.indexes.json
- [ ] 6. Cloud Functions for privileged or derived work; idempotent
- [ ] 7. Deploy rules/indexes/functions per environment; monitor
2. Modeling Firestore
Start from queries, not entities:
| Question | Guidance |
|---|---|
| Embed or separate documents? | Embed small, bounded data read together (address in user). Separate when unbounded (messages), independently secured, or updated by different writers |
| Subcollection or root collection? | Subcollection (users/{uid}/orders) for data owned by a parent and secured by it; root collection with an owner field when you query across parents (or use collection group queries) |
| Need joins? | Denormalize: copy the fields a list screen needs (author name, avatar) and update copies with a function on change |
| Counters/aggregates? | count(), sum(), average() aggregation queries for reads; distributed counters for very high write rates |
| Many-to-many? | Membership docs (orgs/{orgId}/members/{uid}) plus a denormalized memberIds array (bounded size) for array-contains queries |
Limits and performance rules:
- Document max 1 MiB; keep documents small. Arrays and maps that grow without bound belong in subcollections.
- Sustained writes to a single document: plan for about 1 per second.
- Avoid monotonically increasing values (timestamps, sequential IDs) as document IDs or in single-field indexes on high-write collections: they create hotspots. Use auto-generated IDs; exempt such fields from indexing where you never query them.
- Ramp new high-traffic collections gradually (the “500/50/5” guidance: start at 500 ops/s and increase 50% every 5 minutes).
- Queries need an index for every combination of filters and orders beyond single
fields; the error message includes a console link that creates it. Capture it in
firestore.indexes.json(firebase firestore:indexes > firestore.indexes.json) so it is versioned. - Paginate with
limit()+startAfter(lastDoc); offsets are billed for skipped docs.
Transactions and batches
- Transactions for read-modify-write (stock, balances); they retry on contention, so the function must be side-effect free.
- Batched writes for multiple writes that must succeed together without reads.
- Use
FieldValue.increment(),arrayUnion(),serverTimestamp()to avoid races for simple updates.
3. Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() { return request.auth != null; }
function isOwner(uid) { return signedIn() && request.auth.uid == uid; }
match /users/{uid} {
allow read: if isOwner(uid);
allow create: if isOwner(uid)
&& request.resource.data.keys().hasOnly(['displayName', 'photoURL', 'createdAt'])
&& request.resource.data.displayName is string
&& request.resource.data.displayName.size() <= 80
&& request.resource.data.createdAt == request.time;
allow update: if isOwner(uid)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayName', 'photoURL']);
allow delete: if false;
}
match /orgs/{orgId}/projects/{projectId} {
allow read: if signedIn() && exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
allow write: if signedIn()
&& get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)).data.role in ['admin', 'editor'];
}
}
}
Rules principles:
- Default deny: anything not matched is denied. Never deploy
allow read, write: if trueor time-based test-mode rules to production. - Rules are not filters: a query must include constraints that guarantee every result is
allowed (e.g.
where('ownerId', '==', uid)), or the whole query fails. - Validate shape on create and update: allowed keys (
hasOnly), types, sizes, immutable fields (affectedKeys()), server timestamps (== request.time). - Role and claim checks: custom claims (
request.auth.token.admin == true) set by the Admin SDK, or membership documents read withget()/exists()(each costs a read and counts toward per-request limits). - Privileged fields (role, plan, balance) are never client-writable; change them in Cloud Functions with the Admin SDK, which bypasses rules.
More patterns (Storage rules, rate limiting, collection group rules, common mistakes) are in references/rules-patterns.md; read it when writing rules beyond simple ownership.
4. Emulator tests
firebase emulators:start --only auth,firestore,functions,storage --import ./seed --export-on-exit
firebase emulators:exec --only firestore "npm test" # CI: start, test, stop
import { initializeTestEnvironment, assertFails, assertSucceeds } from "@firebase/rules-unit-testing";
import { readFileSync } from "node:fs";
import { doc, getDoc, setDoc, serverTimestamp } from "firebase/firestore";
const env = await initializeTestEnvironment({
projectId: "demo-orders",
firestore: { rules: readFileSync("firestore.rules", "utf8") },
});
test("users cannot read other profiles", async () => {
const alice = env.authenticatedContext("alice").firestore();
await assertFails(getDoc(doc(alice, "users/bob")));
});
test("users can create their own profile with valid fields", async () => {
const alice = env.authenticatedContext("alice").firestore();
await assertSucceeds(setDoc(doc(alice, "users/alice"), { displayName: "Alice", createdAt: serverTimestamp() }));
});
Use a demo-* project ID so emulators never touch real resources. Test denied cases as
thoroughly as allowed ones. Call env.clearFirestore() between tests.
6. Cloud Functions (2nd gen)
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { setGlobalOptions } from "firebase-functions/v2";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
import { initializeApp } from "firebase-admin/app";
initializeApp();
setGlobalOptions({ region: "europe-west1", maxInstances: 10 });
const stripeKey = defineSecret("STRIPE_KEY");
export const onOrderCreated = onDocumentCreated({ document: "orders/{orderId}", secrets: [stripeKey] }, async (event) => {
const db = getFirestore();
const marker = db.doc(`processedEvents/${event.id}`); // event.id is stable across retries
await db.runTransaction(async (tx) => {
if ((await tx.get(marker)).exists) return; // duplicate delivery
tx.set(marker, { at: FieldValue.serverTimestamp() });
tx.update(db.doc(`stats/global`), { orders: FieldValue.increment(1) });
});
});
export const setRole = onCall({ enforceAppCheck: true }, async (req) => {
if (req.auth?.token.admin !== true) throw new HttpsError("permission-denied", "admins only");
// ...
});
- Event-triggered functions are at-least-once: dedupe with
event.idor make writes idempotent. - Avoid infinite loops: a function triggered by writes to a document must not write back to the same document unconditionally; check whether the change is needed first.
- Secrets via
defineSecret(Secret Manager); plain params viadefineString/.envfiles. - Set
minInstancesonly for latency-critical functions (billed while idle); setmaxInstancesto protect downstream systems. - Enable App Check for callable functions and Firestore to reduce abuse from non-app clients.
7. Deploy
firebase use staging # alias from .firebaserc
firebase deploy --only firestore:rules,firestore:indexes
firebase deploy --only functions:onOrderCreated
firebase deploy --only storage
Deploying rules or functions to production requires user confirmation. Deleting functions or indexes removes them in production immediately.
Gotchas
permission-deniedon a query that “should” work usually means the query lacks the constraint the rule requires.get()in rules reads documents as the rules engine, adding latency and read costs; cache roles in custom claims for hot paths (claims refresh on token refresh, up to 1 h).- Deleting a document does not delete its subcollections.
- Firestore
in/array-contains-anyfilters have value-count limits; check current docs. - Client SDK offline persistence can make writes appear successful locally before rules reject them.
Verification
- Rules tests pass in
firebase emulators:exec, covering denied cross-user reads, invalid shapes, and privileged field writes. - Every compound query used in code has an entry in
firestore.indexes.json. - Functions tests (emulator) show duplicate events produce one effect.
- After deploy, Firestore usage and function error metrics show no spike in denials or failures.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/rules-patterns.md
# Security Rules patterns
Read when rules go beyond "owner can read and write their document".
## Reusable validation functions
```
function isValidProject(data) {
return data.keys().hasAll(['name', 'ownerId', 'createdAt'])
&& data.keys().hasOnly(['name', 'description', 'ownerId', 'createdAt', 'updatedAt'])
&& data.name is string && data.name.size() >= 1 && data.name.size() <= 200
&& (!('description' in data) || (data.description is string && data.description.size() <= 5000))
&& data.ownerId is string;
}
match /projects/{id} {
allow create: if isOwner(request.resource.data.ownerId)
&& isValidProject(request.resource.data)
&& request.resource.data.createdAt == request.time;
allow update: if isOwner(resource.data.ownerId)
&& isValidProject(request.resource.data)
&& request.resource.data.ownerId == resource.data.ownerId // immutable owner
&& request.resource.data.createdAt == resource.data.createdAt // immutable creation time
&& request.resource.data.updatedAt == request.time;
}
```
`request.resource.data` is the document as it would be after the write (merged for
updates); `resource.data` is the current stored document.
## Role-based access with membership documents
```
function memberRole(orgId) {
return get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)).data.role;
}
function isMember(orgId) {
return exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
}
match /orgs/{orgId} {
allow read: if isMember(orgId);
allow update: if memberRole(orgId) == 'admin';
match /members/{uid} {
allow read: if isMember(orgId);
allow write: if memberRole(orgId) == 'admin'; // users cannot add themselves
}
}
```
Each `get()`/`exists()` is a billed read, and a single rules evaluation has a limit on
document access calls (check current limits). For hot paths, mirror roles into custom
claims with the Admin SDK: `request.auth.token.orgRoles[orgId] == 'admin'`. Claims are
limited to 1000 bytes and only refresh when the ID token refreshes.
## Collection group queries
Rules for collection group queries must use a recursive wildcard match:
```
match /{path=**}/comments/{commentId} {
allow read: if resource.data.visibility == 'public';
}
```
The query must filter on `visibility == 'public'` for it to be allowed.
## Rate limiting writes (coarse)
Rules can compare against a stored timestamp to limit write frequency per user:
```
match /users/{uid}/posts/{postId} {
allow create: if isOwner(uid)
&& request.time > get(/databases/$(database)/documents/users/$(uid)).data.lastPostAt + duration.value(30, 's');
}
```
The client (or a function) must update `lastPostAt` in the same batch; validate that with
`getAfter()`:
```
&& getAfter(/databases/$(database)/documents/users/$(uid)).data.lastPostAt == request.time
```
For real abuse protection, combine App Check with server-side limits in Cloud Functions.
## Storage rules
```
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /avatars/{uid}/{fileName} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == uid
&& request.resource.size < 5 * 1024 * 1024
&& request.resource.contentType.matches('image/(png|jpeg|webp)');
}
match /{allPaths=**} {
allow read, write: if false;
}
}
}
```
Storage rules can call `firestore.get()` / `firestore.exists()` to check membership in
Firestore (cross-service rules), at extra latency and cost. Content type is client-supplied;
validate actual content in a function if it matters.
## Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| `allow read, write: if request.auth != null;` at the root | Any signed-in user (including anonymous auth) reads and writes everything | Per-collection rules with ownership checks |
| Overlapping `match` blocks where one allows | Any matching allow grants access (rules are OR'ed) | Audit all matches for a path; avoid broad recursive wildcards with allows |
| Validating only on create | Updates can set any fields | Validate on update with `affectedKeys()` |
| Trusting `request.resource.data.role` | Users promote themselves | Make role fields immutable from clients |
| `allow list` missing constraints | Users enumerate collections | Split `get` and `list`; require query constraints for `list` |
| Test-mode expiry rules left in place | Database open until the date, then fully closed | Replace with real rules before launch |
## Rule playground and coverage
- The Rules Playground in the console simulates single requests against deployed rules.
- The Firestore emulator exposes a rules coverage report at
`http://localhost:8080/emulator/v1/projects/<project>:ruleCoverage.html` after tests
run; use it to find untested branches.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.