The Firestore and Storage rules I actually ship, line by line: admin email claims, published only reads, field whitelists, and the list denial that looks like a bug.
Every few months someone opens an issue on a Firebase project along the lines of "your API key is exposed in the JavaScript bundle." It isn't a vulnerability, and the panic is misplaced — but the instinct behind it is right, just aimed at the wrong thing. A Firebase Web API key is a project identifier, not a credential. It's supposed to be public. What people are actually reaching for when they panic about it is the real question: if anyone can point a Firestore client at my project, what stops them reading everything and writing whatever they like?
The answer is one file. Not obfuscation, not a proxy, not "nobody knows the collection names." Security rules are the entire perimeter, and they're the only part of the stack an attacker can't route around, because they execute on Google's servers before a single byte of your data moves. Everything else — your Flutter code, your admin panel's login screen, the if (isAdmin) check that hides the delete button — is client-side theatre that a curious person can bypass with the browser console and ten minutes.
My portfolio runs 114 blog posts, 98 browser tools, 32 games and an admin panel on Firestore and Cloud Storage, and it costs me exactly $0 a month on the Blaze plan. That $0 is partly a rules property: there are no public writes to content anywhere in the schema, so there is no path by which an anonymous visitor can run up my bill. This post is the rules file I actually ship, section by section, with the reasoning behind each line — including the one behaviour that makes people think their rules are broken when they're working perfectly.
The mental model that keeps rules files honest is to write them backwards from how you'd naturally think about them. Don't start with "the blog should be readable." Start with nothing is readable and nothing is writable, and then make yourself justify every hole you punch in it.
Concretely, that means the last block in my file is this, and it was the first block I wrote:
// Default deny: anything not matched above is locked downmatch /{document=**} { allow read, write: if false;}Firestore's default is already deny — an unmatched path is denied without you saying so. Writing it explicitly anyway matters for two reasons. First, it documents intent: the next person to open the file (which is me, eight months later) sees immediately that this is a closed-by-default schema and not an accidentally incomplete one. Second, it changes how you add things. With the deny at the bottom, adding a feature means consciously adding a match block above it and deciding on read and write separately. Without it, people tend to grow rules by loosening whatever's nearest.
The order of operations I follow when a new collection shows up:
match block with nothing allowed.allow.Step 4 is where most of the real work is, and where most of the bugs live. I'll get to it.
One structural note: rules do not cascade the way file permissions do. A match /posts/{docId} block does not grant anything to /posts/{docId}/comments/{commentId} — subcollections need their own match, or a recursive {document=**} wildcard. This trips people in both directions: they assume a parent grant covers children (it doesn't), or they use a recursive wildcard to save typing and accidentally expose a subcollection they hadn't thought about. Be explicit.
Here's the whole authorisation model for my site:
function isAdmin() { return request.auth != null && request.auth.token.email == 'admin@yourdomain.com';}That's it. One function, one address, no roles collection, no permissions document, no membership lookup. Every write in the entire schema goes through it.
The reason this is safe is worth stating precisely, because it looks flimsy at first glance: the email in request.auth.token is not client-supplied. It's a claim inside a Firebase ID token, signed by Google, and verified server-side before the rule ever runs. You can't set it from the client any more than you can set your own bank balance. To make isAdmin() return true, an attacker has to actually authenticate as that account — which means having the password, or the second factor if you've enabled one. The email address itself isn't a secret and doesn't need protecting; knowing it buys you nothing.
There's a cheaper reason to like it too: it costs zero document reads. A role-collection approach — get(/databases/$(database)/documents/roles/$(request.auth.uid)).data.admin == true — does a real Firestore read on every single rule evaluation, and those are billable. On a site whose entire purpose is to cost nothing, that's a design constraint, not a micro-optimisation.
I want to be honest about the trade, because "single admin identified by email" is genuinely bad advice above a team size of one.
The moment there are two admins, you're editing a rules file and running a deploy to grant access, which is a terrible way to manage people. It doesn't revoke cleanly, it has no audit trail, it can't express "this person can publish posts but not delete apps," and the rules file becomes a personnel record living in git history forever.
The right answer at team scale is custom claims: request.auth.token.admin == true, or request.auth.token.role in ['editor', 'admin'], set through the Firebase Admin SDK. Claims are still signed, still free to check, and they decouple identity from authorisation — you grant and revoke by running a script against a UID, not by deploying rules. The catch, and the reason I haven't done it, is that setting a custom claim requires somewhere privileged to run the Admin SDK, and I have deliberately no Cloud Functions in this project (they're the main way a "free" Firebase project stops being free). A one-off local Node script against a service account key works fine for a handful of users; a real team wants that wired into an onboarding flow.
So: email claim for one person, custom claims the moment there are two. Role collections mostly when the permission model is genuinely dynamic and data-driven, and then accept that you're paying a read for every rule evaluation.
One hardening note I'd add before enabling a second auth provider: check request.auth.token.email_verified == true alongside the address. With a single email/password account it's redundant, but some identity providers will hand you a token carrying an email the user never proved they own, and the whole model rests on that address meaning something.
Content collections split into two shapes. The always-public ones are trivial:
match /profile/{docId} { allow read: if true; allow write: if isAdmin();}The ones with drafts need a predicate:
function isPublished() { return resource.data.get('status', 'draft') == 'published';}match /posts/{docId} { allow read: if isPublished() || isAdmin(); allow create, update, delete: if isAdmin();}Two details in there earn their place. resource (no request. prefix) is the document as it currently exists in the database — this is a read rule, so we're inspecting stored data, not incoming data. And .get('status', 'draft') supplies a default, so a legacy document that predates the status field is treated as a draft rather than blowing up the rule. Defaulting to the closed state is the right instinct: if the data is ambiguous, deny.
Splitting write into create, update, delete rather than using the write shorthand is deliberate too. They're identical here, but when I later needed different logic for create versus update on the leads collection, the block was already shaped to take it.
Now the part that generates the most confused Stack Overflow questions in the whole Firebase ecosystem.
With allow read: if isPublished() in place, this query fails with permission-denied:
FirebaseFirestore.instance.collection('posts').get();Every single document in that collection might be published. It still fails. And the first time it happens it looks completely broken — you can fetch an individual published post by ID and it works, but listing the collection doesn't.
The reason is a rule Firebase states plainly and everyone forgets: security rules are not filters. The server does not run your query, fetch the matching documents, evaluate the rule against each one, and quietly drop the failures. That would be a query-cost and information-leak problem, and it would make query latency depend on how much data you can't see. Instead, Firestore evaluates the rule against the query itself and demands that the query is provably safe before it runs. An unconstrained collection('posts') could match an unpublished draft, therefore the whole request is denied.
allow read covers two operations — get (one document by ID) and list (a query). Fetching a published post by ID satisfies the rule because there's a concrete resource to evaluate. A list has no single resource, so Firestore checks whether your query's constraints guarantee the rule holds for everything it could return.
The fix is to make the constraint explicit in the query:
FirebaseFirestore.instance .collection('posts') .where('status', isEqualTo: 'published') .orderBy('publishedAt', descending: true) .limit(20) .get();Now the query itself proves that every document it can return satisfies isPublished(), and it's allowed. The denial was correct behaviour and the client was wrong — which is exactly the shape of a good security control, and exactly why it feels like a bug.
Two practical consequences. First, your admin panel and your public site need genuinely different query code, not the same call with a different UI. Second, that where plus orderBy combination needs a composite index; mine lives in firestore.indexes.json as status ascending plus publishedAt descending, with a second one adding tags array-contains for the tag pages. You'll find this out from a console error with a link that creates it for you, but it's worth knowing that a rule requiring a filter implies an index requiring deployment — the two are coupled, and forgetting the second one breaks production while the emulator (which builds indexes on the fly) stays green.
resource is what's in the database. request.resource is what the client is trying to put there. Read rules inspect the first; write rules inspect the second. Mixing them up is the single most common rules bug I see, and it fails in the dangerous direction — a create has no existing resource, so a rule that references resource.data on create evaluates against null and denies, which you'll notice; but a rule that checks resource.data.ownerId on update is checking the stored owner, not the incoming one, which may or may not be what you meant.
For anything the public can write, the rule needs to constrain the incoming document hard. My contact form is the only such path on the site, and it looks like this:
match /leads/{docId} { allow create: if request.resource.data.keys().hasOnly( ['name', 'email', 'subject', 'message', 'type', 'budget', 'company', 'read', 'createdAt'] ) && request.resource.data.name is string && request.resource.data.name.size() > 0 && request.resource.data.name.size() < 120 && isValidEmail(request.resource.data.email) && request.resource.data.message is string && request.resource.data.message.size() > 0 && request.resource.data.message.size() < 5000 && request.resource.data.read == false && request.resource.data.createdAt == request.time; allow read, update, delete: if isAdmin();}Four separate defences in there, each closing a specific hole.
The field whitelist. keys().hasOnly([...]) is the most important line in the block. Without it, a client can post a perfectly valid contact form that also carries {isAdmin: true}, or {read: true} to hide itself from my inbox, or 400 junk fields. The rule schema and the app's model class drift apart the moment someone adds a field to one and not the other, and a whitelist makes that drift a failed write instead of silent data pollution. Note it's hasOnly, not hasAll — hasAll checks required keys, hasOnly forbids extras, and you frequently want both.
Size caps on every free-text field. A Firestore document can be just under 1 MiB. A message field with no cap means an anonymous visitor can write a megabyte per submission, as fast as they can loop. That's a storage bill and a bandwidth bill in my admin panel, and it's trivially scriptable. Every string a stranger can set gets a maximum. Note the optional fields get the same treatment — a field being optional doesn't mean it's safe if present:
&& (!('company' in request.resource.data) || (request.resource.data.company is string && request.resource.data.company.size() < 200))Server-controlled fields pinned to their initial value. read == false on create means a submission always lands unread; a client can't ship one pre-marked. Any field whose meaning is owned by your backend rather than the user needs pinning like this, or excluding from the whitelist entirely.
Never trusting client timestamps. request.resource.data.createdAt == request.time is my favourite line in the file. request.time is the server's clock at the moment of evaluation. The only way a client can satisfy that equality is by sending FieldValue.serverTimestamp() — the sentinel that tells Firestore to fill the value in server-side. Anything the client computes itself, including DateTime.now(), fails. This turns "please use a server timestamp" from a code-review convention into an enforced invariant. Without it, a spammer can backdate submissions to the bottom of my sorted inbox, and more generally, any ordering or expiry logic built on a client clock is built on a value the client fully controls.
Cloud Storage rules follow the same shape, with two extra levers:
function isAllowedUpload() { return request.resource.size < 10 * 1024 * 1024 && request.resource.contentType.matches('image/.*|application/pdf');}match /public/{allPaths=**} { allow read: if true; allow write: if isAdmin() && isAllowedUpload(); allow delete: if isAdmin();}match /{allPaths=**} { allow read, write: if false;}request.resource.size is the byte count of the incoming object and contentType is its declared MIME type, and on a bucket where uploads are admin-only these are belt-and-braces — but they're the difference between a compromised admin session costing me an afternoon and costing me a bandwidth bill. On any bucket where users can upload, they are not optional.
The caveat worth naming: contentType is client-declared metadata, not a content inspection. Restricting to image/.* stops someone casually uploading a 200 MB video; it does not stop someone uploading a disguised payload with an image content type. Rules can constrain the envelope, not the contents. If what's inside matters, you need processing behind the upload, and that's a different system.
The structural decision that matters more than either rule: large binaries don't live in Storage at all. APKs and DMGs for my apps sit on GitHub Releases, and the site links out. Download bandwidth is the line item that turns a free Firebase project into a surprising invoice, and the cheapest way to control it is to not serve the large files.
Everything above might read as an argument for pushing all your validation into rules. It isn't, and the distinction is the most useful thing in this post.
Rules are the right place for anything that must hold no matter who is calling — the field whitelist, the size caps, the server timestamp, "only the admin writes here." These are properties an attacker must not be able to violate, so they belong on the server side of the trust boundary, and there is no other server side in a Firebase-only architecture.
Rules are a bad place for everything else, and the constraints of the language tell you why. Rules Language has no loops, no regular-expression capture groups, no way to call out to anything, a hard limit on expression complexity, and a cap on get() calls per evaluation. You can't cross-validate two documents meaningfully. You get no error message back beyond permission-denied, so a user who fills a form wrong sees a generic failure with nothing actionable in it. And every additional get() you add to satisfy a business rule is a billable read on every single request.
So the split I use:
The last piece is testing, because a rules file is executable code that ships with no test coverage by default. @firebase/rules-unit-testing against the emulator lets you assert both directions, and the negative assertions are the ones that matter:
await assertFails(db.collection('posts').get()); // unfiltered list deniedawait assertSucceeds(publishedPostsQuery(db).get()); // filtered list allowedawait assertFails(db.collection('leads').add({ ...ok, isAdmin: true }));I run those with firebase emulators:exec before any rules deploy. Rules changes are the highest-blast-radius deploys in the whole project — one loosened predicate and your drafts are public — and they're also the easiest to get wrong, because the failure is invisible. Nothing errors. Nothing looks different. The data is just readable now.
write into create/update/delete so the block is already shaped for the day the logic diverges.where clause plus the composite index it implies.resource is what's stored and request.resource is what's incoming — write rules must constrain the second with a hasOnly field whitelist, size caps on every free-text field, and server-owned fields pinned to their initial values.request.time forces the client to use FieldValue.serverTimestamp(), turning "don't trust client clocks" from a convention into an invariant the server enforces.The rules file is the smallest, highest-leverage piece of code in a Firebase project. Mine is 136 lines and it's the only thing standing between an anonymous visitor and every document I own — and because it's server-side, it's the only part of my stack whose guarantees survive a determined person with the browser console open. Write the deny first. Open the narrowest possible hole. Constrain every incoming document by shape and by size. Let the server set the clock. And when a query comes back permission-denied and the data looks like it should have been readable, consider — before you loosen anything — that the rule might simply be right.