Is my Firebase app secure?
Firebase security comes down to one thing, and it isn't the thing most people worry about. The config sitting in your HTML is fine. Your Security Rules are carrying the entire weight โ and the default ones don't.
1. Your Firebase config is not a leak
Every Firebase app ships a block like this to the browser, and every few weeks someone finds theirs and panics:
const firebaseConfig = {
apiKey: "AIzaโฆ",
authDomain: "myapp.firebaseapp.com",
projectId: "myapp",
// โฆ
}
That's supposed to be public. Despite the name, the Firebase
apiKey isn't a secret โ it identifies your project to Google's
servers, it doesn't authorise anything. Google publishes it in their own
sample code. You cannot hide it, because the browser needs it to know which
project to talk to.
So don't waste an afternoon moving it into an environment variable. It will end up in the bundle anyway, and it was never the problem.
But here's the part that follows from it, and it's the whole point of this page: if anyone can take your config, then anyone can talk to your database. Not through your app โ with their own script, from their own machine, bypassing your UI entirely. The only thing deciding what they get back is your Security Rules.
2. Test mode is a time bomb, and locked mode is a cliff
When you create a Firestore database, Firebase offers you two starting points. Test mode gives you rules that look roughly like this:
allow read, write: if request.time < timestamp.date(2026, 3, 1);
Read that carefully. Until that date, anyone at all can read and write everything. There is no authentication check in there. It's meant for the first week of a prototype, and Firebase makes it expire specifically so you can't forget about it forever.
Then the date passes, your app breaks completely, and you're debugging a production outage. The fastest thing that makes it work again is:
allow read, write: if true; // never ship this
That's the single most common way a Firebase app ends up fully public โ
not ignorance, but an outage fixed under pressure. If your rules contain
if true or a date comparison, treat it as an open database.
3. request.auth != null is not authorisation
The usual second attempt looks responsible and isn't:
match /users/{userId} {
allow read, write: if request.auth != null; // any signed-in user
}
That says "anyone logged in", not "the person this data belongs to". Anyone who can sign up โ which, if you have public registration, is everyone โ can then read and overwrite every other user's document.
What you want is a comparison against the record's owner:
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
And for documents that store the owner in a field rather than the path:
match /projects/{projectId} {
allow read: if request.auth.uid == resource.data.ownerId;
allow create: if request.auth.uid == request.resource.data.ownerId;
allow update, delete: if request.auth.uid == resource.data.ownerId;
}
Note the two different objects: resource.data is the document
as it exists now, request.resource.data is what's being written.
Checking only the first on a create is a common hole โ there's no existing
document, so the condition passes vacuously.
4. Rules don't cascade the way you expect (and the two databases differ)
This one catches people who did everything else right.
In Firestore, rules do not apply to subcollections unless
you match them. A rule on /users/{userId} says nothing about
/users/{userId}/notes/{noteId} โ that path is unmatched, so
access is denied by default, which is safe. The danger is the fix people
reach for:
match /users/{userId}/{document=**} {
allow read, write: if request.auth.uid == userId;
}
That recursive wildcard is correct here, but it grants everything beneath that path โ so if you later add a subcollection that shouldn't follow the same rule, it silently inherits it.
Realtime Database works the opposite way. Rules cascade downward, and a grant at a parent path cannot be revoked deeper. If you allow read at the root, adding a stricter rule on a child does nothing โ the read is already permitted. So a single permissive rule high up quietly overrides everything below it.
5. Storage rules are a separate file
Locking down Firestore does nothing for Cloud Storage. They're different rule sets, edited in different places, and it's easy to secure your database carefully and leave every uploaded file world-readable โ or worse, world-writable, which turns your bucket into free hosting for whatever a stranger uploads and puts it on your bill.
Check them separately, and scope them to the owner the same way:
match /users/{userId}/{fileName} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
6. Rules aren't filters โ and that's why people loosen them
A subtlety worth knowing before it bites you: Firestore rules don't filter results. If you query a collection and the rules can't guarantee that every document the query could return is readable, the whole query is rejected โ you don't get a partial result.
So a perfectly correct owner-scoped rule makes
getDocs(collection(db, "projects")) fail outright. The right fix
is to constrain the query to match the rule:
query(collection(db, "projects"), where("ownerId", "==", user.uid))
The wrong fix โ and the tempting one, because it makes the error go away instantly โ is loosening the rule until the unconstrained query works. That's how a well-intentioned rule becomes an open collection.
7. Then the things that aren't rules
- App Check is the actual answer to "anyone can use my config". It attests that requests come from your real app rather than someone's script. Rules decide what a user may do; App Check helps with who is even allowed to ask.
- Restrict the API key in the Google Cloud console by HTTP referrer. This isn't authentication, but it limits casual reuse of your key against billable APIs.
- Cloud Functions need their own checks. A callable
function is a public endpoint; verify
context.authinside it, and don't assume the caller is your UI. - Test the rules, don't eyeball them. The Firebase emulator runs rules locally, and the console has a rules playground. Rules are code, and this is code where being nearly right is the same as being wrong.
Prompt to paste into your AI tool:
Review my Firestore and Cloud Storage security rules. For each match block, tell me who can read and who can write, in plain English. Flag anything that allows access to any signed-in user rather than the owner of the record, anything with a date comparison or an unconditional allow, and any collection or subcollection with no matching rule. Then show me corrected rules.
Before you ship
- No
if trueand no date comparisons anywhere in your rules - Ownership checked against
request.auth.uid, not just "signed in" request.resource.datachecked on creates, not onlyresource.data- Subcollections explicitly matched (Firestore) / no over-permissive parent rule (Realtime Database)
- Cloud Storage rules reviewed separately from Firestore
- Queries constrained to match the rules, rather than rules loosened to match the queries
- App Check enabled, callable functions checking auth themselves
Check the rest of the app
vibecheck scans your project or your live URL for exposed keys and the other mistakes AI-built apps ship with, in plain English โ with a fix prompt you paste straight back into your AI tool.
Scan my app โ free