Il contesto
PanelControl è un pannello interno per la gestione di ordini, turni, ferie, chat e amministrazione, usato ogni giorno da più operatori con ruoli diversi (operativo, admin). Il backend è Firebase Realtime Database + Firestore, senza framework, con l'autenticazione degli operatori gestita da un login proprio dell'app — email e password verificate lato client contro un nodo del database, con un lockout dopo tentativi falliti.
La richiesta di partenza era circoscritta: aggiungere notifiche Telegram per eventi sospetti — troppi tentativi di login falliti, un nuovo dispositivo mai visto per un operatore, un tentativo di accesso alla sezione Admin da chi non ne ha diritto, l'apertura di DevTools durante l'uso dell'app. Un lavoro di alerting, non di architettura.
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.
Il primo bug: il pulsante "Accedi" che sblocca sempre
Prima di scrivere l'alerting, un controllo del codice di login esistente ha fatto emergere un problema concreto nella funzione che verifica la password dell'area Admin:
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:
// ❌ Il valore booleano "|| true" rende l'intera condizione sempre vera
function checkAdminPwd() {
if (el.value || true) {
unlockAdmin(); // eseguito sempre, password digitata o no
}
}
Un residuo di debug rimasto in produzione: chiunque arrivasse sull'overlay password dell'Admin entrava premendo "Accedi", a prescindere da cosa (o niente) fosse scritto nel campo. Corretto insieme al resto richiamando davvero il controllo sui permessi dell'operatore, con difesa aggiuntiva anche se la funzione venisse invocata a mano dalla console.
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.
La scoperta grave: una credenziale Firebase condivisa e hardcoded
Guardando le Regole del Realtime Database in vista dell'alerting, è emerso qualcosa di molto più serio del bug sull'Admin. Le Regole restringevano lettura e scrittura a un singolo auth.uid fisso — ragionevole in astratto, finché non si guarda chi ottiene quell'uid. Nel bootstrap dell'app, prima ancora che comparisse la schermata di login, veniva eseguito questo codice, incondizionatamente, per chiunque aprisse il sito:
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:
// ❌ 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);
La conseguenza pratica: il vero sistema di login dell'app — email, password personale, verifica dei permessi — non proteggeva i dati Firebase in alcun modo. Era un filtro sull'interfaccia. Chiunque visitasse il sito, anche senza mai compilare un form, otteneva già una sessione con accesso completo in lettura/scrittura all'intero database: ordini, dati del personale, chat interne, tutto. Bastava aprire il sito, non serviva nemmeno leggere il sorgente per ottenere le credenziali — l'app se le autenticava da sola.
Il problema non era una svista isolata da correggere con una patch: le credenziali servite in un bundle JavaScript pubblico non possono mai essere davvero segrete, e con un'unica identità Firebase condivisa da tutti gli operatori le Regole non hanno modo di distinguere un operatore autorizzato da chiunque altro abbia semplicemente copiato quelle due righe in un client HTTP qualsiasi.
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.
La soluzione: Netlify Function di login + Custom Token con claim per ruolo
L'architettura corretta sposta la verifica dell'identità lato server, dove le credenziali sensibili (l'account di servizio Firebase) non sono mai esposte al client. Il flusso diventa:
- L'operatore inserisce email e password come sempre — ma questa volta vengono inviate a una Netlify Function
- La function verifica le credenziali contro il nodo operatori (hash PBKDF2 via Web Crypto API, con supporto a un formato legacy da migrare) usando il service account, mai esposto al client
- Se valide, calcola il ruolo (
admin: true/false) e firma un Custom Token con dentro l'identità reale dell'operatore come claim - Il client riceve il token e chiama
signInWithCustomToken()invece del vecchio login hardcoded - Le Regole del database vengono riscritte per leggere
auth.token.operatoreeauth.token.admininvece di un singolouidfisso
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:
- The operator enters email and password as before — but this time they're sent to a Netlify Function
- 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
- If valid, it computes the role (
admin: true/false) and signs a Custom Token containing the operator's real identity as a claim - The client receives the token and calls
signInWithCustomToken()instead of the old hardcoded login - Database Rules are rewritten to check
auth.token.operatoreandauth.token.admininstead of a single fixeduid
// 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 });
}
Il punto chiave è che admin e operatore vengono decisi esclusivamente dal server, firmati dentro un JWT con la chiave privata del service account. Il client non può più "diventare admin" modificando una variabile in memoria: quel claim è verificato dalle Regole del database a ogni singola lettura o scrittura, non solo all'apertura della sessione.
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.
Il rollout in 5 fasi, per non bloccare fuori nessuno
Sostituire l'identità Firebase di un'app usata ogni giorno da più persone non è un cambio da fare in un colpo solo. Il piano concordato prevedeva 5 fasi, ciascuna verificabile e reversibile prima di passare alla successiva:
- Fase 1 — function isolata.
operator-login.mjsdeployata ma non ancora richiamata da nessuna parte del client: zero rischio, testabile via chiamata diretta. - Fase 2 — client con fallback. Il login prova prima la nuova function; se non risponde, ricade sul vecchio metodo — nessuno resta fuori durante la transizione.
- Fase 3 — verifica su account reali. Test con operatori admin e non-admin di reparti diversi, console del browser aperta a caccia di errori di permesso.
- Fase 4 — rimozione delle credenziali hardcoded. Tolto l'auto-login con la credenziale condivisa; l'app aspetta
onAuthStateChanged()invece di autenticarsi da sola, con la persistenza della sessione sincronizzata con il flag "Ricordami". - Fase 5 — Regole deny-by-default. Solo dopo che il Service Worker ha propagato il nuovo client a tutto il team, le Regole passano da un
uidcondiviso a permessi granulari per claim.
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:
- Phase 1 — isolated function.
operator-login.mjsdeployed but not yet called from anywhere in the client: zero risk, testable via direct calls. - 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.
- Phase 3 — verification on real accounts. Tests with admin and non-admin operators from different departments, browser console open hunting for permission errors.
- 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. - 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
uidto granular claim-based permissions.
Il primo scoglio: le Regole vanno toccate a metà migrazione, non alla fine
Il piano originale prevedeva di lasciare le Regole invariate fino alla Fase 5. Alla Fase 2, non appena il client ha iniziato a autenticarsi con il Custom Token su un browser di test, sono comparsi errori permission_denied sui listener realtime della chat, ancora attivi da prima del login. Il motivo: cambiare identità Firebase a runtime invalida immediatamente i permessi di ciò che è già aperto sotto la vecchia identità. Le vecchie Regole autorizzavano solo l'uid condiviso — il nuovo Custom Token, per quanto valido, non c'entrava nulla per quelle Regole.
La correzione è stata un OR additivo, pubblicato subito, che non toglie nulla a chi è ancora sulla vecchia sessione:
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:
{
".read": "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)",
".write": "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)"
}
Le Regole Firebase non gestiscono "sessioni": ogni lettura e scrittura viene rivalutata in tempo reale contro le Regole correnti. Allargare la condizione con un OR non disconnette nessuno e non invalida nulla — è sicuro da pubblicare anche in pieno orario di lavoro, a differenza della stretta finale.
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.
Fase 4 — persistenza Firebase Auth sincronizzata con "Ricordami"
Togliere l'auto-login senza altro avrebbe costretto tutti a rifare login a ogni ricarica di pagina — inaccettabile per un'app usata tutto il giorno. Firebase Auth ha già una sua persistenza nativa indipendente dal login applicativo: una volta autenticati con signInWithCustomToken, la sessione sopravvive ai ricaricamenti senza bisogno di richiamare il login. Il punto delicato era sincronizzarla con il flag "Ricordami" dell'interfaccia, altrimenti chi non lo spuntava sarebbe comunque rimasto loggato a tempo indeterminato:
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:
// 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
Al bootstrap, l'app non fa più login automatico ma ascolta onAuthStateChanged(): se esiste già una sessione valida (persistenza nativa), l'interfaccia carica direttamente; altrimenti mostra il form di login. E il logout ora chiama davvero signOut() — prima usciva solo dall'interfaccia, lasciando la sessione Firebase attiva in background, un dettaglio irrilevante con la vecchia identità condivisa, ma non più con identità reali per operatore.
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.
Fase 5 — Regole deny-by-default, e i due bug che hanno bloccato l'app
Con il team ormai sulla versione nuova, le Regole passano dal modello additivo a uno realmente restrittivo: root false di default, con permessi granulari per ruolo su ogni nodo:
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:
{
".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"
}
Al primo giro di test con un account non-admin, l'app si è aperta completamente vuota, senza errori vistosi in console. La causa: adminData era stato classificato come "solo admin" in lettura, ma era anche uno dei nodi che l'app legge per intero prima di mostrare qualsiasi schermata, a prescindere dal ruolo di chi è loggato. Bloccandone la lettura ai non-admin, l'intera app restava sospesa in attesa di una lettura che non si sarebbe mai completata — senza errore visibile perché quel listener specifico non aveva un callback di errore esplicito.
Un secondo nodo, con dati anagrafici degli operatori, ha rivelato un limite strutturale delle Regole Firebase: non filtrano parzialmente un nodo. Se il client lo legge tutto insieme (come faceva), il permesso è concesso o negato per l'intero contenuto — non è possibile dire "reparto ed email sì, hash della password no" all'interno dello stesso nodo senza prima separare i dati sensibili in un nodo a parte. Non essendo una modifica da fare a caldo durante un debug, quel nodo specifico è stato temporaneamente riportato aperto a tutti gli operatori — nessuna regressione, visto che con la vecchia identità condivisa l'esposizione era comunque totale — con la migrazione a due nodi separati pianificata come lavoro successivo.
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.
Le notifiche di sicurezza, tornando alla richiesta originale
Chiuso il capitolo identità, restava la richiesta iniziale: notifiche Telegram per eventi sospetti. Implementate con un helper client che chiama una Netlify Function dedicata, con throttle per tipo+operatore su RTDB per evitare spam, e uno storico consultabile:
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:
| Evento | Trigger | Note |
|---|---|---|
| Brute force | lockout dopo 5 tentativi falliti | rate-limit reale lato server, non solo lato client |
| Nuovo dispositivo | deviceId in localStorage assente in RTDB | registrato al primo login riuscito da quel device |
| Admin non autorizzato | tentativo di accesso alla sezione Admin senza permesso | respinto subito, niente più box password "bucato" |
| DevTools aperti | euristica su differenza tra outerWidth/innerWidth | solo per utenti non privilegiati, una notifica a sessione |
Come effetto collaterale utile, lo stesso modulo di notifica è stato riusato per segnalare su Telegram i fallimenti delle automazioni schedulate esistenti (sincronizzazioni periodiche, webhook di stato ordini) — un'unica modifica a un modulo condiviso, invece di toccare sei file diversi uno per uno.
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.
Pulizia finale: eliminare l'account invece di solo cambiare password
Con la migrazione conclusa, restava l'account Firebase condiviso originale. Cambiargli solo la password non basta: nessun punto del codice lo usa più, e le Regole di Firestore (separate da quelle del Realtime Database) verificavano solo request.auth != null, senza controllare quale utente — chi avesse ancora quella vecchia credenziale avrebbe comunque potuto autenticarsi e leggere/scrivere dati su Firestore. La soluzione più pulita è stata eliminare del tutto l'account da Firebase Authentication: un account che non serve più a nulla non ha bisogno di una password più sicura, ha bisogno di non esistere.
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.
Cosa abbiamo imparato
- Un login "in interfaccia" non è sicurezza se le Regole del database non lo rispecchiano. Il vero perimetro sono le Regole, non il form che il browser mostra. Vale la pena verificarlo esplicitamente anche quando il login "sembra" funzionare correttamente.
- Cambiare identità Firebase a runtime invalida subito i permessi su tutto ciò che è già aperto. Le Regole vanno aggiornate in modo additivo appena il client comincia a usare la nuova identità, non rimandate alla fine della migrazione.
- Le Regole Firebase non filtrano parzialmente un nodo. Dati sensibili e non sensibili mescolati nello stesso nodo vanno separati prima di poter scrivere permessi granulari — non è un'operazione da improvvisare durante un debug in corso.
- Prima di restringere un nodo, verificare se è nel percorso di bootstrap dell'app. Un nodo letto al boot per tutti gli utenti, se ristretto per errore a un solo ruolo, blocca l'intera interfaccia per tutti gli altri — spesso senza un errore visibile, se il listener non ha un callback esplicito.
- Un rate-limit vero vive lato server. Un contatore solo client-side è azzerabile ricaricando la pagina o richiamando la funzione di login dalla console: il vero blocco deve stare in uno store che il client non può scrivere direttamente.
- Un account non più usato va eliminato, non solo rinnovato. Se le Regole di un secondo sistema (in questo caso Firestore) non controllano lo stesso claim, una vecchia credenziale valida resta una porta aperta finché l'account esiste.
What we learned
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Domande frequenti
Perché non basta cambiare la password dell'account Firebase condiviso?
Perché il problema non è quale password venga usata, ma il fatto che sia una sola, hardcoded nel sorgente client e condivisa da tutti. Il codice servito al browser non può mai essere davvero segreto: la nuova password sarebbe comunque pubblica nel nuovo bundle. Inoltre, con un'unica identità Firebase condivisa, le Regole del database non possono distinguere un operatore dall'altro. La soluzione richiede un'identità reale per ciascun operatore, non solo una password diversa.
Perché le Regole del database vanno aggiornate a metà migrazione e non solo alla fine?
Perché cambiare identità Firebase a runtime invalida immediatamente i permessi di tutto ciò che il client aveva già aperto sotto la vecchia identità. Appena il client comincia ad autenticarsi con il nuovo Custom Token, ogni listener realtime attivo viene ricontrollato contro le Regole esistenti: se autorizzano solo la vecchia identità condivisa, tutto smette di funzionare molto prima che la migrazione sia conclusa.
Le Regole di sicurezza Firebase possono proteggere solo una parte di un nodo?
No. Le Regole Realtime Database si applicano al nodo intero: se il client lo legge con un'unica chiamata, il permesso è concesso o negato per tutto il contenuto, non chiave per chiave. Dati sensibili mescolati a dati non sensibili nello stesso nodo vanno separati prima di poter restringere solo la parte sensibile.
Perché un nodo bloccato per errore ha reso l'intera app inutilizzabile invece di rompere solo una sezione?
Perché quel nodo era nell'elenco dei dati "critici" che l'app legge per intero prima di mostrare qualsiasi schermata, a prescindere dal ruolo. Bloccandone la lettura solo agli admin, ogni operatore non-admin restava bloccato su una lettura che non si sarebbe mai completata, senza errore visibile perché il listener non aveva un callback di errore esplicito.
Serve davvero un rate-limit lato server se esiste già un blocco dopo tentativi falliti nel client?
Sì. Un lockout solo client-side può essere azzerato ricaricando la pagina o richiamando la funzione di login direttamente dalla console del browser. Un rate-limit reale deve vivere lato server, con lo stato salvato in un nodo che il client non può scrivere direttamente.
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.