Skip to content
LockShip

Guide · Firebase · Updated

A Firebase Security Rules checklist for vibe-coded apps

If your app keeps its data in Firebase and the browser talks to it directly, which is how a lot of apps built with Bolt, v0, Replit, Cursor and friends end up wired, then a short file of rules is the only thing between a stranger and your users’ data. This is the checklist we wish someone had handed us: what bad rules look like, what good ones look like, and how to check yours from the outside. Every rules snippet on this page was run against the Firebase Emulator before we published it.

The key in your config is public. The rules are the lock.

Open your live app, open the browser’s developer tools, and search the JavaScript for apiKey. You will probably find a block with a key starting AIza, your projectId, an authDomain, maybe a databaseURL and a storageBucket. The first time you see it, it feels like you leaked something.

You didn’t. Firebase’s API keys guide says these keys are “public by design”: they identify your project to Google, and that is all. Access to your Realtime Database, Cloud Firestore, and Cloud Storage data “is enforced using Firebase Security Rules” and App Check, “not by keeping your Firebase API key secret.” Every visitor to your site already has the same key you do.

That is the whole story. Security Rules are small files, one each for Firestore, the Realtime Database, and Storage, that Google runs on every request coming from a browser or phone. If they say yes, the answer is yes, whether the request came from your app or from a stranger’s terminal. Firebase’s guide to insecure rules puts it plainly: with open rules, “anyone who guesses your project ID can steal, modify, or delete the data.”

Two keys really are secret, and both are worth a quick search of your bundle. The API keys guide warns that a key for the Gemini Developer API must never be in your code, which matters if an AI tool helped you add AI features. And a service account file, the JSON your server uses with the Admin SDK, skips your rules completely. The Firestore REST docs say requests made with a service account ignore Security Rules and are checked by IAM instead. That belongs on a server and nowhere else.

Why this keeps coming up

Early in 2024, three researchers, MrBruh, Eva (xyzeva) and Logykk, scanned millions of websites for exactly the config block you just found, then checked whether common Firestore collections would answer a signed-out read. Their write-up, 900 Sites, 125 million accounts, 1 vulnerability, counts 124,605,664 exposed user records, including about 106 million email addresses, 20 million passwords, and 27 million billing records. They say the real numbers are likely larger. They emailed 842 site owners. About a quarter fixed the problem, and 1% wrote back.

BleepingComputer’s report on the research counts 916 affected websites and adds two details that stay with us. Eva told them that most of the sites also allowed writes, not just reads. And 98% of the exposed passwords, 19,867,627 of them, were stored in plain text.

It started with a single app. In the write-up on Chattr, an AI hiring tool whose customers it lists as including KFC, Subway and Wendy’s, MrBruh describes the bug in two sentences. With the Firebase config from the page, you started with zero permissions. But if you created a new account through Firebase’s own sign-up feature (the website itself didn’t let you register), you got full read and write access to the database. Step 2 is about exactly that.

None of this took a clever attacker. It took rules that were written for building and never rewritten for launch. If your app uses Supabase rather than Firebase, the same idea applies with different plumbing: see our Supabase RLS checklist and CVE-2025-48757 explained.

The checklist

Work through these in order. Your rules live in the Firebase console, under each product’s Rules tab, or in firestore.rules, database.rules.json and storage.rules if your project keeps them in the repo. Firebase recommends editing them one way only, so a deploy from your code doesn’t quietly overwrite a fix you made in the console. The examples use a users collection and a notes collection. Swap in your own names.

  1. Find test-mode and wide-open rules
  2. Don’t stop at “signed in”
  3. Tie every rule to request.auth.uid
  4. Check what gets written, not only who writes it
  5. Give admin powers through custom claims
  6. Realtime Database: mind the cascade
  7. Lock down Cloud Storage
  8. Add App Check as a second layer, not a replacement
  9. Check from the outside, as a stranger
  10. Test your rules with the emulator

01

Find test-mode and wide-open rules

When you create a database or a Storage bucket, Firebase asks you to pick locked mode or test mode. The Firestore quickstart describes test mode honestly: good for getting started, “but allows anyone to read and overwrite your data.” In its plainest form it looks like this, and the Firestore rules docs carry the warning to never use it in production:

Firestore · never ship this
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Anyone on the internet can read, change, and delete everything.
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

