Il sintomo da riconoscere
Un pannello interno "funziona" se chiunque apra il sito ottiene già, in automatico, un'identità Firebase con accesso in lettura/scrittura — spesso perché al bootstrap dell'app gira un login incondizionato verso un unico uid condiviso, e le Regole del database restringono i permessi a quel solo uid. Il login sull'interfaccia è, in quel caso, solo un filtro visivo: non protegge nulla lato dati.
The symptom to recognize
An internal panel "works" if anyone who opens the site already gets, automatically, a Firebase identity with read/write access — often because the app's bootstrap runs an unconditional login into a single shared uid, and the database Rules restrict permissions to that one uid. The login screen, in that case, is just a UI filter: it protects nothing on the data side.
Come verificarlo in 30 secondi: apri il sito in una finestra anonima, salta la schermata di login (chiudila, torna indietro, o ispeziona il bootstrap), e controlla in console se esiste già una sessione Firebase autenticata prima di aver inserito qualunque credenziale.
La soluzione in tre pezzi
- Una Netlify Function di login verifica email/password server-side (mai nel client) e calcola i claims di ruolo
- Se valide, firma un Custom Token con l'identità reale dell'operatore e i claims dentro
- Il client chiama
signInWithCustomToken() invece di un login hardcoded, e le Regole del database controllano auth.token.* invece di un uid fisso
The solution in three pieces
- A Netlify login function verifies email/password server-side (never in the client) and computes role claims
- If valid, it signs a Custom Token carrying the operator's real identity and claims
- The client calls
signInWithCustomToken() instead of a hardcoded login, and database Rules check auth.token.* instead of a fixed uid
// 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 });
}
Punto chiave: admin e operatore sono decisi esclusivamente dal server e firmati dentro il token con la chiave privata del service account. Il client non può "diventare admin" cambiando una variabile in memoria — il claim è verificato dalle Regole a ogni singola lettura o scrittura, non solo all'apertura della sessione.
// 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 Firebase), l'interfaccia carica direttamente; altrimenti mostra il form. E il logout deve chiamare davvero signOut() — non solo uscire dall'interfaccia, lasciando la sessione Firebase viva in background.
On bootstrap the app no longer auto-logs in, but listens to onAuthStateChanged(): if a valid session already exists (native Firebase persistence), the UI loads directly; otherwise it shows the form. And logout must actually call signOut() — not just exit the UI, leaving the Firebase session alive in the background.
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. Ogni fase è verificabile e reversibile prima di passare alla successiva:
- Fase 1 — function isolata. La login function è deployata ma non ancora richiamata da nessuna parte del client: zero rischio, testabile con una 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 utenti admin e non-admin di reparti diversi, console aperta a caccia di errori di permesso.
- Fase 4 — rimozione del vecchio login. Tolto l'auto-login incondizionato; l'app aspetta
onAuthStateChanged(), persistenza sincronizzata con "Ricordami".
- Fase 5 — Regole deny-by-default. Solo dopo che il nuovo client ha raggiunto tutto il team, le Regole passano 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. Each phase is verifiable and reversible before moving to the next:
- Phase 1 — isolated function. The login function is deployed but not yet called from anywhere in the client: zero risk, testable with a direct call.
- 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 users from different departments, console open hunting for permission errors.
- Phase 4 — removing the old login. The unconditional auto-login is removed; the app waits for
onAuthStateChanged(), persistence synced to "Remember me".
- Phase 5 — deny-by-default Rules. Only after the new client has reached the whole team do the Rules move to granular claim-based permissions.
Lo scoglio da anticipare: le Regole vanno toccate a metà, non alla fine
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 (Fase 2), ogni listener realtime attivo viene ricontrollato contro le Regole esistenti — se autorizzano solo il vecchio uid, tutto smette di funzionare molto prima che la migrazione sia conclusa. La correzione è un OR additivo, pubblicabile anche in pieno orario di lavoro perché non toglie nulla a chi è ancora sulla vecchia sessione:
The snag to anticipate: Rules need touching mid-migration, not at the end
Changing Firebase identity at runtime immediately invalidates the permissions of anything the client had already opened under the old identity. As soon as the client starts authenticating with the new Custom Token (Phase 2), every active realtime listener is re-checked against the existing Rules — if they only authorize the old uid, everything stops working well before the migration finishes. The fix is an additive OR, safe to publish even mid-workday because it 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)"
}
Fase 5 — Regole deny-by-default
Con il team sulla versione nuova, le Regole passano da additive a realmente restrittive: root false di default, permessi granulari per ruolo su ogni nodo.
Phase 5 — deny-by-default Rules
With the team on the new version, the Rules move from additive to genuinely restrictive: root false by default, 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"
}
Due trappole reali della Fase 5: (1) se un nodo classificato "solo admin" in lettura è anche tra i dati che l'app legge al bootstrap per tutti, bloccarlo lascia l'app sospesa su una lettura che non si completa mai — spesso senza errore visibile in console, se quel listener non ha un callback di errore esplicito. (2) le Regole Firebase non filtrano parzialmente un nodo: se il client lo legge tutto insieme, il permesso è concesso o negato per l'intero contenuto — dati sensibili e non vanno separati in nodi distinti prima di restringere l'accesso.
Two real Phase-5 traps: (1) if a node classified "admin-only" for reads is also among the data the app reads at bootstrap for everyone, blocking it leaves the app hanging on a read that never completes — often with no visible console error, if that listener has no explicit error callback. (2) Firebase Rules don't partially filter a node: if the client reads it all at once, permission is granted or denied for the entire content — sensitive and non-sensitive data must be split into separate nodes before restricting access.
Checklist prima di pubblicare Regole deny-by-default
- Ogni nodo letto al bootstrap è accessibile a tutti i ruoli che passano per quel bootstrap, non solo a chi dovrebbe "vederlo" secondo la logica applicativa
- Dati sensibili (hash password, token, PII) vivono in nodi separati da quelli che servono a più ruoli
- La transizione additiva (OR) è stata pubblicata e verificata prima di stringere le Regole finali
- Test manuale con almeno un account admin e uno non-admin, console aperta
- Un piano di rollback pronto: repubblicare la versione additiva se qualcosa si blocca
Checklist before shipping deny-by-default Rules
- Every node read at bootstrap is accessible to all roles that go through that bootstrap, not just whoever should "see" it per app logic
- Sensitive data (password hashes, tokens, PII) lives in nodes separate from ones needed by multiple roles
- The additive (OR) transition was published and verified before tightening the final Rules
- Manual test with at least one admin and one non-admin account, console open
- A ready rollback plan: republish the additive version if something breaks
Domande frequenti
Come faccio a sapere se il mio pannello ha il problema della credenziale condivisa?
Apri il sito in una finestra anonima e controlla in console se esiste già una sessione Firebase autenticata prima di aver inserito qualunque credenziale. Se sì, il login sull'interfaccia è solo un filtro visivo.
Perché le Regole vanno aggiornate a metà migrazione e non solo alla fine?
Cambiare identità Firebase a runtime invalida subito i permessi di ciò che il client aveva già aperto sotto la vecchia identità. Se le Regole autorizzano solo il vecchio uid, tutto smette di funzionare molto prima della fine della migrazione.
Perché un nodo "solo admin" può bloccare l'app anche per gli admin?
Se quel nodo è tra i dati letti al bootstrap per tutti, bloccarne la lettura ai non-admin lascia l'app sospesa su una lettura che non si completa mai — spesso senza errore visibile.
Le Regole Firebase filtrano solo alcuni campi dentro lo stesso nodo?
No. Se il client legge un nodo tutto insieme, il permesso è concesso o negato per l'intero contenuto — dati sensibili vanno separati in nodi distinti prima di restringere l'accesso.
Frequently asked questions
How do I know if my panel has the shared-credential problem?
Open the site in an incognito window and check the console for an already-authenticated Firebase session before entering any credentials. If so, the login screen is just a UI filter.
Why do Rules need updating mid-migration, not just at the end?
Changing Firebase identity at runtime immediately invalidates permissions for anything already open under the old identity. If the Rules only authorize the old uid, everything stops working well before the migration finishes.
Why can an "admin-only" node break the app even for admins?
If that node is among the data read at bootstrap for everyone, blocking non-admin reads leaves the app hanging on a read that never completes — often with no visible error.
Do Firebase Rules filter only some fields within the same node?
No. If the client reads a node all at once, permission is granted or denied for the entire content — sensitive data must be split into separate nodes before restricting access.
Articolo correlato
Come ho scoperto la credenziale condivisa e fatto la migrazione — con i due bug reali della Fase 5
Related article
How I found the shared credential and ran the migration — with the two real Phase 5 bugs
→
Articolo correlato
Quando non serve un Custom Token: token HMAC client-only tra due PWA
Related article
When you don't need a Custom Token: client-only HMAC tokens between two PWAs
→