Firebase rules for vibe-coded apps: 6 checks before going public.

2026-05-09 · vibecheck team · 10 min read · Platform · Firebase

Quick answer Firebase's "test mode" rule template grants allow read, write: if request.time < timestamp.date(...) — public access until a date 30 days in the future. Apps that launch before that date passes ship with permissive rules. The same pattern shows up in Realtime Database (root .read: true), Cloud Firestore (match /{document=**} open), and Cloud Storage (match /{allPaths=**} open). Six things to check before flipping public: RTDB rules, Firestore rules, Storage rules, Authentication providers, App Check, and what's actually in your client config.

Firebase is the longest-running BaaS for this kind of breach. The shape predates Supabase, predates Convex, predates everything in the current vibe-coding lineup — Firestore exposure scans were already producing breach lists in 2020. The platform isn't insecure. The default rules are insecure on purpose, because Firebase wants you to start prototyping in 30 seconds, not 30 minutes. The bug is consistent: apps ship before the prototype-mode rules expire.

This guide walks the six surfaces a Firebase project exposes, with the actual rule blocks to copy. Every check is runnable from outside the app with curl — no Firebase Console access needed.

1. Realtime Database rules

The most weaponised surface in the Firebase product line. RTDB's wire protocol is straightforward: append /.json to any node path and you get JSON back if rules allow it.

Test from outside:

curl 'https://your-project-id.firebaseio.com/.json?shallow=true'

# If this returns { "users": true, "posts": true, ... } instead of
# { "error": "Permission denied" }, the root .read rule is permissive.

Fix. Console → Realtime Database → Rules. The default-deny shape:

{
  "rules": {
    ".read": false,
    ".write": false,
    "users": {
      "$uid": {
        ".read": "auth != null && auth.uid == $uid",
        ".write": "auth != null && auth.uid == $uid"
      }
    },
    "posts": {
      ".read": "auth != null",
      "$postId": {
        ".write": "auth != null && (!data.exists() || data.child('authorId').val() == auth.uid)",
        ".validate": "newData.hasChildren(['authorId', 'body', 'createdAt']) && newData.child('body').isString()"
      }
    }
  }
}

Three things to notice. First, the root is explicitly closed with .read: false, .write: false — this is what stops the whole-tree dump. Second, $uid and $postId are path variables you can reference inside the rule, which is how ownership gets enforced without trusting the client. Third, .validate blocks malformed writes — without it, an authenticated user can still pollute your tree with arbitrary shapes.

After publishing, run the Rules Playground (Console → Realtime Database → Rules → Simulator) with auth = null against /. It should deny. Then run it again with a test UID against /users/<that-uid>. It should allow.

If the scanner finds this open, the dedicated fix-guide is /fix/firebase_rtdb_open.

2. Firestore rules

Firestore is more popular than RTDB at this point and ships with the same prototype-mode footgun. The "test mode" template shown when you first create a database is:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2026, 6, 8);
    }
  }
}

That rule is open until June 8th. After that, it denies everything. The trap: lots of apps launch in week one of this window, ship with the rule unchanged, and rely on it expiring as their security model. That's not a security model — that's a shutoff valve. After the date passes the app stops working entirely, then somebody extends the date instead of writing real rules.

Test from outside:

curl 'https://firestore.googleapis.com/v1/projects/your-project-id/databases/(default)/documents/users?pageSize=1'

# If this returns { "documents": [...] }, the collection allows
# anonymous reads. Try `posts`, `messages`, `items`, `orders` next.

Fix. Console → Firestore Database → Rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Default: deny everything. Be explicit per collection.
    match /{document=**} {
      allow read, write: if false;
    }

    // Each user reads/writes only their own user doc.
    match /users/{userId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == userId;
    }

    // Posts: signed-in users read; only authors write their own.
    match /posts/{postId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null
                    && request.resource.data.authorId == request.auth.uid;
      allow update, delete: if request.auth != null
                            && resource.data.authorId == request.auth.uid;
    }

  }
}

The pattern is the same as RTDB: explicit catch-all deny, then per-collection opens. Three things to be careful about:

Dedicated fix-guide: /fix/firestore_collection_public_read.

3. Cloud Storage rules

The third surface and the one most often forgotten — Storage rules are written in a separate file from Firestore rules and edited in a different Console pane. Apps regularly write good Firestore rules and ship default-open Storage rules.

Test from outside:

curl 'https://firebasestorage.googleapis.com/v0/b/your-project-id.appspot.com/o?maxResults=1'

# If this returns { "items": [...] }, the Storage bucket allows
# anonymous list. Every uploaded file's path is enumerable.

Once the path list is dumped, individual objects are trivially fetchable on the same public REST endpoint. ID cards, receipts, profile photos, screenshots from chat — anything the app uploads to a path-knowable location is now public.

Fix. Console → Storage → Rules:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {

    // Default: deny.
    match /{allPaths=**} {
      allow read, write: if false;
    }

    // Per-user avatars.
    match /users/{userId}/avatar.jpg {
      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/.*');
    }

    // Public marketing assets.
    match /public/{file} {
      allow read: if true;
      allow write: if false;
    }

  }
}

Three Storage-specific points worth flagging:

Dedicated fix-guide: /fix/firebase_storage_public_list.

4. Authentication providers

Auth provider misconfigurations don't show up in the rules files and can't be detected from outside the project. The two most common failure modes:

If you don't intend Authentication to be public, also disable user-managed sign-up via passwordPolicyEnforcementState and force admins to create accounts via the Admin SDK.

5. App Check

The defence-in-depth surface most teams skip. App Check sits between your client and Firebase backend services and verifies that requests originate from your real app — not from someone running curl, not from an automated scraper.

Even with perfect rules, App Check raises the bar for anonymous probes. The setup:

  1. Console → App Check. Register your web app with reCAPTCHA Enterprise (web), DeviceCheck or App Attest (iOS), or Play Integrity (Android).
  2. For each Firebase service (Firestore, Storage, RTDB), set the enforcement mode to "Enforced" once you've validated traffic in the metrics tab.
  3. In your client, initialize App Check before any other Firebase service call:
    import { initializeApp } from "firebase/app";
    import { initializeAppCheck, ReCaptchaEnterpriseProvider }
      from "firebase/app-check";
    
    const app = initializeApp({ /* config */ });
    initializeAppCheck(app, {
      provider: new ReCaptchaEnterpriseProvider(import.meta.env.VITE_RECAPTCHA_SITE_KEY),
      isTokenAutoRefreshEnabled: true,
    });

App Check doesn't replace rules. Rules say which authenticated user can do what; App Check says this request actually came from a browser running our code. They stack.

6. What's actually in your client config

The Firebase web client config object is meant to be public — apiKey, projectId, databaseURL, storageBucket, messagingSenderId, appId all ship in your bundle by design. None of those are secrets.

What does not belong in your client bundle:

Test from outside: view-source on your landing page, search for "private_key", "-----BEGIN PRIVATE KEY", "sk_live_", "sk-ant-", "sk-". None of those substrings should appear.

If anything matches, treat it as compromised and rotate immediately. The Supabase service_role response shape applies to any leaked privileged key.

How vibecheck fits in

vibecheck's Firebase detector probes all four external surfaces — RTDB root, Firestore common collections, Storage list, project metadata — without any credentials. It runs the same checks any attacker would run in the first three minutes of fingerprinting. Inspect your app and you'll get a report whose Firebase section is exactly the test commands above, run for you.

For the platform-comparison view, see Lovable vs Bolt vs v0 vs Replit Agent: security comparison. For the broader pattern that produces every breach in this category, see the vibe coding security guide.