Firebase security rules are opt-in. The default, for every new database & storage bucket, is wide open. This is the writeup of a vulnerability started by a team that built an entire lending platform on Firebase, left 2 out of 3 services at their defaults, and what that meant for the people who trusted them with their data.

Somewhere in this story is a woman who applied for a small loan. She submitted her national ID number, her date of birth, her home address, her GPS coordinates, a photo of her face, a photo of her ID card. and a photo of her house. She listed her husband's name, her mother's maiden name, her guarantor's national ID number. She received a credit score. She signed digitally. She trusted that the platform handling all of this had taken the precautions that platforms are supposed to take.

She had no reason not to. That's not naivety. That's a reasonable assumption about how applications work.

This is about what those precautions actually looked like.

What Firebase Actually Is

Before getting into the vulnerability, it's worth understanding the platform, because the misconfiguration here is not a bug in Firebase. It's a misunderstanding of how Firebase is designed to work, and that distinction matters.

Firebase is a Backend-as-a-Service (BaaS) platform built and operated by Google. It lets development teams build production applications without managing traditional server infrastructure. Instead of provisioning database servers, configuring file storage, or building authentication systems from scratch, a team connects their app to Firebase and uses Google's managed services for all of it.

The relevant services for this vulnerability :

Firebase Storage is file hosting backed by Google Cloud Storage. Teams use it to store user-uploaded files: profile photos, ID card scans, document PDFs, form attachments. Files are organized in a bucket, accessible via a REST API.

Firebase Firestore is a document database. It stores structured data in collections of documents, each containing key-value fields. It's the equivalent of MongoDB in the Firebase ecosystem. This is where application data lives: user records, transaction histories, application submissions.

Firebase Realtime Database is Firebase's older JSON tree database. Some projects use it alongside Firestore for real-time sync features, others use it as the primary store. Structured differently from Firestore but the same access model: REST endpoints, security rules controlling access.

Each of these three services is separate. Each has its own REST API endpoints, its own data model, its own security rules configuration. But they all share one thing: a single `projectId`, the umbrella identifier that ties the entire Firebase project together.

That's the architecture detail that makes this class of vulnerability so impactful. One project, three services, three independent security configurations and if any of them is misconfigured, the others are often misconfigured too. Teams that build everything under one Firebase project tend to think about security at the project level, not the service level. When they forget to set rules, they usually forget across the board.

The Entry Point: init.json

There is a path that almost every Firebase-powered web application exposes by default.

It sits at `/__/firebase/init.json`. Firebase puts it there intentionally, so the frontend JavaScript SDK can initialize without hardcoding credentials into the app bundle. It's not hidden, not a mistake, not a misconfiguration by itself. Every developer who deploys a Firebase web app gets this file automatically, whether they think about it or not.

I've seen it many times. Most of the time you note it and move on.

This time I stayed a little longer.

{
  "apiKey": "AIzaSy[REDACTED]",
  "projectId": "[PROJECT-ID]",
  "storageBucket": "[PROJECT-ID].appspot.com",
  "databaseURL": "https://[PROJECT-ID].asia-southeast1.firebasedatabase.app",
  "authDomain": "[PROJECT-ID].firebaseapp.com"
}

Six fields. Short enough to read in ten seconds. Most people who encounter this file fixate on apiKey first — it sounds like a credential. It isn't. Firebase API keys are not authentication tokens. They're project routing identifiers, used to direct SDK calls to the correct Firebase project. They're designed to be public. You cannot authenticate as a user, access a database, or read a storage bucket using an API key alone. The API key is not the vulnerability.

The field that matters is projectId .

Once you have the projectId, you can construct the REST endpoint for every Firebase service on the project from scratch. The URL patterns are documented, consistent, and require no guessing:

Firebase Storage:
  https://firebasestorage.googleapis.com/v0/b/[PROJECT-ID].appspot.com/o

Firebase Firestore:
  https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/[collection]

Firebase Realtime Database:
  https://[PROJECT-ID].asia-southeast1.firebasedatabase.app/.json

All three reachable via plain HTTP requests. No browser, no SDK, no session cookie. Just the projectId and a curl command.