Many projects get a version with a timer on it instead. It is the rule people paste into a Stack Overflow question after Firebase emails them that client access to their database is “expiring in X day(s)”, with a comment saying it is configured to expire after 30 days:

Firestore · test mode with a timer
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Open to everyone until the date, then closed to every browser.
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2026, 11, 15);
    }
  }
}

We checked both halves of that on the emulator. Before the date, a signed-out visitor could read the whole database and overwrite a user’s profile. With a date that had already passed, every request was refused, including a signed-in user reading their own profile. That is why apps “suddenly break” a month after launch, and why the tempting fix, moving the date forward, is the wrong one. It just reopens everything to everyone. The Realtime Database has the same trap, which its setup docs describe as a trial period followed by an email and then rules that deny all requests.

Look for if true, a bare allow read, write;, a request.time < date, or ".read": true in each of your three rule files. Any of them on data that belongs to users needs replacing with the rules in steps 3 to 7.

02

Don’t stop at “signed in”

This is the rule people reach for when test mode expires, and the one AI tools often suggest. Firebase’s docs show it as an example of a valid rule that is not recommended for production, and the insecure-rules guide asks you to confirm you really want any logged-in user to have access:

Firestore · looks safe, isn't
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Looks safe. Isn't: every signed-in user can read and edit everyone's data.
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

It checks that someone is signed in. It never checks who. On the emulator, signed in as a test user called bob, these rules let bob read alice’s profile, list every user, change alice’s display name, and read a secrets collection that had nothing to do with him.

Here is the part that makes it worse. If your project has email and password sign-in turned on, anyone holding your public key can create an account, whether or not your site has a sign-up page. The API keys guide notes that someone with your key can make authentication requests against your project. That is the Chattr story. We reproduced it on the emulator: a signed-out REST request for a users document got 403, then we created a brand-new account through the Auth sign-up endpoint, sent the same request with that account’s token, and got 200 and the data. “Signed in” is a door strangers can open themselves.

03

Tie every rule to request.auth.uid

For anything that belongs to one person, the rule almost always has the same shape: you may touch it if you are its owner. request.auth.uid is the ID of the signed-in person asking, and it comes from a token Firebase signs, so nobody can fake it. These are adapted from the patterns in Firebase’s rule conditions guide and insecure rules guide:

Firestore · owner-only rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // One profile per user, stored at users/<their uid>.
    match /users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // Each note has an ownerId field holding the uid of whoever wrote it.
    match /notes/{noteId} {
      allow read, delete: if request.auth != null
                          && request.auth.uid == resource.data.ownerId;
      allow create: if request.auth != null
                    && request.auth.uid == request.resource.data.ownerId;
      allow update: if request.auth != null
                    && request.auth.uid == resource.data.ownerId
                    && request.resource.data.ownerId == resource.data.ownerId;
    }

    // Anything not matched above is denied. Keep it that way.
  }
}

A few details here that are easy to get wrong, and that we tested one by one:

  • In users, the document ID is the person’s uid, so comparing it with userId from the path is enough. Bob could not read or overwrite alice’s profile, and a signed-out visitor could not read either.
  • In notes, ownership is a field. resource.data is the document as it is stored now, and request.resource.data is the document as it would be after the write. Create checks the new version, so alice could add a note with her own uid but not one claiming to be bob’s.
  • Update checks both: you own the note now, and the owner isn’t changing. Without the second line, alice could hand her note to bob, or worse, a user could rewrite ownerId on something to take it over. The emulator refused that edit.
  • Anything you don’t write a rule for is refused. Our test users couldn’t touch secrets or orders under these rules, because nothing allowed it. That is the safe way to fail.

One thing that surprises people: rules are not filters. If alice asks for the whole notes collection, Firestore doesn’t quietly hand her just her own notes. It refuses the entire query, because the result could include notes she can’t read. Her query has to say where("ownerId", "==", uid). We saw exactly that on the emulator. If your app breaks after you tighten the rules, this is often why, and the fix is in the query, not in loosening the rule.

04

Check what gets written, not only who writes it

Ownership answers who may write. It doesn’t stop alice from writing plan: "pro" into her own profile, or adding isAdmin: true, or a 10 MB display name. If your app trusts those fields, the browser is effectively deciding what people paid for. The conditions guide shows how to check the incoming document:

Firestore · field validation
// Goes inside: match /databases/{database}/documents { ... }
match /users/{userId} {
  function isOwner() {
    return request.auth != null && request.auth.uid == userId;
  }
  function validProfile(data) {
    return data.keys().hasOnly(['displayName', 'bio', 'plan'])
      && data.displayName is string
      && data.displayName.size() > 0
      && data.displayName.size() <= 50
      && (!('bio' in data) || (data.bio is string && data.bio.size() <= 500));
  }

  allow read: if isOwner();

  // New profiles always start on the free plan.
  allow create: if isOwner()
                && validProfile(request.resource.data)
                && request.resource.data.plan == 'free';

  // People can edit their profile, but never their own plan.
  allow update: if isOwner()
                && validProfile(request.resource.data)
                && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['plan']);

  allow delete: if false;
}

hasOnly lists the fields a profile may have, so nothing extra can sneak in. diff(...).affectedKeys() lets an update touch anything except plan. On the emulator this refused a new profile that started on pro, one with an extra isAdmin field, an empty name, a 51-character name, a name that was a number, and a bio over 500 characters. It allowed a normal profile and a name change, and refused the update that tried to switch the plan.

Things like plans, credits and payment status are better still written only by your server, after a real payment, with the Admin SDK. Then the browser rule for them can simply be allow write: if false, as in the next step.

05

Give admin powers through custom claims

Sooner or later you want an admin who can see every order. The tempting way is a role: "admin" field on the user’s own document, and step 4 shows why that’s risky: it is a document the user can try to write. Firebase’s answer is custom claims. You set them from a trusted server with the Admin SDK, they travel inside the signed ID token, and your rules read them from request.auth.token:

Firestore · admin through a custom claim
// Goes inside: match /databases/{database}/documents { ... }
// True only if your server gave this user an admin custom claim.
function isAdmin() {
  return request.auth != null && request.auth.token.admin == true;
}

match /orders/{orderId} {
  allow read: if isAdmin()
              || (request.auth != null && request.auth.uid == resource.data.ownerId);
  // Orders are written by your server (the Admin SDK skips rules), never by a browser.
  allow write: if false;
}

match /settings/{settingId} {
  allow read: if request.auth != null;
  allow write: if isAdmin();
}

We tested every case. A user with an admin: true claim could read any order and change settings. Alice could read her own order and not change it. Bob and a signed-out visitor were refused. A claim set as the text "true" instead of the boolean true didn’t count either, which is worth knowing when you write the server code that sets it.

Two notes from Firebase’s docs. Claims are capped at 1,000 bytes and are meant for access control only, not profile data. And a new claim only reaches the user after their token refreshes, for example when they sign in again, so don’t panic if it doesn’t take effect immediately.

06

Realtime Database: mind the cascade

The Realtime Database uses a different rules format, JSON, and it behaves differently in one important way. The RTDB security docs say .read and .write rules cascade: access granted at a parent covers everything beneath it, and a stricter rule further down can’t take it back. So this, which reads like “signed-in users can see the list, but each user only their own record”, does not do that:

Realtime Database · the cascade trap
{
  "rules": {
    "users": {
      // This wins: it grants read on /users and everything under it...
      ".read": "auth !== null",
      "$uid": {
        // ...so this stricter rule never gets the chance to say no.
        ".read": "auth !== null && auth.uid === $uid"
      }
    }
  }
}

On the emulator, bob read alice’s record straight through it. The fix is to grant access only at the level it belongs, the user’s own node, and nowhere above it:

Realtime Database · own node only
{
  "rules": {
    "users": {
      "$uid": {
        ".read": "auth !== null && auth.uid === $uid",
        ".write": "auth !== null && auth.uid === $uid",
        "displayName": {
          ".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 50"
        },
        "$other": {
          ".validate": false
        }
      }
    }
  }
}

$uid is the key in the path, so each person can only read and write their own branch. .validate rules don’t cascade, and they check the shape of what gets written: a display name must be text of 1 to 50 characters, and the $other rule refuses any field you didn’t name. Tested: alice could read and write her node, bob couldn’t read hers, nobody could list all users, a signed-out read of the whole database was refused, and writes with a number for a name, a 51-character name, or an extra isAdmin field were rejected.

