Is It Safe to Expose Firebase API Key to the Public?
Firebase-only API keys can be public. Apply API restrictions, Security Rules and App Check; keep keys for other services separate.
TL;DR#
A browser-facing apiKey is safe to include in client code when restricted to Firebase-related APIs. The Firebase API keys documentation explains the distinction: the key identifies the project; Security Rules protect its data. Check the API allowlist as well as the rules. A public key must not also enable the Gemini Developer API. If getAuth() still throws auth/invalid-api-key because the apiKey value is missing or empty (for example, an unset environment variable), see why it still throws without a real apiKey — the check runs before Firebase ever reaches the network.
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, restricting each path by ownership, role or attribute (for example allow read, write: if request.auth != null && request.auth.uid == userId on /users/{userId}; auth != null alone admits any signed-in user, which Firebase lists among insecure rules), and using Firebase's built-in rate limiting and monitoring, not by trying to hide the apiKey.
Use Verify the fix to test unauthenticated access against the local emulator.
What you'll see#
You will see the exact question on Stack Overflow, "Is it safe to expose Firebase API key to the public?". 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);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 Authsecurity 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);
});
});
};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;
}
}
}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,
});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);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;
}
// Shared data - any signed-in user can read, 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;
}
}
}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
publicorprivate
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>
);
}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: Initialise App Check, then enforce it#
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.
Register the web app and its reCAPTCHA provider in the Firebase Console. Then initialise the App Check web SDK in the existing Firebase browser module, before calling the service SDKs. Setting appId alone does not initialise App Check:
// src/lib/firebase.ts — uses getFirebaseConfig from Step 1
import { initializeApp } from 'firebase/app';
import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getFirebaseConfig } from './env';
const app = initializeApp(getFirebaseConfig());
if (typeof window !== 'undefined') {
const siteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
if (!siteKey) throw new Error('NEXT_PUBLIC_RECAPTCHA_SITE_KEY is required');
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(siteKey),
isTokenAutoRefreshEnabled: true,
});
}
export const auth = getAuth(app);
export const db = getFirestore(app);Set the site-key environment variable to the registered reCAPTCHA site's public key. Deploy the client, inspect App Check metrics, then enable enforcement for the supported products you use. Registering the app without client initialisation and enforcement does not reject unverified traffic. If this module uses ReCaptchaEnterpriseProvider instead while Authentication also uses reCAPTCHA Enterprise, an older Firebase SDK can throw Invalid site key or not loaded in api.js until it is upgraded.
Step 5: Check API restrictions and Authentication abuse#
A public key can still be used for Authentication requests. Firebase documents quota controls for password-based Authentication to mitigate brute-force attempts. Review usage before tightening limits so legitimate sign-ins continue to work. Use separate restricted keys for non-Firebase APIs.
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
onAuthStateChangedoruseAuthState - 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"
}
}
}This ruleset enforces the same guarantees:
users/$userIdis readable and writable only by the ownerpublicis world-readable but not writableadminrequires a custom claim ofadmin: true
Deploy RTDB rules with:
firebase deploy --only databaseAnd test them locally with:
firebase emulators:start --only databaseThe 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:
- Install the emulator:
npm install -g firebase-tools- Initialize the emulator:
firebase init emulators- Save the rules from Step 2 as
firestore.rules. Add this entry to the existingfirebase.json, keeping its other settings. The emulator rules configuration must name the local file; without configured rules, the emulator can allow access by default:
{
"firestore": {
"rules": "firestore.rules"
}
}- Start the emulator using that configuration:
firebase emulators:start --only firestore- In a new terminal, simulate an unauthenticated request:
curl -X GET \
'http://localhost:8080/v1/projects/dev-database/databases/(default)/documents/users/abc123' \
-H 'Content-Type: application/json'Use the emulator's actual project ID in place of dev-database. This is the documented emulator REST path. With the rules loaded and unauthenticated reads denied, expect an HTTP 403 permission error, for example:
{
"error": {
"code": 403,
"message": "Missing or insufficient permissions."
}
}If you are still seeing 200 OK on unauthorized requests, check:
- The local
firebase.jsonnamesfirestore.rules, and the emulator loaded that file; restart the emulator after changing its configuration - 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#
Keep a public Firebase key restricted to Firebase services. Protect data with rules, initialise and enforce App Check where supported, and monitor Authentication usage. Keep non-Firebase keys separate; the public-key guidance does not make those keys safe to expose.
Further reading: Firebase Hosting 404s Every Route Except index.html, Fix Firestore 'Host has been set in both settings()' error, Firebase Auth IDBDatabase Transaction Error: 2026 Fix.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Cloud Firestore vs Realtime Database in 2026
Firestore wins for almost every new app — richer queries, multi-region, saner scaling. Realtime Database survives for one job. The verdict, with numbers.
Insert into multiple tables with one Supabase API call 2026
Insert into several tables with one Supabase request? Wrap the inserts in a PostgreSQL function and call it via the Supabase client.
Supabase Auth + Middleware: Session Guide for Next.js 15
Supabase auth and session management in Next.js 15: middleware patterns, cookie handling, refresh tokens, MFA, and the silent failures that ruin auth.