Whether those requests succeed or return 403 depends entirely on the security rules each service has configured. If the rules say "allow all," anyone can access anything. If the rules say "require auth," unauthenticated requests get rejected. The rules are the only gate.

With those three endpoints in hand, the next step was simple: test each one.

Mapping the Full Attack Chain

Before diving into each service, here's what the chain looked like from the outside in. This is the map that a single init.json response made possible:

[REDACTED].com/__/firebase/init.json          ← Entry point: one public URL
        │
        └── Exposes: projectId = "[PROJECT-ID]"
                        │
        ┌───────────────┼──────────────────────────────────┐
        │               │                                  │
        ▼               ▼                                  ▼
Firebase Storage   Firebase Firestore          Firebase Realtime DB
(appspot.com)      (firestore.googleapis.com)  (firebasedatabase.app)
        │               │                                  │
   READ  ⚠️👨🏻‍💻      READ  ⚠️👨🏻‍💻                      READ  🔒︎(403 ✅)
  WRITE  ⚠️👨🏻‍💻     WRITE  ⚠️👨🏻‍💻                     WRITE  🔒︎(403 ✅)
 DELETE  ⚠️👨🏻‍💻    DELETE  ⚠️👨🏻‍💻
        │               │
  100+ files        4 open collections:
  form schemas      ├── customers  → real borrower NIK, phone, GPS
  legal HTML        ├── loans      → loan amounts, disbursement, docs
  bank codes        ├── surveys    → complete filled applications
                    └── groups     → group metadata + moderator PII

The Realtime Database was the one service the team had locked down correctly. Everything else was open.

The First Test: Firebase Storage

Firebase Storage's listing endpoint accepts no authentication by default and returns a paginated JSON listing of every file in the bucket:

curl -s "https://firebasestorage.googleapis.com/v0/b/[PROJECT-ID].appspot.com/o?maxResults=1000"

HTTP 200. No credentials. Over 100 files in the response:

{
  "items": [
    {"name": "FCMImages/Capture.PNG"},
    {"name": "FCMImages/Security-Awareness-1000x1000.jpg"},
    {"name": "FIAMImages/Fraud-Awareness-Square (1) (1).jpg"},
    {"name": "csr/html/form/uk/loan_distribution-1.0.0.html"},
    {"name": "csr/html/form/uk/perjanjian_penanggungan-1.0.0.html"},
    {"name": "csr/html/terms/cashless/cashless_terms_and_condition-1.1.2.html"},
    {"name": "csr/json/bank/banks-1.0.2.json"},
    {"name": "csr/json/form/aplus/form-aplus-1.1.0.json"},
    {"name": "csr/json/form/monus/form-monus-1.0.0.json"},
    {"name": "uk/form-5.5.10.json"},
    {"name": "uk/form-5.5.9.json"},
    {"name": "uk/form-5.5.0.json"},
    {"name": "uk/form-5.3.2.json"},
    ...
  ]
}

Downloading any file follows a consistent pattern:

https://firebasestorage.googleapis.com/v0/b/[BUCKET]/o/[URL-encoded-filename]?alt=media

The `?alt=media` parameter instructs Firebase to return the file contents directly instead of the metadata envelope. Forward slashes in the filename become `%2F`

curl -s "https://firebasestorage.googleapis.com/v0/b/[PROJECT-ID].appspot.com/o/uk%2Fform-5.5.10.json?alt=media"

What was in the bucket? Mostly application scaffolding: versioned form schema JSON files, HTML legal documents, bank code reference lists, marketing images. The `uk/form-5.5.10.json` schema defines the full structure of the loan application form; field names, field types, validation rules, conditional logic, but contains no actual borrower data. It's a 114-field blueprint describing what a completed application looks like, not the completed applications themselves.

The bucket was misconfigured: unauthenticated listing, download, upload, and delete all returned HTTP 200. But the exposed files were templates, not records. Business logic exposed, not PII.

What the bucket did was tell me exactly what kind of platform this was and what the data schema looked like. Loan distribution forms. KTP (national ID card) photo upload fields. Guarantor fields. Cashless terms and conditions. Versioned form schemas with Indonesian field naming conventions.

