Abuse proof a free Firebase backend with Firestore security rules and App Check: cap query limits, block scraping, denormalize reads, and stop denial of wallet.
Every public Firebase app ships its config to the client. The apiKey, the project ID, the Firestore endpoint — all of it sits in the JavaScript bundle or the decompiled APK, in plain sight. That's not a leak; it's how the SDK is designed to work. The Firebase apiKey isn't a secret credential — it's a project identifier, and Google's own docs say as much. So the honest question isn't "how do I hide my backend?" — you can't — it's "what stops someone who has already read my config from opening a script and hammering my database until my bill explodes or they've walked off with every document I own?"
I run my portfolio platform on Firebase's Blaze plan with a self-imposed hard rule: it must cost $0. No Cloud Functions, binaries live on GitHub Releases, and every read and write goes straight from the client to Firestore. That constraint forces a discipline most tutorials skip: when there's no server-side middleware to rate-limit or sanitize requests, **your Firestore security rules and App Check are the middleware**. There's no Express layer to reject a malformed body, no API gateway to throttle a burst, no server to authorize a query before it runs. The rules engine is the only thing standing between a curl loop and your bill. Here's how I design that layer so it actually holds up.
People conflate these, and the fixes are unrelated. If you remember one thing from this post, make it this distinction — because a defense against one does almost nothing against the other:
App Check attacks the first by class. Rules handle the second — and, used cleverly, help with the first too. Keep both attacks in your head as you read the rest of this; every technique below maps to one or both.
App Check is the piece people skip because they confuse it with Firebase Auth. They're orthogonal, and you want both. Auth answers "who is this user?" App Check answers "is this request coming from a build of my app I actually shipped?" A scraper script running curl against your Firestore REST endpoint has no valid attestation token, so with App Check enforced, it's rejected at the edge — before your security rules even run, and before you're billed for the read.
On the client you register a platform-appropriate provider. Play Integrity on Android, DeviceCheck or App Attest on Apple platforms, reCAPTCHA Enterprise or the v3 provider on the web:
await FirebaseAppCheck.instance.activate( androidProvider: AndroidProvider.playIntegrity, appleProvider: AppleProvider.appAttest, webProvider: ReCaptchaV3Provider('your-site-key'),);Each provider works by asking the platform to vouch for your app: Play Integrity asks Google Play whether this really is your signed APK on a genuine device, App Attest asks Apple to sign an assertion tied to your app's key, reCAPTCHA scores how bot-like the browser session looks. Firebase exchanges that attestation for a short-lived App Check token that the SDK then attaches to every Firestore, Storage, and Realtime Database request automatically — activating it once at startup is enough.
Two things I learned the hard way. First, turn on enforcement per-service in the console, and only after you're confident real clients are sending valid tokens. App Check ships in monitoring mode first for a reason: the dashboard shows you the ratio of verified-to-unverified requests so you can watch it climb toward 100% as your updated app rolls out. Flip enforcement too early and you'll lock out your own users while you're still debugging attestation on some OEM Android device that ships a broken Play Integrity, or a corporate proxy that mangles reCAPTCHA. Watch the metrics for a full release cycle, then enforce.
Second, App Check is not a wall against a determined attacker. Tokens can be lifted from a rooted or jailbroken device, replayed for their (short) lifetime, and the web providers are heuristic by nature — a good headless-browser farm will pass reCAPTCHA some of the time. Treat App Check as a very effective cost-and-friction multiplier that filters out the lazy 95%: the drive-by scraper, the copy-pasted curl loop, the script kiddie pointing a scraper at your REST endpoint. It is not authorization. That's what rules are for, and rules are where the real bounding happens.
Here's the mental shift that changed how I write Firestore security rules. Most people write allow read as a boolean — can this user see this doc, yes or no. But a Firestore list rule is evaluated against the query itself, before any documents are read, and it can inspect request.query.limit and the query's where clauses. That's the lever nobody talks about: you can make an unbounded query structurally illegal. The database refuses to run it at all, so you're never billed for the reads it would have produced.
It helps to know that allow read is shorthand for two finer-grained operations: get (fetching a single document by ID) and list (running a query that returns many). Splitting them lets you be generous with single-doc reads and strict with bulk ones:
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { // Public collection: readable, but never in bulk. match /posts/{postId} { // Single-doc reads by ID: fine, as long as it's published. allow get: if resource.data.published == true; // Bulk queries: only if the client caps the page size // AND only asks for published docs. No cap, no data. allow list: if request.query.limit <= 30 && request.query.where.published == true; } // Per-user data: strict ownership, no cross-user enumeration. match /users/{uid}/private/{docId} { allow read, write: if request.auth != null && request.auth.uid == uid; } }}The allow list rule is the interesting one. A scraper wanting all your posts has to paginate 30 at a time, and each page still has to satisfy the published == true filter — so drafts are invisible even to a query that explicitly tries to grab them. You've turned "download the whole collection in one request" into "make thousands of small, well-behaved, rate-limitable requests," which is both slower for the attacker and trivially detectable in your usage graphs. The limit ceiling also caps your worst-case cost per request to a known number, which is the whole point of a rate limiter.
One subtlety worth internalizing: the rule constrains the query, not the result. If a client asks for 500 documents, Firestore doesn't clamp the result to 30 — it rejects the whole request with a permission error. Your app code has to request .limit(30) (or fewer) explicitly, or its own reads fail too. That's a feature: it forces every legitimate read path in your codebase to declare its bound up front, which is exactly the audit you want.
A hard rule I follow: never expose a collection with an unbounded list. If a screen genuinely needs a big list, it almost always wants a denormalized read instead — a single pre-aggregated document. Which brings me to the most important move of all.
The most effective anti-billing move isn't a rule at all; it's data modeling. If your home screen shows "1,240 published posts," do not compute that by reading 1,240 documents on every page load. That's 1,240 billed reads per visitor, and an attacker refreshing in a loop is now multiplying your bill by hand — denial-of-wallet handed to them on a plate by your own architecture.
Instead, keep a single summary document — say stats/home holding { postCount: 1240, latestSlugs: [...] } — and read that. One document read serves the entire screen no matter how large the underlying collection grows. Since I can't run Cloud Functions on my $0 setup, I update these counters from the admin client inside a transaction when I publish, not on every visitor's read path. A transaction guarantees the counter and the document stay consistent even if two publishes race. The visitor's side of the app becomes almost entirely single-document gets and bounded lists — exactly the shape rules can police tightly.
This is the quiet win, worth stating as a principle: a well-denormalized Firestore has a small, fixed surface of legal read patterns, and rules can enumerate all of them. A sprawling normalized schema forces you to allow broad, open-ended queries, and every broad query is an attack surface. Good data modeling and good security are the same work here — the shape that's cheap to read is also the shape that's easy to lock down.
Reads get all the attention, but writes are where a bill really runs away — and where garbage lands in your data. A single unbounded write path lets an attacker inflate your storage, poison your collections with junk, or smuggle in fields your app never expected. On my public collections I lock writes down to the exact shapes I actually accept: I type-check every field, cap string lengths so nobody stores a 1MB blob in a comment, and reject any key I didn't explicitly allow.
match /comments/{id} { allow create: if request.auth != null && request.resource.data.keys().hasOnly(['text', 'author', 'createdAt']) && request.resource.data.text is string && request.resource.data.text.size() < 2000 && request.resource.data.author == request.auth.uid && request.resource.data.createdAt == request.time;}hasOnly is the unsung hero here — it rejects any write carrying extra fields, which stops clients from smuggling isAdmin: true, over-writing a moderation flag, or padding documents with junk to inflate your storage bill. Pinning author == request.auth.uid stops one user forging a comment as another. And forcing createdAt == request.time means the client can't backdate or spoof timestamps — the server clock wins. Notice there's no allow update or allow delete here at all: on a comment, silence means denied, so a comment is immutable once written unless I add an explicit rule. That default-deny posture is your friend; when in doubt, grant nothing.
For anything with a natural cap — one vote per user, one profile per account — I model it so the document ID is the constraint (/votes/{uid} rather than an auto-ID under /votes), then require request.auth.uid == uid on create. That makes "vote a thousand times" structurally impossible instead of something I have to detect after the fact.
Rules that are wrong in the permissive direction fail silently — everything works, right up until someone notices the collection is world-readable. So I don't ship a rule I haven't tried to break. The Firebase Emulator Suite runs your firestore.rules locally, and @firebase/rules-unit-testing lets you assert both directions: that a legitimate request is assertSucceeds and a malicious one is assertFails. I write the failing cases first — the unauthenticated read, the over-limit query, the write with an extra field — and only then the happy path. If the "should be denied" test passes on day one, the rule was never doing anything.
On a serverless, no-budget Firebase project you have exactly three levers, and they stack — none of them replaces the others:
request.query.limit make unbounded scraping structurally impossible and put a hard ceiling on cost per request — they are the rate limiter you never deployed a server for.apiKey is a public identifier, not a secret — assume every attacker already has your full client config.read into get and list, and gate list on request.query.limit and where clauses so no query can pull the whole collection.hasOnly, type and size checks, request.auth.uid ownership, and request.time for timestamps; default to deny.assertSucceeds and assertFails in the emulator before shipping any rule change.None of this needs a Cloud Function or a cent of spend. The whole game is refusing to expose any operation whose cost or blast radius you can't bound in advance — and once you internalize that, firestore.rules stops being a checklist and becomes the rate limiter you never had to deploy.