DEV Community

A plaintext Firebase password authenticated anyone who visited the site - here's how I fixed it without disconnecting anyone

The Setup

PanelControl is a vanilla-JS internal panel backed by Firebase Realtime Database + Firestore. Operators log in with email/password, checked client-side against a database node, with a lockout after failed attempts. Nothing unusual so far.

The original ask was narrow: add Telegram notifications for a handful of suspicious events - brute-force attempts, a never-before-seen device for an operator, an unauthorized attempt to reach the Admin section, DevTools opened during use. Pure alerting work.

Bug #1: The login button that always unlocks

Before writing any alerting logic, a review of the existing Admin-area password check turned up this:

// โŒ The "|| true" makes the whole condition always truthy
function checkAdminPwd() {
  if (el.value || true) {
    unlockAdmin(); // runs regardless of what's typed, or nothing at all
  }
}

A debug leftover that made it to production. Anyone who landed on the Admin password overlay got in by clicking "Log in" - password or not. Fixed by actually wiring the real permission check, plus a server-side-verified fallback in case the function were ever called directly from the console.

The real discovery: a shared, hardcoded Firebase credential

Looking at the Realtime Database Rules ahead of the alerting work surfaced something much bigger. The Rules restricted read/write to a single fixed auth.uid - reasonable, until you check who actually gets that uid. This ran unconditionally, for every visitor, before the login screen even appeared:

// โŒ Plaintext Firebase credentials, executed for EVERY visitor
var _fbEmail = ['[shared-account]', '[domain]'].join('@');
var _fbPass = '[REDACTED]';
firebase.auth().signInWithEmailAndPassword(_fbEmail, _fbPass);

The app's real login form - personal email, personal password, permission checks - protected nothing at the database level. It was a UI filter sitting on top of a database that was already wide open to anyone who simply loaded the page. No need to even read the source to get the credentials; the app authenticated itself with them automatically.

This isn't a one-line patch. Credentials shipped in a public JS bundle can never be truly secret, and with one Firebase identity shared by every operator, the Rules have no way to tell an authorized operator apart from anyone who just copied those two lines into an HTTP client. The real security perimeter of a Firebase app is its Rules, not the login form the browser shows.

The fix: a serverless login function + Custom Tokens with role claims

The architecture moves identity verification server-side, where the Firebase service account is never exposed to the client:

  • The operator submits email + password to a Netlify Function
  • The function verifies credentials against the operators node (PBKDF2 via Web Crypto API) using the service account - never in the client bundle
  • If valid, it computes the role and signs a Custom Token with the operator's real identity as a claim
  • The client calls signInWithCustomToken() instead of the old hardcoded login
  • Database Rules check auth.token.operatore / auth.token.admin instead of a single fixed uid
// Netlify Function - server-side only, never shipped to the client
export default async function handler(req) {
  const { email, password } = await req.json();

  // 1. Server-side rate limit: 5 attempts, then 60s lockout per email
  const attempts = await getAttempts(email);
  if (attempts.locked) return json({ ok: false, error: 'locked' }, 423);

  // 2. Verify against the operators node (service account, never client-side)
  const op = await findOperatorByEmail(email);
  const valid = op && await verifyPassword(password, op.passwordHash);
  if (!valid) {
    await registerFailedAttempt(email);
    return json({ ok: false }, 401);
  }

  // 3. Role claims computed server-side, unforgeable by the client
  const claims = { operatore: op.chiave, admin: isAdmin(op) };
  const token = await mintCustomToken(`op_${op.chiave}`, claims);
  return json({ ok: true, token, operatore: op.chiave, admin: claims.admin });
}

The key point: admin and operatore are decided exclusively server-side and signed into a JWT with the service account's private key. The client can't "become admin" by flipping an in-memory variable - the claim is checked by the database Rules on every single read/write, not just when the session opens.

Rolling it out in 5 phases

Swapping the Firebase identity of an app used daily by a whole team isn't a one-shot change:

  1. Isolated function - deployed but not called from the client yet. Zero risk, testable directly.
  2. Client with fallback - login tries the new function first, falls back to the old method if it doesn't respond.
  3. Verification on real accounts - admin and non-admin operators from different departments, console open hunting for permission errors.
  4. Removing the hardcoded credentials - auto-login gone, the app waits for onAuthStateChanged() instead, with session persistence synced to the "Remember me" checkbox.
  5. Deny-by-default Rules - only once the Service Worker has propagated the new client to the whole team.

Snag #1: Rules need touching mid-migration, not at the end

The original plan left the Rules untouched until the last phase. As soon as the client started authenticating with the Custom Token in phase 2, permission_denied errors appeared on realtime chat listeners still open from before login.

Reason: changing Firebase identity at runtime immediately invalidates the permissions of anything already open under the old identity. The old Rules only authorized the shared uid - the new, perfectly valid Custom Token meant nothing to them.

Fix: an additive OR, safe to publish mid-workday because Firebase Rules aren't sessions - every read/write is re-evaluated in real time.

{
  ".read": "auth != null && (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)",
  ".write": "auth != null && (auth.uid === '[SHARED_UID]' || auth.token.operatore != null)"
}

Snag #2: deny-by-default broke the whole app for non-admins

Once the team was fully on the new client, the Rules moved to a genuinely restrictive model: root false by default, granular per-role permissions per node. First test with a non-admin account: the app opened completely empty. No visible console errors.

The cause: one node had been classified "admin read-only," but it was also one of the nodes the app reads in full before showing any screen at all, regardless of role. Restricting it left every non-admin operator hanging on a read that would never resolve - no visible error, because that specific listener had no explicit error callback.

A second node, holding operator profile data, exposed a structural limit of Firebase Rules: they don't partially filter a node. If the client reads it all at once, permission is granted or denied for the entire content - you can't say "department and email yes, password hash no" within the same node without first splitting the data. Not something to improvise mid-debug, so that node was temporarily left open to all operators (no regression - it was already fully exposed under the old shared identity), with the split planned as follow-up work.

Before restricting a node to a specific role, always check whether it's part of the app's bootstrap path for every user - not just the role that's "supposed" to see it.

Cleaning up: delete the account, don't just rotate the password

One loose end: the original shared Firebase account. Rotating its password isn't enough - nothing uses it anymore, and Firestore's rules (separate from Realtime Database) only checked request.auth != null, without checking which user. Anyone who still had the old credential could still authenticate and touch Firestore data.

Cleanest fix: delete the account from Firebase Authentication entirely. An account nothing depends on doesn't need a stronger password - it needs to not exist.

What I'd tell past me

  • A UI-level login isn't security if the database Rules don't mirror it - worth verifying explicitly even when the login "looks" correct.
  • Changing Firebase identity at runtime invalidates permissions on everything already open - update Rules additively as you go, not at the end.
  • Firebase Rules can't partially filter a node - split sensitive data out before writing granular permissions.
  • Before restricting a node, check if it's on the app's bootstrap path for everyone.
  • A real rate limit lives server-side - a client-only counter resets on page reload.
  • An unused account should be deleted, not renewed - if a second system doesn't check the same claim, an old credential is still an open door.

Full bilingual write-up (with the RTDB Rules diffs, the security-alert Telegram integration, and the complete phase-by-phase checklist) on my blog.

Comments

No comments yet. Start the discussion.