The context

PanelControl is an internal panel for order, shift, leave, chat and admin management, used daily by multiple operators with different roles (regular, admin). The backend is Firebase Realtime Database + Firestore, no framework, with operator authentication handled by the app's own login — email and password checked client-side against a database node, with lockout after failed attempts.

The initial request was narrow: add Telegram notifications for suspicious events — too many failed login attempts, a never-before-seen device for an operator, an unauthorized attempt to reach the Admin section, DevTools opened while using the app. An alerting task, not an architecture one.

The first bug: the "Log in" button that always unlocks

Before writing the alerting logic, a review of the existing login code surfaced a real problem in the function that checks the Admin area password:

checkAdminPwd() — prima del fix
// ❌ Il valore booleano "|| true" rende l'intera condizione sempre vera
function checkAdminPwd() {
  if (el.value || true) {
    unlockAdmin(); // eseguito sempre, password digitata o no
  }
}

A debug leftover that made it into production: anyone landing on the Admin password overlay got in by clicking "Log in", regardless of what (or nothing) was typed in the field. Fixed alongside the rest by actually calling the real operator-permission check, with an added layer of defense even if the function were invoked directly from the console.

The serious discovery: a shared, hardcoded Firebase credential

While reviewing the Realtime Database Rules ahead of the alerting work, something far more serious than the Admin bug surfaced. The Rules restricted read and write to a single fixed auth.uid — reasonable in the abstract, until you look at who actually gets that uid. In the app's bootstrap, before the login screen even appeared, this code ran unconditionally for anyone who opened the site:

initFirebase() — prima del fix
// ❌ Email e password Firebase in chiaro, eseguite per OGNI visitatore
var _fbEmail = ['[account-condiviso]', '[dominio]'].join('@');
var _fbPass  = '[PASSWORD_REDATTA]';
firebase.auth().signInWithEmailAndPassword(_fbEmail, _fbPass);

The practical consequence: the app's actual login system — email, personal password, permission checks — did not protect Firebase data in any way. It was a UI filter. Anyone visiting the site, even without ever filling in a form, already had a session with full read/write access to the entire database: orders, staff data, internal chat, everything. Just opening the site was enough; you didn't even need to read the source to get the credentials — the app authenticated itself with them automatically.

This wasn't an isolated oversight fixable with a quick patch: credentials served in a public JavaScript bundle can never truly be secret, and with a single Firebase identity shared by every operator, the Rules have no way to distinguish an authorized operator from anyone who simply copied those two lines into any HTTP client.

🔴

Le credenziali in un sorgente client non sono "difficili da trovare": sono pubbliche per definizione. Il vero perimetro di sicurezza di un'app Firebase sono le Regole del database, non l'interfaccia di login mostrata al browser.

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

The correct architecture moves identity verification server-side, where sensitive credentials (the Firebase service account) are never exposed to the client. The flow becomes:

  1. The operator enters email and password as before — but this time they're sent to a Netlify Function
  2. The function verifies the credentials against the operators node (PBKDF2 hash via the Web Crypto API, with support for a legacy format being migrated) using the service account, never exposed to the client
  3. If valid, it computes the role (admin: true/false) and signs a Custom Token containing the operator's real identity as a claim
  4. The client receives the token and calls signInWithCustomToken() instead of the old hardcoded login
  5. Database Rules are rewritten to check auth.token.operatore and auth.token.admin instead of a single fixed uid
