โ† blog
Firebase Custom Token Realtime Database Sicurezza Netlify Functions

Firebase Custom Token Auth: the complete guide

How to replace a shared Firebase credential (or no server-side check at all) with a per-user Custom Token, role claims decided only by the server, and deny-by-default Realtime Database Rules โ€” a phased rollout that never locks the team out mid-shift.

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.

The solution in three pieces

  1. A Netlify login function verifies email/password server-side (never in the client) and computes role claims
  2. If valid, it signs a Custom Token carrying the operator's real identity and claims
  3. The client calls signInWithCustomToken() instead of a hardcoded login, and database Rules check auth.token.* instead of a fixed uid
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

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.

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:

  1. 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.
  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 users from different departments, console open hunting for permission errors.
  4. Phase 4 โ€” removing the old login. The unconditional auto-login is removed; the app waits for onAuthStateChanged(), persistence synced to "Remember me".
  5. 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.

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:

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

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.

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

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 before shipping deny-by-default Rules

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.

Related article
How I found the shared credential and ran the migration โ€” with the two real Phase 5 bugs
โ†’
Related article
When you don't need a Custom Token: client-only HMAC tokens between two PWAs
โ†’

When to migrate to Firebase Custom Token

This guide explains how to replace a shared Firebase credential with Custom Tokens, role-based claims, and deny-by-default RTDB Rules, with a 5-phase rollout designed to avoid downtime.

When to use it

When NOT to use it

Alternatives

Common mistakes