07

Lock down Cloud Storage

Files get forgotten because they don’t feel like a database. They have their own rules file, and the open version looks like this:

Cloud Storage · wide open
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Anyone can list, download, overwrite, and delete every file.
    match /{allPaths=**} {
      allow read, write: if true;
    }
  }
}

On the emulator, a signed-out visitor could list a user’s folder, download the file in it, and overwrite it. For ID scans, invoices or anything else people upload, keep each person in a folder named after their uid, and check what they upload, following Firebase’s Storage conditions guide:

Cloud Storage · own folder, checked uploads
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Private files live at users/<uid>/<file name>.
    match /users/{userId}/{fileName} {
      allow read, delete: if request.auth != null && request.auth.uid == userId;
      allow create, update: if request.auth != null
                            && request.auth.uid == userId
                            && request.resource.size < 5 * 1024 * 1024
                            && request.resource.contentType.matches('image/.*|application/pdf');
    }
  }
}

request.resource is the file being uploaded, so you can cap its size and type. Tested: alice could upload a PNG and a PDF to her own folder, list it, and delete her own files. Bob couldn’t read or delete hers, a stranger couldn’t list or download them, and an upload to bob’s folder, an HTML file, and a file just over 5 MB were all refused. If some files really are public, like product photos, give them their own path with allow read; and keep writes owner-only.

08

Add App Check as a second layer, not a replacement

App Check makes your app prove, with reCAPTCHA Enterprise on the web or device attestation on phones, that a request really comes from your app. Once you enforce it for Firestore, the Realtime Database or Storage, requests without a valid App Check token are refused. That cuts out a lot of scripted abuse, and it is worth turning on.

But it answers a different question from your rules. Firebase’s own description is that Authentication protects your users and App Check “protects you, the developer”, and that the two are complementary. App Check can’t tell alice’s data from bob’s, and Firebase says it “does not guarantee the elimination of all abuse.” Someone using your real app in a real browser passes App Check every time. Rules are still the lock.

09

Check from the outside, as a stranger

Rules you haven’t tested are guesses. The quickest real check is to send the request a stranger would, from your own terminal, using the values from your config. Only run this against a project you own.

Firestore: ask one collection for one document, with no sign-in. Try each collection that holds anything about your users.

Terminal · Firestore
curl "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/users?pageSize=1&key=YOUR_API_KEY"

Realtime Database: use the databaseURL from your config. shallow=true returns only the top-level keys rather than all of your data, which is all you need to know whether the door is open.

Terminal · Realtime Database
curl "YOUR_DATABASE_URL/.json?shallow=true"
  • Real data comes back: a stranger can read that collection or database today. Go back to steps 1 to 3.
  • Firestore says 403 and PERMISSION_DENIED, or the Realtime Database says 401 and “Permission denied”: your rules turned a signed-out visitor away. Good.

We ran both commands against the emulator, first with the open rules from step 1 and then with the rules from steps 3 and 6. The open rules handed back the data. The locked ones gave exactly the errors above. This test can’t see the signed-in problem from step 2, though, so also make two test accounts and try to open one account’s data while signed in as the other.

10

Test your rules with the emulator

This is the step that makes the others stick. Firebase’s Local Emulator Suite runs Firestore, the Realtime Database and Storage on your own machine (it needs Node.js and Java 11 or newer), and the @firebase/rules-unit-testing library lets a test pretend to be any user, or nobody. Firebase says that library never touches your production data. Here is a complete test file. It expects the owner-only rules from step 3 in a firestore.rules file in the same folder:

JavaScript · rules.test.mjs
// rules.test.mjs
import { readFileSync } from "node:fs";
import { test, before, after } from "node:test";
import { initializeTestEnvironment, assertFails, assertSucceeds } from "@firebase/rules-unit-testing";
import { doc, getDoc, setDoc } from "firebase/firestore";

let env;

before(async () => {
  env = await initializeTestEnvironment({
    projectId: "demo-my-app",
    firestore: { rules: readFileSync("firestore.rules", "utf8") },
  });
  // Seed one profile with the rules switched off, just for setup.
  await env.withSecurityRulesDisabled(async (ctx) => {
    await setDoc(doc(ctx.firestore(), "users/alice"), { displayName: "Alice" });
  });
});