operator-login.mjs — verifica e firma del token (semplificato)
// Netlify Function — gira solo lato server, mai nel bundle client
export default async function handler(req) {
  const { email, password } = await req.json();

  // 1. Rate-limit lato server: 5 tentativi, poi lockout 60s per email
  const attempts = await getAttempts(email);
  if (attempts.locked) return json({ ok: false, error: 'locked' }, 423);

  // 2. Verifica contro il nodo operatori (service account, mai nel client)
  const op = await findOperatorByEmail(email);
  const valid = op && await verifyPassword(password, op.passwordHash);
  if (!valid) { await registerFailedAttempt(email); return json({ ok: false }, 401); }

  // 3. Claim di ruolo calcolati server-side, non falsificabili dal 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 is that admin and operatore are decided exclusively by the server, signed inside a JWT with the service account's private key. The client can no longer "become admin" by tweaking an in-memory variable: that claim is checked by the database Rules on every single read or write, not just when the session opens.

The 5-phase rollout, so no one gets locked out

Replacing the Firebase identity of an app used daily by multiple people isn't a one-shot change. The agreed plan had 5 phases, each verifiable and reversible before moving to the next:

  1. Phase 1 — isolated function. operator-login.mjs deployed but not yet called from anywhere in the client: zero risk, testable via direct calls.
  2. Phase 2 — client with fallback. Login tries the new function first; if it doesn't respond, it falls back to the old method — no one is locked out during the transition.
  3. Phase 3 — verification on real accounts. Tests with admin and non-admin operators from different departments, browser console open hunting for permission errors.
  4. Phase 4 — removing hardcoded credentials. The shared-credential auto-login is removed; the app waits for onAuthStateChanged() instead of authenticating itself, with session persistence synced to the "Remember me" flag.
  5. Phase 5 — deny-by-default Rules. Only after the Service Worker has propagated the new client to the whole team do the Rules move from a shared uid to granular claim-based permissions.

The first snag: Rules need touching mid-migration, not at the end

The original plan left the Rules untouched until Phase 5. In Phase 2, as soon as the client started authenticating with the Custom Token on a test browser, permission_denied errors appeared on realtime chat listeners that had been open since before login. The 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 Custom Token, however valid, meant nothing to those Rules.

The fix was an additive OR, published right away, that takes nothing away from anyone still on the old session:

Regole RTDB — transizione additiva
{
  ".read":  "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)",
  ".write": "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)"
}

Firebase Rules don't manage "sessions": every read and write is re-evaluated in real time against the current Rules. Widening the condition with an OR disconnects no one and invalidates nothing — safe to publish even in the middle of the workday, unlike the final tightening.

Phase 4 — Firebase Auth persistence synced with "Remember me"

Removing auto-login without anything else would have forced everyone to log in again on every page reload — unacceptable for an app used all day long. Firebase Auth already has its own native persistence independent of the app's login: once authenticated with signInWithCustomToken, the session survives reloads without calling login again. The tricky part was syncing it with the UI's "Remember me" flag, otherwise anyone who didn't check it would still stay logged in indefinitely:

doLogin() — Fase 4
// La persistenza va impostata PRIMA del sign-in, non dopo
await firebase.auth().setPersistence(
  ricordami ? firebase.auth.Auth.Persistence.LOCAL
            : firebase.auth.Auth.Persistence.SESSION
);
const res = await fetch('/.netlify/functions/operator-login', { /* ... */ });
const { token } = await res.json();
await firebase.auth().signInWithCustomToken(token);
// nessun fallback: se la function non risponde, il login fallisce e basta

On bootstrap, the app no longer auto-logs in but listens to onAuthStateChanged(): if a valid session already exists (native persistence), the UI loads directly; otherwise it shows the login form. And logout now actually calls signOut() — before it only exited the UI, leaving the Firebase session alive in the background, an irrelevant detail with the old shared identity, but no longer with real per-operator identities.

Phase 5 — deny-by-default Rules, and the two bugs that broke the app

With the team now on the new version, the Rules move from the additive model to a genuinely restrictive one: root false by default, with granular per-role permissions on every node:

Regole RTDB — Fase 5, estratto
{
  ".read": false, ".write": false,
  "adminData": {
    ".read":  "auth.token.operatore != null",
    ".write": "auth.token.admin === true"
  },
  "operatoriDevices": {
    "$op": { ".read": "auth.token.operatore === $op || auth.token.admin === true",
             ".write": "auth.token.operatore === $op || auth.token.admin === true" }
  },
  "chat": { "dm": { "$myKey": { ".read": "auth.token.operatore === $myKey" } } }
  // ... resto dei nodi con "auth.token.operatore != null"
}

On the first test run with a non-admin account, the app opened completely empty, with no visible console errors. The cause: adminData had been classified as "admin-only" for reads, but it was also one of the nodes the app reads in full before showing any screen, regardless of the logged-in role. Blocking non-admins from reading it left the whole app hanging on a read that would never complete — with no visible error because that specific listener had no explicit error callback.

A second node, holding operator profile data, revealed a structural limit of Firebase Rules: they don't partially filter a node. If the client reads it all at once (as it did), 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 sensitive data into a separate node. Not being a change to improvise mid-debug, that specific node was temporarily left open to all operators — no regression, since exposure was already total under the old shared identity — with the migration to two separate nodes planned as follow-up work.

⚠️

Prima di restringere un nodo a un ruolo specifico, va sempre verificato se è tra i dati che l'app legge al bootstrap per tutti gli utenti — non solo per chi dovrebbe vederlo secondo la logica applicativa.

Security notifications, back to the original request

With the identity chapter closed, the original request remained: Telegram notifications for suspicious events. Implemented with a client helper calling a dedicated Netlify Function, with per-type-and-operator throttling on RTDB to avoid spam, and a queryable history:

EventoTriggerNote
Brute forcelockout dopo 5 tentativi fallitirate-limit reale lato server, non solo lato client
Nuovo dispositivodeviceId in localStorage assente in RTDBregistrato al primo login riuscito da quel device
Admin non autorizzatotentativo di accesso alla sezione Admin senza permessorespinto subito, niente più box password "bucato"
DevTools apertieuristica su differenza tra outerWidth/innerWidthsolo per utenti non privilegiati, una notifica a sessione

As a useful side effect, the same notification module was reused to report failures of existing scheduled automations (periodic syncs, order-status webhooks) on Telegram — a single change to a shared module, instead of touching six different files one by one.

Final cleanup: deleting the account instead of just changing the password

With the migration complete, the original shared Firebase account remained. Just changing its password isn't enough: no code path uses it anymore, and the Firestore rules (separate from the Realtime Database ones) only checked request.auth != null, without checking which user — anyone who still had that old credential could still authenticate and read/write Firestore data. The cleanest solution was deleting the account from Firebase Authentication entirely: an account that serves no purpose anymore doesn't need a stronger password, it needs to not exist.

When a migration like this is needed (and when it's not)

When to do it

  • Hardcoded shared credentials discovered in an existing app: once this pattern is identified, migrating to Custom Tokens with individual roles isn't optional, it's corrective.
  • App growing from prototype to a tool with more users: permissive security rules that were fine for a single developer become a real risk with more people and more roles.
  • Granular per-role authorization is needed (admin vs standard user), not achievable with a single shared credential or generic RTDB rules.

When it's not the priority

  • App with a single user/owner with no planned team growth: the migration cost isn't justified until a second person joins.
  • Rules already deny-by-default with individual authentication: if the foundation is already solid, there's no need to redo everything.

Alternatives

For a simpler case of passing identity between two apps without needing granular roles or RTDB/Firestore rules integration, a client-signed HMAC token is a lighter alternative, suited to lower-risk contexts.

Common mistakes

  • Touching security rules only at the end of the migration: RTDB Rules need updating mid-way, otherwise there's a risk of a window where old and new authentication systems aren't both correctly supported.
  • Migrating all users at once: a phased rollout (like the 5 phases described in this article) avoids locking anyone out during the transition.
  • Moving from permissive to deny-by-default rules without testing every read/write path: overly restrictive rules silently break existing functionality, often discovered only by users in production.
  • Just changing the password instead of deleting a compromised account: if a credential was shared or exposed, the correct cleanup is deleting the account, not just rotating the password.

What we learned

  1. A UI-level login isn't security if the database Rules don't mirror it. The real perimeter is the Rules, not the form the browser shows. Worth verifying explicitly even when login "looks" like it's working correctly.
  2. Changing Firebase identity at runtime immediately invalidates permissions on everything already open. Rules need an additive update as soon as the client starts using the new identity, not postponed to the end of the migration.
  3. Firebase Rules don't partially filter a node. Sensitive and non-sensitive data mixed in the same node must be split before granular permissions can be written — not something to improvise mid-debug.
  4. Before restricting a node, check whether it's in the app's bootstrap path. A node read at boot for every user, if mistakenly restricted to one role, freezes the whole interface for everyone else — often with no visible error, if the listener has no explicit error callback.
  5. A real rate limit lives server-side. A client-only counter can be reset by reloading the page or calling the login function from the console: the real block must live in a store the client can't write to directly.
  6. An unused account should be deleted, not just renewed. If a second system's rules (Firestore, in this case) don't check the same claim, an old valid credential remains an open door for as long as the account exists.

Frequently asked questions

Why isn't changing the shared Firebase account's password enough?

Because the problem isn't which password is used, but the fact that there's one, hardcoded in the client source and shared by everyone. Code served to the browser can never truly be secret: the new password would still be public in the new bundle. Also, with a single shared Firebase identity, the database Rules can't distinguish one operator from another. The fix requires a real identity per operator, not just a different password.

Why do database Rules need updating mid-migration, not just at the end?

Because changing Firebase identity at runtime immediately invalidates the permissions of everything the client had already opened under the old identity. As soon as the client starts authenticating with the new Custom Token, every active realtime listener is re-checked against the existing Rules: if they only authorize the old shared identity, everything stops working well before the migration is finished.

Can Firebase security rules protect only part of a node?

No. Realtime Database Rules apply to the entire node: if the client reads it with a single call, permission is granted or denied for all its content, not key by key. Sensitive data mixed with non-sensitive data in the same node must be split before the sensitive part can be restricted.

Why did one mistakenly restricted node make the whole app unusable instead of breaking just one section?

Because that node was on the list of "critical" data the app reads in full before showing any screen, regardless of role. Restricting its read access to admins only left every non-admin operator stuck on a read that would never complete, with no visible error because the listener had no explicit error callback.

Is a server-side rate limit really needed if there's already a client-side lockout after failed attempts?

Yes. A client-only lockout can be reset by reloading the page or calling the login function directly from the browser console. A real rate limit must live server-side, with its state stored in a node the client can't write to directly.