AgenticCLI
▮ shipguardis a free, local secure-ship agent for AI-built apps — 5 checks in ~2s.$ npm i -g @agenticcli/shipguard

Firebase Security Rules: The Test-Mode Trap That Exposes Your Database

11 min · jul 2026

Firebase Security Rules: The Test-Mode Trap That Exposes Your Database

Firebase security rules are the only thing standing between your Firestore database and anyone on the internet who knows your project ID. When you start a project in "test mode," Firebase writes a rule that opens every document to every reader and writer, no login required. It also puts a clock on that rule: 30 days, then it flips to deny-everything. Most vibe-coded apps never touch the file again. They just wait for one of the two ways this ends badly.

TL;DR

  • Firebase test mode generates a rule like allow read, write: if request.time < timestamp.date(2026, 8, 18); — full read/write, no auth, for exactly 30 days.
  • AI coding agents ship this rule as-is because the app works. Nothing in a demo tells you the database is public.
  • A September 2025 audit found 150+ unauthenticated Firebase services across a sample of top mobile apps — root cause: test mode and weak rules.
  • The Tea app breach (July 2025) came from a Firebase storage bucket left without access controls: 72,000 images, then a second incident exposed 1.1 million private messages.
  • We ran ShipGuard's free scanner against a mock repo carrying exactly this setup. It caught a hardcoded API key. It did not catch the open rules — that check lives in a different tier. We'll show you both.

What Are Firebase Security Rules?

Firebase security rules are a declarative permissions file — firestore.rules for your database, storage.rules for file storage — that Firebase evaluates on every single read and write request, before your application code ever runs. There's no ORM layer, no middleware, no server route standing between a client and your data. The rules file is the entire authorization boundary. If a rule says allow read, write: if true, that's not a default that your backend can override later — it's the final word. Anyone with your project ID can hit the Firestore REST API directly and get every row back, with no server involved at all.

This is a different model from a traditional backend, and it trips up developers moving from Express or Django, where "not linking to it" and "not authorizing it" feel like similar levels of protection. In Firebase, they aren't. The database is reachable from the open internet by design — the rules file is the only wall.

What Is Firebase Test Mode — and Why Does It Expire?

When you create a new Firestore database or Storage bucket, the Firebase console asks you to pick "Locked mode" or "Test mode." Locked mode writes allow read, write: if false — nothing gets through, including your own app, until you write real rules. Test mode writes the opposite, with a twist: instead of a flat if true, the console generates a time-boxed condition.

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

Anyone can read or write anything, for 30 days, then the condition flips false and the app goes dark. Firebase's own documentation is explicit that this mode exists for early development and needs to be replaced before anything real touches it (see Firebase's guide to avoiding insecure rules). The trap has two faces, and they're both bad. Ship it and forget it, and for 30 days your data is public. Ship it, get busy, and on day 31 your production app starts throwing permission-denied errors with no warning — a second incident, self-inflicted by a clock nobody was watching.

Most engineering teams eventually rewrite the rule and move on. Most single-founder AI-built apps do neither, because nobody on the team was the one who clicked "test mode" — the agent was.

I've been in security long enough to have opinions about clocks nobody's watching — the CERT-In work I did years ago was mostly finding the exact gap between "someone configured this" and "someone owns this." Firebase test mode is that gap, wearing a 30-day countdown. It got interesting again the moment AI agents started clicking through the same console setup, at the same default, at scale.

Why Do AI Coding Agents Ship Test-Mode Rules?

An AI coding agent asked to "add a database" reaches for the fastest path to a working app: firebase init, pick test mode, ship the CRUD screen. It's not being reckless — it's optimizing for the thing it can verify, which is "the app loads and the button saves data." A closed rules file makes every write fail during setup, which looks like a bug the agent has to fix. Test mode makes the failures go away. The agent learned that green means done, so it reaches for whatever makes the red go green.

Nothing in that loop tells the agent the resulting rule is a security decision. The tutorials it was trained on use test mode too — for the exact same reason, to get a demo working in five minutes. It pattern-matched a thousand quickstarts and gave you their least secure common denominator. Not a mistake exactly. A default, inherited without anyone examining what it actually grants.

The failure mode compounds because Firestore rules live in one small file, separate from the routes and components where an agent (or a human reviewer skimming a diff) is looking for bugs. firestore.rules doesn't show up in a typical code review pass unless someone goes looking for it specifically. It's not hidden. It's just never where anyone happens to be looking.