after(() => env.cleanup());

test("a stranger can't read a profile", async () => {
  const stranger = env.unauthenticatedContext().firestore();
  await assertFails(getDoc(doc(stranger, "users/alice")));
});

test("Alice can read her own profile", async () => {
  const alice = env.authenticatedContext("alice").firestore();
  await assertSucceeds(getDoc(doc(alice, "users/alice")));
});

test("Bob can't read Alice's profile", async () => {
  const bob = env.authenticatedContext("bob").firestore();
  await assertFails(getDoc(doc(bob, "users/alice")));
});
Terminal · run it
# Once: the CLI, the emulator test library, and the Firebase SDK
npm install --save-dev firebase-tools @firebase/rules-unit-testing firebase

# firebase.json only needs to point at your rules:
# { "firestore": { "rules": "firestore.rules" } }

# Start the Firestore emulator, run the tests, shut it down again
npx firebase emulators:exec --project demo-my-app --only firestore "node --test rules.test.mjs"

We ran this exact file with that exact command, and all three tests passed. Then change your rules to the “signed in” version from step 2 and run it again. The Bob test fails, which is the point: the test caught the mistake before a stranger did. Add a test each time you add a collection, run them before every deploy, and you can let an AI rewrite your rules without holding your breath.

Where LockShip fits

We built LockShip because step 9 is easy to forget and tedious to repeat after every change. You paste your app’s URL and prove you own the domain. The scan finds the Firebase config in your live bundle and checks whether Firestore collections with common names, like users, orders and messages, answer a signed-out request for a single document. It also looks for secret keys in your JavaScript, tries /.env and /.git, and reads your security headers.

It is just as important to say what it doesn’t do. It never signs in, so it can’t catch the step 2 problem or run the two-account test. It can’t read your rules files, and it never writes anything. It is a point-in-time, best-effort check, not a penetration test. A clean result means “nothing obvious from the outside right now,” not “safe.” Use it alongside this checklist, not instead of it.

The summary is free: a score and the single worst thing we found. The $39 Launch Safe certificate adds the full report with fix prompts for your builder, a PDF, and seven days of re-scans while you fix things. A passing re-scan gives you a badge for your site.

Want a second pair of eyes on step 9?

Paste your app’s URL, prove you own the domain, and see what a stranger can read. The summary is free.

Run a free scan

Before you rely on a result, read the disclaimer.

Questions people ask

Is the Firebase API key in my JavaScript a leak?

No. Firebase’s API keys guide says keys for Firebase services are public by design: they identify your project, they don’t authorize anything. Your Firestore, Realtime Database, and Storage data is protected by Security Rules and App Check, not by hiding the key. The one to keep out of your code is a separate key for the Gemini Developer API, which Firebase says must never be public.

What does “allow read, write: if true” mean?

It means anyone on the internet can read, change, and delete that data without signing in. It is what test mode amounts to, and Firebase’s own docs label it never for production. If you see it, or the version with request.time < timestamp.date(...), fix that first.

My rules say request.auth != null. Isn’t that enough?

Usually not. It checks that someone is signed in, not who they are, so every signed-in user can read and edit everyone else’s data. Firebase lists it as a common insecure pattern. And if your project allows sign-ups, a stranger can create an account with your public key and become one of those signed-in users. Compare request.auth.uid with the owner of the data instead.

My test-mode rules expired and the app stopped working. Can I just move the date?

You can, and the app will work again, but you have reopened your whole database to the internet until the new date. The right fix is to replace that rule with real ones, like the ownership rules in step 3, and test them with the emulator before you publish.

Does App Check replace Security Rules?

No. App Check checks that a request comes from your real app on a real device. Security Rules decide which user may touch which data. Firebase describes them as complementary, and says App Check does not guarantee the elimination of all abuse. Use both.

Is Firebase less secure than Supabase?

Not in our view. They share the same shape: a public key in the browser, and rules on the server that do the real work. Firebase has Security Rules, Supabase has Row Level Security. Either one is safe when the rules are right, and either one is wide open when they are missing.

Keep reading

Sources

We checked each of these on 16 October 2026, and ran every rules snippet above against the Firebase Local Emulator Suite. Firebase changes often, so if something here disagrees with its docs, trust the docs and tell us at hi@lockship.dev.