This was a microfinance lending platform, almost certainly serving Indonesian borrowers. And if Storage had the form blueprints, Firestore almost certainly had the filled-out submissions.

Understanding Firestore's Structure

Firestore is Firebase's document database. The data model is straightforward: a database contains collections, each collection contains documents, each document contains fields. The REST API follows this hierarchy directly:

https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/[collection]/[documentId]

Hitting the collection endpoint without a document ID returns a paginated list of all documents in that collection. Hitting a specific document path returns that document's full field contents.

The catch: you need to know the collection name. Firestore doesn't expose a collection listing endpoint without authentication. Without a valid name, the API returns an error. With a valid name and open security rules, it returns everything.

Collection names in a microfinance lending platform are not a mystery. Developers name things after what they contain. Any team building this kind of system reaches for the same vocabulary: `customers`, `loans`, `borrowers`, `users`, `applications`, `surveys`, `payments`, `transactions`, `groups`, `branches`, `agents`.

The testing methodology is simple and the response codes are unambiguous:

  • HTTP 200: collection exists and is readable without authentication. Vulnerability confirmed.
  • HTTP 403: collection exists but requires authentication. Correctly secured.
  • HTTP 404: collection does not exist.
curl -s -o /dev/null -w "%{http_code}" \
  "https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/customers?pageSize=1"

I tested over 80 collection names. Here is what the response codes mapped to:

| Collection | HTTP | Has Documents | Contents |
| - -| - -| - -| - -|
| `customers` | 200 | Yes | Full borrower PII |
| `loans` | 200 | Yes | Loan records + document URLs |
| `surveys` | 200 | Yes | Complete filled applications |
| `groups` | 200 | Yes | Group metadata + moderator PII |
| `users` | 200 | Empty | Accessible, no data |
| `borrowers` | 200 | Empty | Accessible, no data |
| `transactions` | 200 | Empty | Accessible, no data |
| 70+ others | 200 | Empty | Accessible, no data |
| Realtime DB (all paths) | 403 | - | Correctly secured |

Four collections containing real production data. Seventy-plus that were accessible but empty. And the Realtime Database, across every path tried, returned 403. One out of three services had functioning security rules. Two did not.

The accessible-but-empty collections are worth noting. They confirm that the security rules were missing entirely, not just misconfigured for specific collections. Any collection the team had ever created or would ever create in this Firestore instance was open to the public, including future collections they hadn't built yet.

The Customers Collection: Borrower PII at Scale

Customer IDs in the `customers` collection followed recognizable numeric ranges: `2020xxxxxx` and `5001xxxxxx`. The prefix pattern is consistent with registration year and batch grouping. Sequential enumeration from a known starting ID worked directly.

curl -s "https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/customers/5001000000"

HTTP 200:

{
  "name": "projects/[PROJECT-ID]/databases/(default)/documents/customers/5001000000",
  "fields": {
    "name":         { "stringValue": "SITI [REDACTED]" },
    "legalId":      { "stringValue": "14030[REDACTED]" },
    "sms":          { "stringValue": "+62812[REDACTED]" },
    "address":      { "stringValue": "GG [REDACTED]" },
    "ktpKelurahan": { "stringValue": "[REDACTED]" },
    "ktpKecamatan": { "stringValue": "[REDACTED]" },
    "bankName":     { "stringValue": "bri" },
    "updatedAt":    { "stringValue": "2026-02-21 08:23:16" },
    "geoTagHome": {
      "mapValue": { "fields": {
        "latitude":  { "doubleValue": [REDACTED] },
        "longitude": { "doubleValue": [REDACTED] }
      }}
    },
    "photoPerson":     { "stringValue": "https://storage.googleapis.com/[REDACTED]/survey/8039829/..." },
    "photoHome":       { "stringValue": "https://storage.googleapis.com/[REDACTED]/survey/8039829/..." },
    "photoPersonBuss": { "stringValue": "https://storage.googleapis.com/[REDACTED]/survey/8039829/..." }

The `updatedAt` field: five days before the test. This was not a staging environment or a demo dataset. A real person's record, updated five days prior, containing their full name, national ID number (`legalId`), phone number, home address, sub-district and district, bank name, and precise GPS home coordinates, alongside direct URLs to their personal and home photos.

The photo URLs pointed to Google Cloud Storage. Those were also accessible without authentication, because the Storage bucket itself was open.

There were hundreds of records like this one, spread across the `2020xxxxxx` and `5001xxxxxx` ID ranges. Customer-level PII for every person who had ever been registered on the platform, sitting in an unauthenticated REST endpoint.

The Loans Collection: Financial Records

The `loans` collection stored individual loan records, each linked back to a customer via the `customerNumber` field. This cross-reference was how specific customer IDs with active records were first confirmed enumerate loans, extract `customerNumber`, query that customer directly.

curl -s "https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/loans/1000041"

HTTP 200:

{
  "fields": {
    "id":             { "stringValue": "1000041" },
    "customerNumber": { "stringValue": "20200[REDACTED" },
    "purpose":        { "stringValue": "Ternak Sapi" },
    "principal": {
      "mapValue": { "fields": {
        "amount":   { "stringValue": "4000000" },
        "currency": { "stringValue": "IDR" }
      }}
    },
    "disbursedDate":  { "stringValue": "2021-01-27T09:33:55.22747Z" },
    "sector":         { "stringValue": "Peternakan" },
    "state":          { "stringValue": "CLOSED" },
    "subState":       { "stringValue": "PAID OFF" },
    "docs": { "arrayValue": { "values": [{
      "mapValue": { "fields": {
        "type": { "stringValue": "doc-loa" },
        "url":  { "stringValue": "https://storage.googleapis.com/[REDACTED]/doc-loa/DocumentLOA_100004120210127...pdf" }
      }}
    }]}}
  }
}

Each loan record contained: loan ID, customer cross-reference, stated loan purpose, principal amount and currency, disbursement date, economic sector, current state (active, closed, paid off), and a direct URL to the signed loan agreement PDF stored in Firebase Storage.

Those document URLs were also accessible without authentication.

The `loans` collection contained hundreds of records spanning disbursement dates from 2021 through 2026, representing the full history of lending activity on the platform.

The Surveys Collection: The Most Sensitive Data

The `surveys` collection was where the filled loan applications lived. If `customers` showed you the borrower profile, `surveys` showed you the entire loan application submission, every field from that 114-field schema in Storage, populated with real data from a real person who submitted it to request a loan.

Each survey document had two layers: top-level processed fields (credit score, approval status, loan cycle) and a nested `_raw` map containing the complete verbatim form submission.

curl -s "https://firestore.googleapis.com/v1/projects/[PROJECT-ID]/databases/(default)/documents/surveys/1093924"

HTTP 200. Application #1093924, borrower [REDACTED]:

[Top-level processed fields]
  fullname:         [REDACTED]
  creditScoreValue: 814.05
  creditScoreGrade: A
  stage:            APPROVED_BM
  loanCycle:        1

[_raw — complete form submission]
  client_fullname:             [REDACTED]
  client_ktp:                  [REDACTED - National ID Number]
  client_birthdate:            [REDACTED]
  client_birthplace:           Pekalongan
  client_religion:             Islam
  client_jenis_kelamin:        Perempuan
  client_maritalstatus:        Menikah
  client_ibu_kandung:          [REDACTED - Mother's maiden name]
  client_phone:                [REDACTED]
  client_alamat:               [REDACTED]
  client_kecamatan:            [REDACTED]
  client_kota_kab:             Pekalongan
  client_provinsi:             Jawa Tengah
  geotagging:                  [REDACTED]
  data_suami:                  [REDACTED - Husband's name]
  client_ktp_penanggung_jawab: [REDACTED - Guarantor's National ID]
  data_pengajuan:              3,000,000 IDR
  plafond:                     3,000,000 IDR
  rate:                        0.3167 (31.67%/year)
  installment:                 79,000 IDR/week
  tenor:                       50 weeks
  disbursementDate:            2021-06-08

  photo_ktp:                   https://storage.googleapis.com/[REDACTED]/survey/1093924/...jpeg
  photo_client_selfie:         https://storage.googleapis.com/[REDACTED]/survey/1093924/...jpeg
  photo_client:                https://storage.googleapis.com/[REDACTED]/survey/1093924/...jpeg
  photo_client_house:          https://storage.googleapis.com/[REDACTED]/survey/1093924/...jpeg
  photo_ktp_penanggung_jawab:  https://storage.googleapis.com/[REDACTED]/survey/1093924/...jpeg
  client_digital_signature:    https://storage.googleapis.com/[REDACTED]/survey/1839892/...
  form_tr:                     https://storage.googleapis.com/[REDACTED]/loan/1178404/...pdf

Let me be specific about what this single document contained:

Full name. National ID number (NIK). Date of birth. Birthplace. Religion. Gender. Marital status. Mother's maiden name. Phone number. Full home address including street, sub-district, district, and province. Precise GPS coordinates of home. Husband's full name. Guarantor's national ID number. Loan amount requested. Approved loan amount. Annual interest rate. Weekly installment amount. Loan tenor in weeks. Disbursement date. Credit score value and letter grade. Internal approval stage and loan cycle number.

Plus direct URLs, all unauthenticated, to: the borrower's KTP (national ID card) photo, a selfie, a personal photo, a home exterior photo, the guarantor's KTP photo, the borrower's digital signature, and the signed loan agreement PDF.

This is a complete financial and personal identity dossier. In aggregate, the `surveys` collection contained hundreds of records in this format. Every person who had ever submitted a loan application on this platform.

Write Access: When Read Is Not the Worst Part

Reading hundreds of borrower records is a serious confidentiality violation. But the security rules that permitted reading also permitted writing, modifying, and deleting.full CRUD access with no authentication at any point.

Creating a new document in any collection:

## Construct from the Firestore REST API
...
...

payload = {
    "fields": {
        "name":    {"stringValue": "ATTACKER INJECTED"},
        "legalId": {"stringValue": "9999999999999999"}
    }
}
# POST to /documents/customers → HTTP 200

Response:

{
  "name": "projects/[PROJECT-ID]/databases/(default)/documents/customers/TYF6XDy0lXqazvvepLhy",
  "fields": {
    "name":    {"stringValue": "ATTACKER INJECTED"},
    "legalId": {"stringValue": "9999999999999999"}
  },
  "createTime": "2026-02-26T12:17:39.658121Z"

Modifying an existing document: PATCH to the document path with new field values; HTTP 200, record overwritten.

Deleting a document: DELETE to the document path, HTTP 200, record permanently gone with no recovery path.

I created a canary document in an isolated test collection to confirm write access, then immediately deleted it. No real records were modified or deleted. But the access was real and unrestricted.

What write and delete access means in practice for a production lending platform:

Fraudulent record injection: Insert fake borrower records or loan approvals directly into production collections, bypassing the application's validation layer entirely.

Data tampering: Modify loan amounts, approval statuses, credit scores, or repayment records for any existing borrower. A bad actor could mark a loan as repaid, change a credit grade from F to A, or alter disbursement amounts.

Evidence destruction: Delete loan records, customer profiles, or survey submissions. For a regulated financial platform, missing records are a compliance and legal liability.

Full exfiltration: Script sequential reads across the customer ID ranges to pull every borrower record in the database. The API imposes no rate limiting that would prevent this.

The misconfiguration does not distinguish between a researcher running a single test and an attacker running a scripted sweep. The same rules or lack of rules, apply to both.

What Comes After the Chain Completes

When a chain like this closes, the feeling is not triumph. A single bug is a door. A chain like this is discovering that the building has no locks and never did.

I kept thinking about the scale. Not abstractly, specifically. The `customers` collection had hundreds of records. The `surveys` collection had hundreds of complete application submissions. Every person who had ever applied for a loan on this platform, every piece of information they had submitted in trust, sitting in a public API endpoint with no access control whatsoever.

The `surveys` collection was the part that stayed with me. It wasn't just that PII was exposed. It was the completeness of it. Religion. Mother's maiden name. Husband's name. A credit score. A digital signature. The kind of data that, in aggregate, is a complete personal, financial, and social profile of a person. Fields that exist in a loan application precisely because they are sensitive, identity verification, anti-fraud, credit assessment. And all of it retrievable by anyone who could type a URL.

I stopped enumerating after confirming the pattern across a small number of records. The vulnerability was proven. Going further would have meant accessing data I had no legitimate reason to read.

What I didn't stop thinking about was how long this had been this way. The oldest loan records dated back to 2021. The `updatedAt` timestamps in the `customers` collection showed active updates through the week of the test. This wasn't a recently deployed misconfiguration. It had been open for years, across the entire operational life of the platform, while the borrowers it served had no idea.

The Lesson: Test Every Service, Every Time

The pattern that makes Firebase misconfiguration so common is the way teams think about security at the project level rather than the service level.

A developer secures the Realtime Database. They write rules, test them, they work. They move on with the assumption that the other services are handled the same way. But Firestore has its own rules file, separate from the Realtime Database. Storage has its own rules file, separate from Firestore. Each service has to be configured independently.

The team that built this platform did exactly one thing right: they locked down the Realtime Database. If you only look at that service, the security posture looks considered. But they built the real application data on Firestore and Storage, and neither had rules.

This is now a reflexive part of how I approach any Firebase-backed application. Find the `init.json`. Extract the `projectId`. Test all three services. Don't assume that one secured service means the others are secured. The pattern holds more often than it should: if one is misconfigured, check the others immediately.

The Realtime Database 403 was almost misleading. It created a superficial impression of a team that thought about security. The impression collapsed the moment I tested Firestore.

The Fix

Every Firebase service has its own security rules configuration, managed in the Firebase Console or deployed via the Firebase CLI. The Firestore and Storage rules for this project were at the default open state. In Firestore, that default looks like this:

// Default open rules — anyone, anywhere, no authentication required
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write;
    }
  }
}

The baseline fix is requiring authentication before any access:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

For Storage, the same baseline in `storage.rules`:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

The right model goes further. In a lending platform, not every authenticated user should read every document. The correct rules reflect the application's actual access model:

  • A borrower can read and update only their own customer record.
  • A loan officer can read records associated with their assigned branch or group.
  • Survey submissions can only be read by the submitting borrower or authorized staff.
  • No user, authenticated or not should have delete access to production financial records without an explicit admin role check.

But `if request.auth != null` is the baseline that eliminates unauthenticated access entirely. It's two words added to an existing rule. The team already knew the syntax, the Realtime Database rules proved it. The rules for Firestore and Storage just weren't there.

One consistent decision applied across three services instead of one closes the entire chain.

What init.json Is and Isn't

The `init.json` file is not the vulnerability. It cannot and should not be removed. Firebase web apps need it to initialize, and removing it breaks the frontend SDK. There are no secrets in that file that should be hidden.

The vulnerability is a mental model error: "the frontend needs this config file, therefore the backend is safe because clients have to go through the frontend first." That assumption is wrong. The Firebase REST APIs are public-facing, fully documented, and completely bypasses the frontend. Any attacker can construct a valid Firestore or Storage request using nothing but the `projectId` and a terminal.

The security boundary in Firebase exists only in the server-side rules. The `init.json` file tells you where every service lives. The rules file controls whether you can get inside. If the rules file is empty, the boundary is empty.

Every Firebase project I review now, I check all three services. The pattern holds more reliably than it should: if a team misconfigured one, they usually misconfigured the others. The Realtime Database being secured here was the exception. Two out of three services wide open was enough for full compromise of hundreds of borrower records.

The woman who submitted her loan application did everything she was supposed to do. She trusted that the platform had done the basic things platforms are supposed to do. A two-line rule change in a configuration file, applied when the database was first created, would have made that trust warranted.

It wasn't applied. This is what that cost.

If you're building on Firebase: open the Firebase Console right now, go to Firestore → Rules, Storage → Rules, and Realtime Database → Rules. Read each one carefully. If any of them contain `allow read, write;` without a condition, that service is open to the public internet at this moment.