hackymacky

Notes

← Blog

The Firebase web API key is not the secret

15 Oct 2026 · 1552 words

Firebase says the web apiKey only identifies the project. Security Rules decide who can read. How to test your own Realtime Database, Firestore, and Storage, and why a service-account JSON in the bundle is different.

A Firebase web config in a Next.js bundle looks like a leak. It has a field named apiKey, a project id, and sometimes a database URL. Secret scanners flag it. Firebase's own docs say not to.

The key identifies the project to Firebase services. It does not authorize a read. Security Rules do that for Realtime Database, Cloud Firestore, and Cloud Storage. App Check is a separate control for which app instances may call. If you lock the rules and restrict the key to Firebase APIs, the config in the client is the intended setup.

This note is how to tell those cases apart on a project you own: the web config, an open ruleset, and a service-account JSON. The curl examples hit your own database URL. They are GET requests. They do not create users or write documents.

What Firebase says the API key is

The API keys page is direct. Keys for Firebase services only identify your project and app. Authorization is Google Cloud IAM, Firebase Security Rules, and Firebase App Check. Unlike a typical API key, a Firebase key is not what controls access to backend resources. Rules control which end users can access resources. App Check controls which apps can access resources.

Firebase-provisioned keys are restricted to Firebase-related APIs. If the key meets the requirements on that page, Firebase says it is safe to include in code or config. The same page says Realtime Database, Cloud Firestore, and Cloud Storage are protected by Security Rules and App Check, not by keeping the API key secret.

The security checklist repeats the point. Start those products by denying all access, then add rules as you add paths. New Cloud Firestore instances have a production-mode default. New Realtime Database instances have a locked-mode default. Write the rule when you add the path, not as a launch-week chore.

A scanner that reports apiKey: "AIza…" next to projectId as a leaked secret is describing the client SDK's public config. The finding that matters is a ruleset that answers an unauthenticated read.

How the web config gets into the bundle

Next.js keeps environment variables on the server unless the name starts with NEXT_PUBLIC_. At next build, that prefix is replaced with the value from the build environment and written into the JavaScript sent to the browser. After the build, changing the variable does not change the files already emitted. The mechanism is the same one in the client-bundle post.

The Firebase web SDK is a client. It needs apiKey, authDomain, projectId, and, when you use them, databaseURL and storageBucket. Putting those on NEXT_PUBLIC_FIREBASE_* is how a browser app is supposed to initialize. A literal firebaseConfig object in a client module is the same outcome.

That is the opposite of a service account. The Admin SDK key is a Google Cloud credential. The checklist says that on Cloud Functions you do not paste it in: the Admin SDK can take Application Default Credentials. A JSON blob with "type": "service_account", a client_email ending in .iam.gserviceaccount.com, and a private_key does not belong in a client component, a NEXT_PUBLIC_ variable, or a file under public/.

Realtime Database: one unauthenticated GET

The Realtime Database REST reference says requests run with no authentication unless you pass auth or access_token. They succeed only when your rules allow public access. A rules rejection is HTTP 401. The body says permission denied. An expired or invalid auth token is also 401. For a request you sent with no token, 401 means the rules held.

shallow=true is the parameter to use when you only want to know whether the root is readable. It limits depth. If the location holds a JSON object, each child is returned as true instead of the child value. If the location holds a string, number, or boolean, that value is returned as-is. The reference says shallow cannot be combined with other query parameters, including the filters.

Take the databaseURL from your own config. It looks like https://your-project-default-rtdb.firebaseio.com or a regional host on firebasedatabase.app. Request only the root, with only that one parameter:

bash

curl -sS -D - -o /dev/null \
  'https://YOUR-PROJECT-default-rtdb.firebaseio.com/.json?shallow=true'

Read the status. Do not paste the body into a ticket. shallow=true still returns key names.

  • 401, or a body that says permission denied: the root read is not public.
  • 200 and null: the rules allowed the read and the root has no data. An empty node is not a locked node. Locked mode would have been 401.
  • 200 and a JSON object or a primitive: the root read is public. Treat that as open rules, even if you expected the data to be harmless.

Locked mode, the default the checklist points at for a new database, is a root that denies read and write:

json

{
  "rules": {
    ".read": false,
    ".write": false
  }
}

Open the paths you mean to publish. A root ".read": true is the tutorial snippet that makes the curl above succeed for every child. Do not leave it on a project that stores user data.

The REST API can also read and replace rules at /.settings/rules.json, and that call wants a project secret. Do not put that secret in the client, and do not use a read of the rules endpoint as a casual check. The data GET above is enough to see whether anonymous clients can read.

Firestore: rules on the unauthenticated call

The Cloud Firestore REST guide draws the same line. A Firebase Auth ID token is a user, and Firestore applies your Security Rules. A Google OAuth token for a service account is the application, and Firestore uses IAM and can ignore rules. An unauthenticated request is also judged by Security Rules.

The base URL is https://firestore.googleapis.com/v1/. Listing a collection your app already names looks like this, with pageSize=1 so you are not pulling a page of documents to answer a yes-or-no:

bash

curl -sS -o /dev/null -w '%{http_code}\n' \
  'https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/YOUR_COLLECTION?pageSize=1'

Use a collection id that appears in your client as collection(db, "…") or the first argument of doc(db, "…"). Do not invent a wordlist of table names against a project you are only supposed to be reviewing from the outside. This check is for the app you ship.

403 with PERMISSION_DENIED means the rules refused the list. 200 means the list was allowed. An empty 200 still means the list was allowed. Production mode starts from a deny. The rules language for that default is:

text

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

Add allow read only for documents you intend to publish. A match on /{document=**} with if true is the Firestore version of a root ".read": true.

The web apiKey can be sent as the key query parameter because that is how the client identifies the project. It still is not a user credential. If the call is 200 without an Authorization header, the rules allowed it.

Storage: list is its own rules check

Cloud Storage for Firebase lists objects through the Firebase Storage API, not by handing you a raw Google Cloud Storage token. The JavaScript SDK's list() builds https://firebasestorage.googleapis.com/v0/b/{bucket}/o and sends prefix, delimiter=/, and maxResults. maxResults defaults to 1000 and cannot be higher. For a check, set it to 1. That returns at most one item or prefix. It does not download bytes. Bytes are a different URL, with alt=media.

bash

curl -sS -o /dev/null -w '%{http_code}\n' \
  'https://firebasestorage.googleapis.com/v0/b/YOUR_PROJECT.appspot.com/o?prefix=&delimiter=%2F&maxResults=1'

The bucket string is storageBucket from your config. Older projects use appspot.com. Newer ones also use firebasestorage.app.

401 and 403 mean the list was refused. The SDK maps 401 to unauthenticated and 403 to unauthorized. 200 means the rules allowed the list. The list-files guide says list() fails on rules version 1 with an error that listing is disallowed until you move to rules_version = '2'. Version 1 is not a secure list. It is a missing list. The deny-by-default starting point on version 2 is:

text

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

Then allow read on the prefixes you mean to be public. A global allow read: if true makes the curl above return items to anyone.

The JSON that is a secret

Search the built client for a service account, not for AIza:

bash

rg -n "service_account|iam\\.gserviceaccount\\.com|BEGIN .*PRIVATE KEY" .next/static public app src || true

A hit with "type": "service_account" and a private key is a Google Cloud credential in a public file. Rules do not apply to it. IAM does. Delete that key on the service account in Google Cloud Console, under IAM, Service Accounts, Keys. Remove the file from the repo and from any NEXT_PUBLIC_, VITE_, or EXPO_PUBLIC_ variable. On Cloud Functions or Cloud Run, use the runtime's credentials the way the checklist describes, instead of shipping a JSON key.

Rotate, redeploy, and run the rg again. Do not prove the old key by calling an Admin API with it.

What to ignore, and what to fix

Ignore the web apiKey when it sits in a Firebase config and the key restrictions match Firebase's API key page. Fix a 200 from the Realtime Database root, a 200 from a Firestore list, or a 200 from a Storage list. Those are Security Rules. Fix a service-account private key as a leaked credential, the same way you would fix any other private key in the client bundle.

The Supabase version of this split is the anon key versus Row Level Security, which is the RLS post. The publishable client config is normal. The open data plane is the bug.