What Happens When a Firebase Database Is Actually Left Open?

This isn't a hypothetical. In July 2025, the social-safety app Tea left a Google Cloud Storage bucket managed through Firebase without proper access controls. Roughly 72,000 images became publicly downloadable, about 13,000 of them selfies and government IDs submitted for identity verification. A second, separate exposure days later handed over a database of more than 1.1 million private messages between users. Neither exposure required a password, a token, or an exploit — both were reachable because the storage layer's access rules didn't require authentication (Security.org's writeup of the Tea app breach; Sentra's technical analysis).

Two months later, security researcher Mike Oude Reimer published a wider audit: reviewing roughly 400 of the most popular mobile apps across just three app categories, he found more than 150 Firebase services — Firestore databases, Realtime Database instances, Storage buckets, and Remote Config secrets — reachable with zero authentication. The exposed data ranged from payment details and cleartext passwords to AWS and GitHub tokens with elevated privileges. Extrapolated across all app categories, the estimate ran to roughly 4,800 similarly exposed services. His stated root cause: test mode and other weak security rules, left in place well past "temporary" (Oude Reimer's original research, "Tea continued"; coverage at Cyber Security News).

This is the 2026 shape of the problem. AI coding tools make it trivially fast to stand up a Firebase-backed app, which means the population of apps running on a 30-day test-mode clock — or on rules nobody rewrote after the clock expired into something else insecure — is larger than it's ever been. The apps in these disclosures weren't hobby projects. Some had ten-million-plus downloads. The mistake scales with adoption, not with team size.

How to Read a Firestore Security Rule in Plain English

You don't need to memorize Firebase's rules language to audit a file — you need to know five things it's built from.

Match blocks define paths. match /users/{userId} targets one document per user; match /{document=**} is a wildcard that matches every document in the entire database, at any depth. That double-star is the single most important thing to search for in any rules file — it means "this rule applies to everything."

allow statements grant, they never restrict. Every allow read or allow write you add is a door. There's no equivalent "deny" statement to close a door once opened elsewhere in the file — the only default is closed, and every allow is an exception to it.

request.auth is the identity of whoever's asking. request.auth != null means "someone is logged in" — it says nothing about who. request.auth.uid == userId compares the logged-in user's ID against the path segment, which is what actually scopes a document to its owner.

resource.data is the document as it exists before the write; request.resource.data is what the client is trying to write. Comparing the two is how you stop a user from editing fields they shouldn't touch, like a role or a price.

Conditions combine with && and || like any boolean expression. A rule with only one condition is worth double-checking — real authorization almost always needs at least two (is this user logged in, and do they own this specific document).

Read a .rules file top to bottom looking for exactly one pattern: an allow with a wildcard path and either if true or a bare allow read, write; with no condition at all. That's the whole test-mode signature, minus the date-expiry variant test mode actually ships by default.

If your stack is Postgres through Supabase instead of Firebase, the wildcard-versus-scoped-ownership problem shows up under a different name — Row Level Security policies left disabled or written too broadly. The failure mode and the fix are structurally the same; see how Supabase RLS trips up AI-built apps for the Postgres side of this same story.

Hardened Firebase Rules: Before and After

Here's the test-mode default most projects launch with — permissive, no scoping, on a clock:

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

And a version scoped per-collection, with real ownership checks:

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

    // Users can read and edit only their own profile document.
    match /users/{userId} {
      allow read, write: if request.auth != null
                          && request.auth.uid == userId;
    }

    // Posts are public to read (a blog, a feed) but only the
    // author can create or edit their own post, and can't
    // reassign it to someone else after the fact.
    match /posts/{postId} {
      allow read: if true;
      allow create: if request.auth != null
                     && request.auth.uid == request.resource.data.authorId;
      allow update, delete: if request.auth != null
                             && request.auth.uid == resource.data.authorId;
    }

    // Everything else is denied by omission — there's no
    // catch-all match block left to grant it.
  }
}

Storage rules follow the same shape. The test-mode default:

// storage.rules — test mode default
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.time < timestamp.date(2026, 8, 18);
    }
  }
}

Hardened, scoped to a per-user upload path — the pattern that would have stopped the Tea app's identity-document exposure at the rules layer:

// storage.rules — hardened
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /user-uploads/{userId}/{fileName} {
      allow read, write: if request.auth != null
                          && request.auth.uid == userId;
    }
  }
}

The difference between the two versions of each file isn't length. It's whether a wildcard match block grants access to a path, or a scoped match block grants access to an identity.

What Did a Real Scan Actually Find?

We built this exact setup — the test-mode firestore.rules and storage.rules above, a firebase.json, and a client file initializing the Firebase SDK the way a typical scaffold does — and ran ShipGuard's CLI against it with no login, no flags, no cherry-picking:

npx @agenticcli/shipguard scan --json

Two findings came back, both in src/lib/firebaseConfig.ts at line 7, both anchored to the hardcoded Firebase web API key:

{
  "findings": [
    {
      "id": "hardcoded_secret",
      "title": "Google API Key",
      "severity": "high",
      "confidence": "HIGH",
      "file": "src/lib/firebaseConfig.ts",
      "line": 7,
      "explanation": "Hardcoded Google API key detected. This key can be used to access Google Cloud services or Google Maps, incurring charges.",
      "recommendation": "Remove from source code. Restrict key usage in the Google Cloud Console and use environment variables."
    },
    {
      "id": "hardcoded_secret",
      "title": "High-Entropy Secret String",
      "severity": "medium",
      "confidence": "MEDIUM",
      "file": "src/lib/firebaseConfig.ts",
      "line": 7
    }
  ],
  "riskScore": { "score": 45, "recommendation": "REVIEW_BEFORE_SHIP" },
  "filesScanned": 8
}

Worth being straight about what that key finding actually means: a Firebase web apiKey isn't a secret in the traditional sense — Firebase's own documentation says it's fine to ship client-side, because Firebase's real access boundary is the rules file, not the key. ShipGuard's free-tier secret scanner doesn't know that distinction yet; it pattern-matches any AIza-prefixed string the same way it would a Google Maps key, and the recommendation it gives — restrict the key's scope in the Google Cloud Console — happens to be sound advice regardless. But it's a generic secrets finding, not a Firebase-aware one. For a longer look at what that same secrets scanner catches when the key involved really is the whole ballgame — a live payment-provider credential — see what a real secret scan found in a vibe-coded app.

Here's the part we won't soften: neither firestore.rules nor storage.rules produced a single finding. The free, local, no-login CLI has zero Firebase-specific detectors today — the checks that ship in the box are secrets, auth, database (Prisma/Supabase-shaped), payments, and deployment config, and none of them parse a .rules file. ShipGuard's broader rule corpus does define two Firebase-specific detectors — firebase.rules.firestore-public-read-write and firebase.rules.storage-public-access — but even those are built to catch the literal allow read, write: if true or a bare allow read, write;, the shorthand version many AI assistants paste when copying an old Stack Overflow answer. Test mode's actual console-generated rule, the time-boxed if request.time < timestamp.date(...) version above, is a different string. A pattern that only greps for if true won't catch it. That gap is exactly why this article exists instead of a one-line tool pitch: know what your scanner actually reads before you trust its silence.

How to Gate Firebase Rules Before You Deploy

Automating this starts with reading the two rules files yourself once, using the plain-English walkthrough above, before you ever open a terminal. After that, the habit that actually holds under deadline pressure is a scan wired into your deploy step, not a mental note to "check the rules eventually."

npx @agenticcli/shipguard scan --changed --strict --json

--changed keeps the scan to files touched since your last commit, so CI stays fast. --strict is what turns a finding into a blocked pipeline: as of ShipGuard v0.5.0, a plain scan always exits 0 and reports its findings in the JSON recommendation field — it's --strict that makes exit code 1 fire when something needs fixing, which is the signal your CI step should actually branch on. Exit code 2 means a config or network problem, not a security finding; exit code 3 is a runtime crash. Those codes are the contract — script against them, not against "0 means safe," which isn't what 0 means here.

For the rules files specifically, the practical discipline is boring on purpose: open firestore.rules and storage.rules any time you touch data access, confirm there's no bare wildcard match block left standing, and confirm every allow resolves to a request.auth check rather than a date. A scanner catches what it's told to look for. A person who's read the file once knows what "test mode" actually says.


About the author

