DEV Community

Cover image for Is It Safe to Expose Firebase API Key to the Public?
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Is It Safe to Expose Firebase API Key to the Public?

TL;DR

If you have seen the Stack Overflow question "Is it safe to expose Firebase API key to the public?" and you are wondering whether your app is at risk because you committed your apiKey to GitHub, here is the truth: it is safe by design. The real danger is not the exposed key itself, but missing or misconfigured Firebase Security Rules and client-side authentication checks.

The root cause of confusion is treating Firebase's apiKey like a traditional backend secret such as a database password or JWT signing key. It is not. The apiKey is just an identifier, like a username, that tells Firebase which project to connect to. What protects your data is the combination of:

  • Firebase Authentication to verify who is making the request
  • Security Rules to define what each authenticated or anonymous user can do

Fix it by auditing your Security Rules, enforcing auth != null where needed, and using Firebase's built-in rate limiting and monitoring, not by trying to hide the apiKey.

If that does not resolve your anxiety, scroll to Verify the fix and I will show how to test your rules locally and simulate attacker access.

  • Symptom: You are worried your app is compromised because your apiKey is public, for example in firebaseConfig in src/lib/firebase.ts
  • Root cause: You are conflating Firebase's public identifier (apiKey) with a secret credential
  • Fix: Replace secret-hiding habits with proper Security Rules (rules_version = '2'; + allow read, write: if request.auth != null) and client-side auth guards
  • Verification: Use firebase emulators:start and curl to simulate unauthenticated requests and confirm they are rejected by rules

What you'll see

You will see the exact question on Stack Overflow, "Is it safe to expose Firebase API key to the public?", with 893 upvotes and more than 334,000 views. Developers ask this because they have seen code like this:

// src/lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: 'my-app.firebaseapp.com',
  projectId: 'my-app',
  storageBucket: 'my-app.appspot.com',
  messagingSenderId: '123456789',
  appId: '1:123456789:web:abcdef',
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
Enter fullscreen mode Exit fullscreen mode

They commit this to a public GitHub repo and then worry someone will "steal" the apiKey and access the database.

This fear reproduces across local dev, staging, and production in Next.js, Vite, and plain HTML apps. The apiKey is visible in shipped client code and can often be seen through devtools, source maps, or network activity. That is by design, not a bug.

Root cause

The confusion comes from how Firebase structures its SDK and security model. Unlike traditional APIs where a secret key grants access, Firebase uses a client-side identifier (apiKey) to route requests to the correct project, and server-side rules to enforce access control.

The apiKey is not a secret. It is a public identifier, like your project ID. A useful mental model is:

  • apiKey = "Which Firebase project am I talking to?"
  • auth = "Who am I?" verified by Firebase Auth
  • security rules = "What am I allowed to do?"

The relevant code path is:

// src/lib/firebase.ts (real, runnable)
import { initializeApp, FirebaseApp } from 'firebase/app';
import { getAuth, User, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, doc, getDoc } from 'firebase/firestore';

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

const app: FirebaseApp = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);

