The client is not the boundary: hardening a Firebase app
Firebase makes it trivially easy to talk to the database from the browser. That is the feature, and it is also the entire threat model — notes from tightening a stack I use every day.
- #firebase
- #secure-design
- #web
Firebase's pitch is that the client talks to the database directly. No API layer, no serialisation, no plumbing. It is genuinely why I reach for it on small projects.
It also means the security model is inverted relative to what most tutorials teach. There is no trusted server in the middle to make decisions, so every decision has to live somewhere the client cannot reach — which, in Firebase, means security rules.
The mental shift
The thing to internalise is that your application code is a convenience. The real API surface is the database, and it is reachable by anyone with your project's config values — which ship in the JavaScript bundle by design.
So the question for every collection is not "what does my app do here" but:
If someone opened a console and wrote this query by hand, what would come back?
If the honest answer is "everything", the rules are wrong, no matter how correct the application is.
Rules as the specification
Rules read like an access-control spec because that is what they are. The version I settled on for public content and a private inbox:
match /projects/{id} {
allow read: if true; // linked from a public page
allow write: if isAdmin();
}
match /messages/{id} {
allow read, update, delete: if isAdmin();
allow create: if request.resource.data.keys().hasOnly(
['name','email','subject','message','createdAt','read']
)
&& request.resource.data.message.size() <= 4000
&& request.resource.data.read == false;
}Three things in that create rule matter more than they look:
hasOnlyrejects extra fields. Without it, anyone can write arbitrary keys into your documents — including ones your dashboard later renders.- Size limits are the difference between a contact form and free storage.
read == falsestops a submitter from creating a message pre-marked as read, which would quietly hide it from the inbox.
The rule I got wrong first
My initial version validated the fields it cared about and said nothing about the rest.
hasOnlyis what closes that; validating the fields you know about is not the same as rejecting the ones you don't.
What still belongs on the server
Rules cannot do everything. Rate limiting, spam heuristics and notification email need somewhere to run, so on this site the contact form posts to a route handler that validates with a schema, applies a honeypot and a per-window limit, and only then writes.
That handler is defence in depth, not the control. It sits in front of a rule that would reject a malformed write anyway. If someone skips the handler and writes to Firestore directly, they get exactly the same answer — which is the test for whether the boundary is in the right place.
Storage has the same trap
Uploads are the second place the "trusted client" assumption sneaks back in. A rule that allows any authenticated write turns the bucket into a host for whatever content type is uploaded:
match /cv/{file} {
allow read: if true;
allow write: if isAdmin()
&& request.resource.contentType == 'application/pdf'
&& request.resource.size < 8 * 1024 * 1024;
}Pinning the content type at the rule matters because the browser sends it, and the browser is the thing you are defending against.
The short version
Working in Firebase made a principle concrete that I had previously only read about: put the control where the attacker cannot stand between it and the data. In Firebase the answer is unusually literal — it is the rules file, and nowhere else. Every other layer is ergonomics.