In a sample of 950 open-source GitHub projects with Firebase rules, researchers found that almost 25% had security rules that left data exposed. That is not a theoretical risk — it means one in four Firebase apps was shipping with a wide-open database.
Firebase security rules are the single point of failure between your users' data and the public internet. There is no server sitting between your client app and the database. If the rules are wrong, anyone with your project config (which is public by design) can read or write your data directly.
This guide covers the eight most common Firebase security rule mistakes, with vulnerable code and fixed code for each one.
Firebase uses declarative rules to control who can read and write data. Every request — from your app or from a REST client — is evaluated against these rules before it touches the database.
There are three rule systems depending on the Firebase product:
Firestore Security Rules use a match and allow syntax:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null;
}
}
}
Realtime Database Rules use a JSON structure:
{
"rules": {
"users": {
"$uid": {
".read": "auth != null",
".write": "auth != null"
}
}
}
}
Storage Rules control file uploads and downloads:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /uploads/{userId}/{allPaths=**} {
allow read, write: if request.auth != null;
}
}
}
The critical thing to understand: if you deploy no rules, or deploy rules with allow read, write: if true, your database is completely public. Anyone can read every document and write anything they want.
Realtime Database rules live in a file called database.rules.json. The structure mirrors your data tree: one top-level "rules" object, then a node for each path, with .read, .write, .validate, and .indexOn keys attached at the level they apply to. Keys starting with $ are wildcards that capture the child key so you can compare it against auth.uid.
Two behaviors decide everything else, and both are documented in Firebase's rules syntax reference:
Default deny. Access is disallowed by default — if no .read or .write rule is specified at or above a path, the request is denied. An empty {"rules": {}} file locks the whole database.
Rules cascade downward, and grants cannot be revoked. Shallower rules override rules at deeper paths. A .read: true on a parent grants read access to every descendant, and no child rule can take it back. This is the opposite of Firestore, where rules do not cascade. Practically: write your .read and .write at the deepest node that makes sense, and treat any true near the root as a public dump of that entire subtree.
.validate is the exception — it does not cascade, and it cannot grant access. It only runs on writes that were already allowed by a .read/.write rule, and only for non-null values, so it is skipped on deletes.
The baseline pattern: each user owns exactly one node keyed by their uid, and nobody else can see it.
{
"rules": {
".read": false,
".write": false,
"users": {
"$uid": {
".read": "auth !== null && auth.uid === $uid",
".write": "auth !== null && auth.uid === $uid",
".validate": "newData.hasChildren(['displayName', 'email'])",
"displayName": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 100"
},
"email": {
".validate": "newData.isString() && newData.val().contains('@') && newData.val().length <= 254"
},
"avatarUrl": {
".validate": "newData.isString() && newData.val().beginsWith('https://')"
},
"$other": {
".validate": false
}
}
}
}
}
The $other node with ".validate": false is the part most rule files are missing. Without it, an authenticated user can write plan, credits, or isAdmin into their own node, because .write already said yes and no validation rule objects to unknown keys. With it, any key you did not explicitly list is rejected. Fields your server owns — plan, role, billing — simply never appear in this file, and your backend writes them with the Admin SDK, which is not subject to these rules.
The explicit ".read": false and ".write": false at the root are redundant (deny is the default) but they document intent, and they make it obvious in code review if someone later flips one to true.
For content that is meant to be world-readable — published posts, a public directory — put the true on the smallest possible subtree.
{
"rules": {
"posts": {
".read": true,
".indexOn": ["publishedAt", "authorId"],
"$postId": {
".write": "auth !== null && (!data.exists() || data.child('authorId').val() === auth.uid)",
".validate": "newData.hasChildren(['title', 'body', 'authorId', 'publishedAt'])",
"title": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 120"
},
"body": {
".validate": "newData.isString() && newData.val().length <= 20000"
},
"authorId": {
".validate": "newData.val() === auth.uid && (!data.exists() || data.val() === newData.val())"
},
"publishedAt": {
".validate": "newData.isNumber() && newData.val() <= now"
},
"$other": {
".validate": false
}
}
}
}
}
Three things are doing work here. The .write expression splits create from update: !data.exists() is a create (allowed for any signed-in user), otherwise the existing authorId must match the caller, which covers both edits and deletes of someone else's post. The authorId validation pins the field to the caller and makes it immutable after creation, so nobody can reassign a post to another author. And because .read: true sits on posts, everything under posts is public — never park draft content, moderation notes, or author email addresses in that subtree.
The pattern that breaks most often. Membership lives in its own top-level node so that rules can check it with root.child(...) without granting read access to the room itself.
{
"rules": {
"members": {
"$roomId": {
".read": "auth !== null && data.child(auth.uid).exists()",
"$uid": {
".write": "auth !== null && root.child('members').child($roomId).child(auth.uid).val() === 'owner'",
".validate": "newData.isString() && newData.val().matches(/^(owner|member)$/)"
}
}
},
"rooms": {
"$roomId": {
"meta": {
".read": "auth !== null && root.child('members').child($roomId).child(auth.uid).exists()",
".write": "auth !== null && root.child('members').child($roomId).child(auth.uid).val() === 'owner'",
"name": {
".validate": "newData.isString() && newData.val().length <= 80"
},
"$other": {
".validate": false
}
},
"messages": {
".read": "auth !== null && root.child('members').child($roomId).child(auth.uid).exists()",
".indexOn": ["createdAt"],
"$messageId": {
".write": "auth !== null && root.child('members').child($roomId).child(auth.uid).exists() && (!data.exists() || data.child('senderId').val() === auth.uid)",
".validate": "newData.hasChildren(['senderId', 'text', 'createdAt'])",
"senderId": {
".validate": "newData.val() === auth.uid"
},
"text": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 2000"
},
"createdAt": {
".validate": "newData.isNumber() && newData.val() === now && !data.exists()"
},
"$other": {
".validate": false
}
}
}
}
}
}
}
Note where the read rules sit: on rooms/$roomId/meta and rooms/$roomId/messages, not on rooms/$roomId. If you hoisted a single .read up to $roomId it would still be correct here, but the moment you add a private child node under the room, the cascade hands it to every member. Deep is safer.
createdAt uses newData.val() === now, which forces the client to write ServerValue.TIMESTAMP — a hardcoded number will not match the server clock and the write fails. The !data.exists() in the same expression refers to the createdAt node itself, making the field write-once, so an edited message cannot backdate itself. If you want to allow clients to send their own timestamps, loosen it to newData.val() <= now.
.validate rules worth copying.validate is where you stop clients from writing garbage. The expressions available on newData and data:
{
"rules": {
"examples": {
"$id": {
"aString": { ".validate": "newData.isString() && newData.val().length <= 500" },
"aNumber": { ".validate": "newData.isNumber() && newData.val() >= 0 && newData.val() <= 100" },
"aBoolean": { ".validate": "newData.isBoolean()" },
"anEnum": { ".validate": "newData.val() === 'draft' || newData.val() === 'published'" },
"aReference": { ".validate": "root.child('users').child(newData.val()).exists()" },
"writeOnce": { ".validate": "!data.exists()" },
"required": { ".validate": "newData.exists()" }
}
}
}
}
Remember that .validate never grants anything. If a node has only .validate rules and no .read/.write at or above it, every request to it is still denied.
.indexOn for queries.indexOn goes on the node whose children you query, listing the child keys you order or filter by. If your client calls ref('posts').orderByChild('publishedAt').limitToLast(20), the index belongs on posts:
{
"rules": {
"posts": {
".read": true,
".indexOn": ["publishedAt", "authorId"]
},
"leaderboard": {
"$season": {
".read": "auth !== null",
".indexOn": ".value"
}
}
}
}
Use ".indexOn": ".value" when you sort with orderByValue() on primitive children instead of orderByChild(). Without an index, the client SDKs still return correct results but Firebase logs an unspecified-index warning and the query degrades as the node grows, because the filtering happens after the data is transferred; the REST API requires the index outright. See Firebase's indexing guide for the full behavior. Keys are indexed automatically, so orderByKey() needs nothing.
Each product has its own deploy target, and each target reads a file path declared in firebase.json:
{
"database": { "rules": "database.rules.json" },
"firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" },
"storage": { "rules": "storage.rules" }
}
Run the rules against the emulator before they ever reach production:
# Boot the emulators locally
firebase emulators:start --only firestore,database,storage
# Or run your rules test suite and shut everything down afterwards (CI-friendly)
firebase emulators:exec --only firestore,database "npm test"
Then ship the rules on their own, without dragging functions or hosting along:
firebase deploy --only firestore:rules
firebase deploy --only database
firebase deploy --only storage
# Or all three in one go
firebase deploy --only firestore:rules,database,storage
firestore:rules and firestore:indexes are separate targets, so deploying rules will not touch your composite indexes. A deploy replaces the live ruleset wholesale — there is no partial merge — which means the file in your repo must always be the complete, current ruleset. Rolling back is a redeploy of the previous file from version control.
The single most common Firebase security mistake is the "fix it later" rule:
Vulnerable:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
This grants full read and write access to every document in your database to anyone on the internet. No authentication required. Firebase even shows a warning banner in the console when you deploy this, but developers dismiss it during development and forget to change it before launch.
Fixed:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// No default access — every collection must have explicit rules
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow write: if request.auth != null && request.auth.uid == userId;
}
}
}
The fix is to never use a wildcard {document=**} with open permissions. Define rules per collection, and require authentication at minimum.
This is the second most common mistake. The rules check that a user is logged in, but not that they own the data they are accessing:
Vulnerable:
match /users/{userId} {
allow read, write: if request.auth != null;
}
Any authenticated user can read and modify any other user's document. If user A is logged in, they can read user B's profile, change their email, or delete their data.
Fixed:
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
The request.auth.uid == userId check ensures users can only access their own documents. This is ownership verification — the single most important pattern in Firebase security rules.
For data that needs to be shared (like a team workspace), use a field-based check:
match /projects/{projectId} {
allow read: if request.auth != null &&
request.auth.uid in resource.data.members;
allow write: if request.auth != null &&
request.auth.uid == resource.data.ownerId;
}
Even with ownership checks, users can write arbitrary fields to their own documents if you do not validate the incoming data:
Vulnerable:
match /users/{userId} {
allow read: if request.auth.uid == userId;
allow write: if request.auth.uid == userId;
}
A malicious user could write fields like isAdmin: true, plan: "enterprise", or credits: 999999 to their own profile document. If your app logic reads these fields to gate features, you have a privilege escalation vulnerability.
Fixed:
match /users/{userId} {
allow read: if request.auth.uid == userId;
allow create: if request.auth.uid == userId &&
request.resource.data.keys().hasOnly(['name', 'email', 'avatarUrl']) &&
request.resource.data.name is string &&
request.resource.data.email is string;
allow update: if request.auth.uid == userId &&
request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['name', 'avatarUrl']);
}
Key points:
hasOnly() to restrict which fields can be writtenwrite into create, update, and delete for granular controldiff().affectedKeys() on updates to restrict which fields can be changedIn Firestore, rules do not cascade from parent to child collections. But in the Realtime Database, they do — and this catches people off guard:
Vulnerable (Realtime Database):
{
"rules": {
"chats": {
".read": true,
".write": "auth != null",
"$chatId": {
"messages": {
"$messageId": {
".write": "auth.uid === newData.child('sender').val()"
}
}
}
}
}
}
The .read: true on chats grants read access to the entire chats tree, including all messages in all chat rooms. The more restrictive rules on child nodes are irrelevant because the parent already granted access.
Fixed:
{
"rules": {
"chats": {
"$chatId": {
".read": "auth != null && root.child('chatMembers').child($chatId).child(auth.uid).exists()",
"messages": {
"$messageId": {
".write": "auth != null && auth.uid === newData.child('sender').val()"
}
}
}
}
}
}
In Realtime Database rules, always put access controls at the deepest level possible, and never set .read: true or .write: true on parent nodes.
In Firestore, the opposite is true: rules on /chats/{chatId} do not apply to /chats/{chatId}/messages/{messageId}. You must write explicit rules for subcollections. This is safer by default, but means you need to remember to add rules for every subcollection.
Firebase stores whatever your client sends. If you expect a number and a user sends a string, your app breaks — or worse, your aggregation logic produces wrong results:
Vulnerable:
match /orders/{orderId} {
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid;
}
A user can create an order with { quantity: "abc", price: -100, userId: "their-uid" }. No type checking, no range validation.
Fixed:
match /orders/{orderId} {
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.quantity is int &&
request.resource.data.quantity > 0 &&
request.resource.data.quantity <= 100 &&
request.resource.data.price is number &&
request.resource.data.price > 0 &&
request.resource.data.status == 'pending' &&
request.resource.data.createdAt == request.time;
}
Always validate:
is string, is int, is number, is bool, is timestampin ['pending', 'active', 'cancelled']createdAt to use request.time so clients cannot forge timestampsMany apps store configuration, feature flags, or analytics data in Firebase. These collections often have no rules at all, which means they inherit the default (deny all in Firestore, but may be open in Realtime Database if a parent rule allows it):
Vulnerable:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
// No rules for /admin, /config, /analytics
// In Firestore, this means denied by default — but developers
// often "fix" this by adding a wildcard rule
match /{document=**} {
allow read: if request.auth != null;
}
}
}
That wildcard at the bottom gives every logged-in user read access to /admin, /config, and every other collection.
Fixed:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
match /admin/{document=**} {
allow read, write: if request.auth != null &&
request.auth.token.admin == true;
}
match /config/{document} {
allow read: if request.auth != null;
allow write: if request.auth != null &&
request.auth.token.admin == true;
}
// No wildcard fallback — unlisted collections are denied
}
}
Use Firebase custom claims (request.auth.token.admin) to gate admin access. Set these claims from your backend using the Admin SDK — they cannot be set by the client.
Never add a wildcard match rule as a "convenience" fallback. Every collection should have explicit rules.
Developers spend time on Firestore rules but completely forget about Firebase Storage. The default storage rules in a new project often look like this:
Vulnerable:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if true;
}
}
}
This means anyone can upload any file (including malicious executables), overwrite other users' files, and read all stored files. Combined with file path enumeration, an attacker can download every file in your storage bucket.
Fixed:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{userId}/avatar/{fileName} {
allow read: if request.auth != null;
allow write: if request.auth != null &&
request.auth.uid == userId &&
request.resource.size < 5 * 1024 * 1024 &&
request.resource.contentType.matches('image/.*');
}
match /users/{userId}/documents/{fileName} {
allow read: if request.auth != null &&
request.auth.uid == userId;
allow write: if request.auth != null &&
request.auth.uid == userId &&
request.resource.size < 10 * 1024 * 1024;
}
}
}
Storage rules should always:
request.auth.uidrequest.resource.sizerequest.resource.contentTypeFirebase provides a Rules Playground in the console and a local emulator for testing rules. Most developers never use either one, deploying rules and hoping for the best.
The risky approach:
# Write rules, deploy, hope it works
firebase deploy --only firestore:rules
The correct approach:
First, use the Firebase Emulator Suite to test rules locally:
// rules.test.js
const { initializeTestEnvironment, assertFails, assertSucceeds } =
require('@firebase/rules-unit-testing');
const testEnv = await initializeTestEnvironment({
projectId: 'my-project',
firestore: {
rules: fs.readFileSync('firestore.rules', 'utf8'),
},
});
// Test: Authenticated user can read own data
const alice = testEnv.authenticatedContext('alice');
await assertSucceeds(
alice.firestore().collection('users').doc('alice').get()
);
// Test: Authenticated user CANNOT read other user's data
await assertFails(
alice.firestore().collection('users').doc('bob').get()
);
// Test: Unauthenticated user cannot read anything
const unauthed = testEnv.unauthenticatedContext();
await assertFails(
unauthed.firestore().collection('users').doc('alice').get()
);
Write tests for every rule. At minimum, test:
You can also use the Rules Playground in the Firebase Console (Firestore > Rules > Rules Playground) to simulate individual requests and see which rule allowed or denied them.
Yes, completely. This is documented, intended behavior — not a bug and not something you can configure away.
Firebase's own documentation is explicit. For Firestore: "The server client libraries bypass all Cloud Firestore Security Rules and instead authenticate through Google Application Default Credentials" (Get started with Cloud Firestore Security Rules). For the Realtime Database, an app initialized with admin credentials "has access to read and write all data, regardless of Security Rules" (Add the Firebase Admin SDK to your server). The same holds for Cloud Storage accessed through the Admin SDK or the Google Cloud Storage client libraries.
So security rules govern exactly two things:
https://your-project.firebaseio.com/users/abc.json?auth=<id-token>Anything holding a service account key is outside that boundary entirely.
A leaked service account key is total compromise. Your rules can be flawless and it will not matter. Treat serviceAccountKey.json like a root password: never commit it, never bundle it into client code, never expose it through a NEXT_PUBLIC_, VITE_, or REACT_APP_ environment variable (those are inlined into the browser bundle at build time), and rotate it immediately if it ever lands in a repo or a log. Prefer Application Default Credentials on managed runtimes like Cloud Functions and Cloud Run so there is no key file to leak in the first place.
Your server code is its own authorization layer. Every Cloud Function, API route, and background job that uses the Admin SDK must authenticate and authorize on its own, because the database will not do it for you:
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
const WRITABLE_FIELDS = ['name', 'avatarUrl'];
export async function updateProfile(idToken, targetUserId, patch) {
// 1. Authenticate: prove the caller is who they claim to be.
const decoded = await getAuth().verifyIdToken(idToken);
// 2. Authorize: your rules are not running here, so check ownership yourself.
if (decoded.uid !== targetUserId) {
throw new Error('forbidden');
}
// 3. Validate: the same allowlist your .validate / hasOnly() rules enforce.
const safe = Object.fromEntries(
Object.entries(patch).filter(([key]) => WRITABLE_FIELDS.includes(key)),
);
await getFirestore().collection('users').doc(targetUserId).update(safe);
}
A common failure is a Cloud Function that takes a userId from the request body and writes to it with admin privileges. Any caller can pass any uid. The rules that would have blocked this from a client are simply not in the code path.
You can opt back into rules on the Realtime Database. The Admin SDK supports databaseAuthVariableOverride, which pins the auth variable your rules see instead of granting full admin access:
import { initializeApp, cert } from 'firebase-admin/app';
initializeApp({
credential: cert(serviceAccount),
databaseURL: 'https://your-project-default-rtdb.firebaseio.com',
// Rules now evaluate with auth.uid === 'worker-service'.
// Set this to null to run as an unauthenticated client instead.
databaseAuthVariableOverride: { uid: 'worker-service' },
});
This is a Realtime Database feature only. Firestore and Storage server libraries have no equivalent — for those, downscoping means using a client SDK with a real user token, or writing the checks in your own code.
Misconfigured Firebase security rules are not a theoretical problem. Here is what happens when rules are wrong:
Data leaks at scale. Security researchers regularly scan the internet for open Firebase databases. In 2024, a study found over 125 million records exposed across thousands of apps, including email addresses, passwords stored in plaintext, GPS coordinates, and financial data. An attacker does not need sophisticated tools — a simple REST request to https://your-project.firebaseio.com/.json returns everything if the Realtime Database rules allow it.
Account takeover. If users can write to each other's documents, an attacker can change another user's email address in the database, trigger a password reset, and take over the account.
Data corruption. Without field validation, a malicious user can write garbage data that breaks your app for everyone. Imagine a shared leaderboard where someone writes score: 999999999 or score: "not a number".
The vibe-coded app problem. A 2025 study by Wiz found that apps built with AI coding tools are particularly vulnerable to Firebase misconfigurations. AI assistants often generate the "quick start" rules (allow read, write: if true) and developers ship them without thinking twice. If you are building with Cursor, Bolt, Lovable, or similar tools, your vibe-coded app likely has security gaps that need manual review.
Open your firestore.rules, database.rules.json, and storage.rules files. Search for these red flags:
if true — wide open accessif request.auth != null without ownership checks — any user can access any data{document=**} with permissive rules — wildcard matches everythingcreate or update — clients can write anything.read: true or .write: true in Realtime Database parent nodes — cascading accessUse the Rules Playground in the Firebase Console to simulate requests as different users. Test every collection with:
Manual review only catches what you think to look for. Automated scanners can detect open Firebase databases, misconfigured storage buckets, and missing authentication from the outside — the same way an attacker would find them.
Check your rules into version control and review them in pull requests, just like application code. Any change to security rules should be reviewed by at least one other developer.
CheckVibe's Firebase scanner automatically detects common Firebase security misconfigurations from the outside. It checks for:
firebaseConfig object leaks information an attacker could useThe scanner runs the same checks an attacker would perform — probing your Firebase endpoints to see what responds. You get a report showing exactly which rules need to be fixed, with severity ratings and fix guidance.
Run a free scan on your Firebase app to see what is exposed.
Firebase security rules are your primary defense for client-side data access, but they are not the only layer you need. You should still validate data on the server side (using Cloud Functions or a backend API) for sensitive operations like payments, role assignments, and account deletion. Security rules protect against direct database access, but your Cloud Functions also need proper authentication and input validation. For a more complete security posture, follow the OWASP Top 10 checklist.
Firestore rules use a match/allow syntax and do not cascade — rules on a parent collection do not apply to subcollections. Realtime Database rules use a JSON structure and do cascade — a .read: true on a parent node grants access to all children. Firestore rules are generally considered more secure by default because of this non-cascading behavior. If you are starting a new project, use Firestore.
Your Firebase project config (apiKey, authDomain, projectId, etc.) is designed to be public. It is embedded in your client-side JavaScript and anyone can find it. The config alone does not grant data access — your security rules determine what is accessible. However, if your rules are permissive, anyone with the project config (which is everyone) can read your entire database. This is why rules are so critical: the config is always public, so the rules are the only barrier.
Use the Firebase Emulator Suite to write automated tests for your rules. Install it with firebase init emulators, then write tests using @firebase/rules-unit-testing. Test every access pattern: unauthenticated reads, cross-user reads, invalid field writes, and admin operations. Run these tests in CI so rule changes are validated before deploy. You can also use the Rules Playground in the Firebase Console for quick manual testing of individual requests.
Realtime Database rules go in a file called database.rules.json, structured as one top-level rules object whose nesting mirrors your data tree. At any path you can attach .read, .write, .validate, and .indexOn. Wildcard keys beginning with $, such as $uid, capture the child key so you can compare it to auth.uid. Inside expressions you have auth, data, newData, root, and now. Everything is denied until a .read or .write at or above the path allows it.
Run firebase deploy --only firestore:rules for Firestore, firebase deploy --only database for the Realtime Database, and firebase deploy --only storage for Storage, or combine them with commas. Each target reads the file path declared in firebase.json. Test with firebase emulators:start first, because a deploy replaces the live ruleset wholesale rather than merging changes, so the file in your repo must always be the complete current ruleset.
Yes, by anything holding admin credentials. Firebase documents that the Admin SDK and server client libraries bypass security rules entirely and authenticate through a service account instead. Rules only constrain the client SDKs and REST requests carrying an end-user ID token. A leaked service account key therefore grants full read and write access no matter how strict your rules are, and every server route must run its own authorization checks in code.
Use this checklist before every deploy:
allow read, write: if true rules anywhereread and write rule checks request.auth.uid against the document ownerwrite rules are split into create, update, and delete with different permissionscreate and update using hasOnly() and type checks{document=**} matches with permissive rulesrequest.auth.token.admin == true).read: true or .write: true on parent nodes"$other": { ".validate": false } catch-allNEXT_PUBLIC_-style env varsFirebase security rules are the most important 50 lines of code in your project. Get them wrong and nothing else matters — your database is public. Get them right and you have a solid foundation for securing your API endpoints and the rest of your application.
Want to know if your Firebase app is exposed right now? Run a free CheckVibe scan and get a security report in under 60 seconds. No signup required.
Paste your URL and get a security report in 30 seconds — 100+ automated checks with AI-ready fix prompts.
Scan your site freeRelated articles
A quick guide to checking your website's security. 7 things to test right now — SSL, headers, exposed secrets, vulnerabilities, and more. No security expertise needed.
The 7 most dangerous JWT security mistakes developers make. Algorithm confusion, weak secrets, missing expiration, and more — with code examples showing how to fix each one.
AI coding assistants ship features fast but routinely introduce security vulnerabilities. Learn the 8 most common security mistakes in vibe-coded apps and how to catch them before attackers do.