← blog
Firebase Custom Token Realtime Database Sicurezza Netlify Functions

Firebase Custom Token Auth: guida completa

Come sostituire una credenziale Firebase condivisa (o l'assenza totale di controllo server-side) con un Custom Token per utente, claims di ruolo decisi solo dal server, e Regole del Realtime Database deny-by-default — rollout a fasi, senza mai bloccare fuori il team a metà giornata.

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.

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

  1. Una Netlify Function di login verifica email/password server-side (mai nel client) e calcola i claims di ruolo
  2. Se valide, firma un Custom Token con l'identità reale dell'operatore e i claims dentro
  3. Il client chiama signInWithCustomToken() invece di un login hardcoded, e le Regole del database controllano auth.token.* invece di un uid fisso
operator-login.mjs — Netlify Function (semplificato)
// 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.

doLogin() — client, con persistenza sincronizzata
// 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.

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:

  1. Fase 1 — function isolata. La login function è deployata ma non ancora richiamata da nessuna parte del client: zero rischio, testabile con una chiamata diretta.
  2. 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.
  3. Fase 3 — verifica su account reali. Test con utenti admin e non-admin di reparti diversi, console aperta a caccia di errori di permesso.
  4. Fase 4 — rimozione del vecchio login. Tolto l'auto-login incondizionato; l'app aspetta onAuthStateChanged(), persistenza sincronizzata con "Ricordami".
  5. 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.

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:

Regole RTDB — transizione additiva (Fase 2)
{
  ".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.

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"
}

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.

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

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.

Articolo correlato
Come ho scoperto la credenziale condivisa e fatto la migrazione — con i due bug reali della Fase 5
Articolo correlato
Quando non serve un Custom Token: token HMAC client-only tra due PWA