// Client-side auth check, not a security rule.
export const getCurrentUser = (): Promise<User | null> => {
  return new Promise((resolve) => {
    onAuthStateChanged(auth, (user) => {
      resolve(user);
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

This code is safe only if your Firestore rules look like this:

// firestore.rules (real, runnable)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      // Users can only read/write their own profile
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
    match /public/{document} {
      // Public data - anyone can read
      allow read: if true;
      allow write: if false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The root cause of the panic is this: developers assume the apiKey is a credential like a password and that exposing it lets attackers break in. But Firebase deliberately separates identification from authorization. The apiKey alone cannot grant access. It only identifies the project. Without valid authentication and without satisfying Security Rules, requests are rejected even with the correct apiKey.

That is why trying to hide the apiKey via server-side rendering, environment variables, or proxying does not improve security. It adds complexity while obscuring the real fix: enforcing rules.

The fix

The fix is simple: stop trying to hide the apiKey. Start enforcing rules.

Here is how to secure your app properly.

Step 1: Move apiKey to environment variables, but keep it public

In Next.js, use NEXT_PUBLIC_ prefixed variables. They are safe to expose because they are intended to be public:

// src/lib/env.ts (real, minimal)
export const getFirebaseConfig = () => ({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
});
Enter fullscreen mode Exit fullscreen mode

Then in src/lib/firebase.ts:

// src/lib/firebase.ts (real, runnable)
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getFirebaseConfig } from './env';

const config = getFirebaseConfig();

if (!config.projectId) {
  throw new Error('NEXT_PUBLIC_FIREBASE_PROJECT_ID is required');
}

const app = initializeApp(config);
export const auth = getAuth(app);
export const db = getFirestore(app);
Enter fullscreen mode Exit fullscreen mode

This does not hide the key. It only avoids hardcoding it. The key is still public, and that is fine.

Step 2: Write strict Security Rules

The real security layer is in your rules. Here is a production-ready Firestore ruleset:

// firestore.rules (real, runnable)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Public data - read-only, no writes
    match /public/{document} {
      allow read: if true;
      allow write: if false;
    }

    // User profiles - only the owner can read/write
    match /users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // Private user data - authenticated users only, no writes
    match /private/{document} {
      allow read: if request.auth != null;
      allow write: if false;
    }

    // Admin-only collection
    match /admin/{document} {
      allow read, write: if request.auth != null && request.auth.token.admin == true;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This ruleset enforces:

  • Public data is readable by anyone, including unauthenticated users
  • User data is readable and writable only by the owner
  • Admin data requires a custom claim of admin: true
  • No one can write to public or private

Step 3: Add client-side auth guards

Never trust the client. Always check auth state before rendering sensitive UI:

// src/components/Profile.tsx (real, runnable)
'use client';
import { useEffect, useState } from 'react';
import { useAuthState } from 'react-firebase-hooks/auth';
import { auth } from '@/lib/firebase';
import { doc, getDoc } from 'firebase/firestore';
import { db } from '@/lib/firebase';

export default function Profile() {
  const [user, loading] = useAuthState(auth);
  const [profile, setProfile] = useState<Record<string, unknown>>({});

  useEffect(() => {
    if (!user) return;
    const fetchProfile = async () => {
      const docRef = doc(db, 'users', user.uid);
      const snap = await getDoc(docRef);
      if (snap.exists()) {
        setProfile(snap.data());
      }
    };
    fetchProfile();
  }, [user]);

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>Please sign in.</div>;

  return (
    <div>
      <h1>{profile.name || user.email}</h1>
      <p>ID: {user.uid}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component will not render data unless user is authenticated, but the real protection is still in the rules. This is just a UX guard.

Step 4: Enable App Check

App Check helps prevent abuse from non-Firebase SDK clients such as bots calling your API directly. It is not a replacement for rules. It is a defense-in-depth layer.

In firebaseConfig, add:

// src/lib/env.ts (real, runnable)
export const getFirebaseConfig = () => ({
  // ...other keys...
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  // App Check is enabled via Firebase Console, not config
});
Enter fullscreen mode Exit fullscreen mode

Then enable App Check in the Firebase Console under Build -> App Check and configure reCAPTCHA v3 or another provider.

Client-side security in Firebase

"Client-side security" refers to the checks and guards your app performs in the user's browser or mobile runtime, code the user can read and modify. In a Firebase app, this typically includes:

  • Monitoring auth state with onAuthStateChanged or useAuthState
  • Conditionally rendering UI for signed-in users
  • Validating form input for a better user experience
  • Managing and refreshing ID tokens used by Firebase Auth

Client-side security matters for UX, but it has a hard architectural limit: the client is hostile territory. Any check running in the browser can be bypassed by devtools, a modified bundle, a browser extension, or a direct curl request to the Firebase endpoint.

This is why Firebase enforces authorization server-side. Security Rules run on Google's infrastructure, evaluate the verified request.auth token, and reject non-compliant requests before they touch your data. The apiKey only routes the request to the correct project. The rules decide whether it proceeds.

The right mental model is simple: client-side security tells your UI what to show. Server-side rules decide what the database will accept. Use both, but trust only the rules with your data.

Firebase Realtime Database: the same security model applies

If your project uses Firebase Realtime Database instead of, or alongside, Firestore, the answer is the same. RTDB reads the same apiKey from your firebaseConfig, uses the same client-side model, and follows the same design assumption: the key is a public project identifier, not a secret.

RTDB uses a JSON-based rules language instead of the Firestore DSL. A production-ready RTDB ruleset looks like this:

{
  "rules": {
    "users": {
      "$userId": {
        ".read": "auth != null && auth.uid === $userId",
        ".write": "auth != null && auth.uid === $userId"
      }
    },
    "public": {
      ".read": true,
      ".write": false
    },
    "admin": {
      ".read": "auth != null && auth.token.admin === true",
      ".write": "auth != null && auth.token.admin === true"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This ruleset enforces the same guarantees:

  • users/$userId is readable and writable only by the owner
  • public is world-readable but not writable
  • admin requires a custom claim of admin: true

Deploy RTDB rules with:

firebase deploy --only database
Enter fullscreen mode Exit fullscreen mode

And test them locally with:

firebase emulators:start --only database
Enter fullscreen mode Exit fullscreen mode

The bottom line is the same for Firestore and RTDB: the apiKey is safe to expose, and your data is protected by server-enforced Security Rules, not by hiding the key.

Verify the fix

Run the Firebase Emulator Suite to test your rules locally:

  1. Install the emulator:
npm install -g firebase-tools
Enter fullscreen mode Exit fullscreen mode
  1. Initialize the emulator:
firebase init emulators
Enter fullscreen mode Exit fullscreen mode
  1. Start the emulators:
firebase emulators:start --only firestore
Enter fullscreen mode Exit fullscreen mode
  1. In a new terminal, simulate an unauthenticated request:
curl -X GET \
  'http://localhost:8080/firestore/v1/projects/dev-database/databases/(default)/documents/users/abc123' \
  -H 'Content-Type: application/json'
Enter fullscreen mode Exit fullscreen mode

You should see:

{
  "error": {
    "code": 403,
    "message": "Missing or insufficient permissions."
  }
}
Enter fullscreen mode Exit fullscreen mode

If you are still seeing 200 OK on unauthorized requests, check:

  • Your rules file is named firestore.rules and deployed with firebase deploy --only firestore
  • You are using rules_version = '2'
  • Your client code is not bypassing the intended access path

Why this happens (and how to avoid it next time)

The underlying invariant is this: Firebase is designed for client-side access. It assumes your API key is public and shifts security responsibility to rules and auth, not obscurity.

To prevent this confusion:

  • Keep Firebase config in NEXT_PUBLIC_* variables instead of hardcoding it
  • Test your rules in CI with emulator-based tests
  • Use API key restrictions as a minor abuse mitigation, not as your core security layer

Summary

Exposing your Firebase apiKey is safe by design. The key tells Firebase which project a client is targeting, but it does not grant access to your data. What protects you is the combination of Firebase Authentication and Security Rules. Whether you use Firestore or Realtime Database, the model is the same: write strict rules, require auth where needed, add App Check and restrictions for defense in depth, and stop treating the client-facing apiKey like a password.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)