Is It Safe to Expose Firebase API Key to the Public?
Yes, Firebase API keys are public by design. Learn why exposure is not the risk, and how to secure your app with proper rules and auth instead.
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.
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);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;
}
// 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;
}
}
}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: 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
});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
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- Start the emulators:
firebase emulators:start --only firestore- 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'You should see:
{
"error": {
"code": 403,
"message": "Missing or insufficient permissions."
}
}If you are still seeing 200 OK on unauthorized requests, check:
- Your rules file is named
firestore.rulesand deployed withfirebase 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
FAQ#
Is Firebase API key the same as a secret key?#
No. Firebase's apiKey is a public identifier like your project ID. It does not grant access. What matters is your Security Rules and Authentication. The server-side secret is something like a service account key, not the browser-facing apiKey.
Should I regenerate my Firebase API key if it was exposed?#
No. That is usually unnecessary. The apiKey is safe to expose. Focus instead on auditing your rules and auth setup.
Can I restrict Firebase API key usage by domain or IP?#
Yes. Firebase supports API key restrictions. They can help reduce abuse, but they are not your primary security layer.
Do I need different API keys for development and production?#
No, but you should usually use different Firebase projects so data and rules stay isolated across environments.
How do Security Rules protect against exposed API keys?#
Security Rules run on Firebase's servers, not in your client. Even if an attacker knows your apiKey, they still cannot bypass rules. The apiKey identifies the project. The rules authorize the request.
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#
- Supabase vs Firebase in 2026: The Honest Comparison No One Is Telling You
- Why Developers Are Switching from Firebase to Supabase (And You Should Too)
- Docker Tutorial 2026: Dockerfile, Compose + Production Setup
- Fix ChunkLoadError: Loading Chunk Failed in Next.js
- Fix Dynamic Server Usage Error in Next.js App Router
- Fix "No Firebase App [DEFAULT] has been created" in Flutter
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Insert into multiple tables with one Supabase API call 2026
Struggling to insert rows into several tables with a single Supabase request? Learn how to wrap the inserts in a PostgreSQL function and call it via the Supabase client.
How to Convert a String to Number in TypeScript: 2026 Fix
Fix silent NaN errors when converting strings to numbers in TypeScript. Learn explicit parsing, type narrowing, and runtime validation.
TypeScript ! Operator: Fix Object Is Possibly Undefined
What the ! operator does in TypeScript, why it causes "Object is possibly undefined" errors, and how to replace it safely.