Published by AgenticCLI. ShipGuard is a CLI-first release gate for AI-built apps — it runs locally, nothing in your codebase leaves your machine on the free tier, and it's built to grow into the checks this piece just showed you it doesn't have yet. Learn more at agenticcli.dev


{/* SCHEMA HINTS Required schema types for this article:

  • Article (author = Organization schema only; name: AgenticCLI; no individual author)
  • FAQPage (faq frontmatter array, 6 questions — rendered via the standard Faq accordion, same as .md posts)
  • Organization (name: AgenticCLI, url: https://agenticcli.dev)

Schema constraints:

  • Author must be Organization type, never an individual
  • No individual author URL in any href (no /author/* paths)
  • No sameAs pointing to an individual profile

Refer to docs/gtm/content-factory/pillar-template.md Section 8 for FAQPage JSON-LD template. Capture ID: firebase-test-mode-trap (mock-repo capture, ShipGuard v0.5.0, 2026-07-19) Internal links used: /shipguard, /blog/supabase-rls-ai-built-apps, /blog/secret-scanner-vibe-coded-app */}

FAQ

What is Firebase test mode?
Test mode is one of two options Firebase offers when you first create a Firestore database or Storage bucket, the other being "Locked mode." Test mode writes a security rule that grants full read and write access to anyone, with no authentication required, for exactly 30 days — the console generates a time-boxed condition like `if request.time < timestamp.date(2026, 8, 18)` rather than a flat `if true`. It exists to let you build without fighting permission errors during early development. It is not meant to reach production, and Firebase's own documentation says so directly.
How do I know if my Firebase database is public?
Open `firestore.rules` and search for a `match` block using the double wildcard, `match /{document=**}`, combined with an `allow read, write` that has either no condition, `if true`, or a date-based `request.time` check. If you find that combination anywhere in the file, every document in your database is reachable by anyone who knows your project ID — no login screen, no API key, nothing required. The same check applies to `storage.rules`, looking for `match /{allPaths=**}` instead. If your rules only grant access scoped to `request.auth.uid`, your data is not public.
Do Firebase security rules actually expire?
Only the ones test mode generates. The 30-day condition Firebase writes into a fresh test-mode project is a real, evaluated part of the rule — once the date passes, `request.time < timestamp.date(...)` becomes false and every request is denied, including your own app's. Rules you write yourself don't expire unless you build in a similar date check. The expiry isn't a safety net; it just converts an open-database problem into a broken-app problem on a fixed schedule, and plenty of teams have been surprised by both ends of that trade.
What's the difference between Firestore rules and Storage rules?
Both are security-rules files evaluated by Firebase before a request reaches your data, but they protect different resources. `firestore.rules` governs your Firestore database — documents and collections, evaluated per path with access to `resource.data` for field-level checks. `storage.rules` governs Cloud Storage — uploaded files, evaluated per file path, with no equivalent field-level inspection since a file isn't structured data. A project that hardens one and forgets the other is only half-covered; the Tea app's July 2025 exposure was specifically a storage-rules failure, not a Firestore one.
Does ShipGuard catch Firebase test-mode rules today?
Run with no login, ShipGuard's free CLI does not — its bundled checks cover secrets, auth, common database patterns, payments, and deployment config, and none of them parse `.rules` files yet. ShipGuard's broader rule corpus defines Firebase-specific detectors for the literal `allow read, write: if true` and bare `allow read, write;` patterns, which is the version many AI assistants paste from older tutorials. The console's actual test-mode default uses a date-expiry condition instead, which that pattern doesn't match. We're telling you this plainly because a tool that overclaims what it catches is worse than no tool — the manual read-through above closes that gap until the detector does.
How do I harden my Firebase rules before I ship?
Replace every wildcard `match` block's `allow` statement with a condition scoped to `request.auth.uid`, matching it against the path segment or against a stored owner field in `resource.data`. Do this for both `firestore.rules` and `storage.rules` — they're separate files and neither one hardens the other. Run `npx @agenticcli/shipguard scan --changed --strict --json` as part of your deploy step to catch hardcoded secrets and other issues in the same pass, and re-read the rules files by hand any time a new collection or storage path gets added, since that's when a new unscoped wildcard is most likely to slip back in.

────────[ ▮ gate ]────────

Don't ship the next one.

Free, local, no account. Catches this exact bug class before deploy.

$ npx @agenticcli/shipguard scan