The context

A POS terminal reseller needed a showcase site with an integrated e-commerce. The requirements were clear: no payment gateway to integrate (checkout with ready-made payment links or bank transfer details), a catalog of around seven products with selectable pricing variants, and the ability for their sales team to place orders directly from the site — with the salesperson's name automatically saved on each order, read from their identity already authenticated in the internal management panel.

The decision to skip off-the-shelf e-commerce platforms was deliberate: something fully custom was needed, integrable with the Firebase ecosystem already in use for the management panel, and controllable in every detail. Resulting stack: vanilla HTML/CSS/JS, Firebase Realtime Database for catalog and orders, Firebase Storage for images and PDFs, Netlify for hosting and serverless functions.

Architecture: two separate Firebase projects

The first key decision was keeping the e-commerce database completely separate from the internal management panel's database. With around forty orders per day (about fifteen thousand a year), merging them into the same Firebase project would have created risk of contamination between commercial data and administrative data (salaries, budgets, employee records).

The e-commerce Realtime Database structure:

Firebase RTDB — struttura
{
  "products": {          // lettura pubblica, scrittura solo admin auth
    "{id}": {
      "name": "[Prodotto A]",
      "price": 0,
      "image": "https://...",
      "activeTariffs": ["tariff_id_1", "tariff_id_2"]
    }
  },
  "tariffs": {           // archivio globale tariffe, lettura pubblica
    "{id}": {
      "name": "[Piano A]", "code": "[CODICE-TARIFFA]",
      "canone": "€0/mese", "commissioni": "1,00%",
      "pdfUrl": "https://firebasestorage..."
    }
  },
  "orders": {            // lettura/scrittura solo utenti autenticati
    "{pushKey}": {
      "number": 1, "vendor": "[Venditore]",
      "customer": { "name": "...", "email": "..." },
      "items": [{ "product": "[Prodotto A]", "tariff": "[Piano A]" }]
    }
  },
  "counters": {          // non leggibile dal client, solo transazioni
    "lastOrderNumber": 42
  }
}

Pricing variants: global archive + per-product assignment

The most interesting data modeling challenge was handling pricing plans. Each POS terminal can be paired with multiple pricing tiers ([Piano A], [Piano B], [Piano C], etc.), but the same plans repeat across different products. Embedding tariffs inside each product would mean duplicating data and updating multiple nodes every time a plan changes.

The solution was a global archive at /tariffs with all plans defined once, and per product an activeTariffs array holding the IDs of applicable plans. The admin can check or uncheck plans per product with a checkbox, without touching the plan data itself. Products without plans (e.g. a printer) use the noTariffs: true flag instead.

On the frontend, when the user selects a plan from the product sheet, a detail box updates in real time showing all information: commissions, monthly fee, linked account and card, economic conditions, included services, and — highlighted in monospace — the [Provider] plan code to communicate to the customer.

Order numbering: Firebase atomic transaction

Progressive order numbering sounds trivial, but with multiple vendors placing orders concurrently it becomes a classic race condition problem. If two vendors press "Confirm" at the same instant, both read lastOrderNumber = 41 and write 42 — one order vanishes.

Firebase Realtime Database solves this with atomic transactions: the runTransaction() function attempts the update, and if the value changed on the server in the meantime, it automatically retries until it succeeds with the correct value.

JavaScript — transazione atomica ordine
// Incremento atomico del contatore — zero duplicati anche con più venditori
async function submitOrder(orderData) {
  const counterRef = ref(db, 'counters/lastOrderNumber');

  const { snapshot } = await runTransaction(counterRef, currentValue => {
    // currentValue è null al primo ordine
    return (currentValue || 0) + 1;
  });

  const orderNumber = snapshot.val(); // garantito unico

  const ordersRef = ref(db, 'orders');
  await push(ordersRef, {
    number:   orderNumber,
    vendor:   orderData.vendorName || 'Anonimo',
    customer: orderData.customer,
    items:    orderData.items,
    method:   orderData.paymentMethod,
    ts:       Date.now()
  });
}
💡

Il nodo /counters nelle regole Firebase ha ".read": false — non è leggibile dal client. Solo la transazione scrive su quel nodo, il numero arriva nella risposta della transazione stessa.

Admin panel: PBKDF2 with no password in the source

The admin panel lets you manage products (name, price, images, assigned plans) and the global pricing archive ([Provider] codes, fees, conditions, T&C PDFs). It must be accessible only to those who know the password, but without a dedicated backend.

Storing the password in plain text in the source code is obviously out of the question — anyone reading the HTML file sees it. But even hashing isn't enough with plain MD5 or SHA-256: they're fast to brute-force. The solution is PBKDF2 (Password-Based Key Derivation Function 2), which applies the hash function thousands of times making it computationally expensive to attack.

The browser's Web Crypto API exposes PBKDF2 natively. At first setup, the hash is generated outside the site (or with a small Node script) and only the hash and salt are put in the code — never the original password.

JavaScript — verifica password con Web Crypto PBKDF2
// Nel codice: solo salt e hash. La password non c'è mai.
const STORED_SALT = "a7f3..."; // generato casualmente al setup
const STORED_HASH = "e9b2..."; // PBKDF2 della password reale

async function verifyPassword(input) {
  const enc = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw', enc.encode(input), 'PBKDF2', false, ['deriveBits']
  );
  const derived = await crypto.subtle.deriveBits({
    name: 'PBKDF2',
    salt: hexToBuffer(STORED_SALT),
    iterations: 310000,          // costoso per il brute-force
    hash: 'SHA-256'
  }, keyMaterial, 256);

  return bufferToHex(derived) === STORED_HASH;
}

After a successful verification, the admin performs an anonymous Firebase sign-in in the background: this allows the database Security Rules to recognize the user as authenticated (auth != null) and authorize writes on products and plans. The catalog remains publicly readable, orders are protected.

The zero-value trap in JavaScript

During development I encountered a subtle bug worth documenting: a terminal's price was zero (included in the monthly fee), but the site kept showing "price to be defined" even after saving it correctly.

The problem was JavaScript's falsy evaluation. In two places in the code I was using patterns like:

JavaScript — il bug
// ❌ SBAGLIATO: 0 è falsy, viene trattato come assente
const price = p.price || null;      // 0 → null → Firebase cancella il campo
const display = p.price ? ... : 'prezzo da definire'; // 0 → ramo else

// Nel template HTML dell'admin:
`value="${p.price || ''}"`  // 0 || '' → '', campo appare vuoto al reload

// ✅ CORRETTO: != null è true per qualsiasi numero, incluso lo zero
const price = p.price != null ? p.price : null;
const display = p.price != null ? ... : 'prezzo da definire';
`value="${p.price != null ? p.price : ''}"`

The bug manifested in three different places with different effects: during save (the field was deleted by Firebase because it received null), in the admin template (the field appeared empty on reload and the next save wrote null again), and in the frontend display (showing the fallback instead of the price). The fix is always the same: use != null instead of || or a ternary without a guard.

Content Security Policy and Firebase: dynamic subdomains

Adding a Content Security Policy is good security practice, but Firebase Realtime Database uses long-polling as a fallback channel alongside WebSockets, and the subdomains it generates are dynamic and unpredictable (e.g. s-gke-euw1-nssi1-1.europe-west1.firebasedatabase.app). A CSP in an HTML <meta> tag doesn't support subdomain wildcards, so Firebase was silently blocked.

The solution is moving the CSP from HTML to Netlify's HTTP headers in netlify.toml, where wildcards work correctly:

netlify.toml — CSP con wildcard Firebase
[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = """
      default-src 'self';
      script-src 'self' 'unsafe-inline'
        https://www.gstatic.com
        https://cdnjs.cloudflare.com;
      connect-src 'self'
        https://*.firebasedatabase.app
        wss://*.firebasedatabase.app
        https://*.googleapis.com
        https://firebasestorage.googleapis.com;
      img-src 'self' data: blob:
        https://firebasestorage.googleapis.com;
    """
    X-Frame-Options         = "DENY"
    X-Content-Type-Options  = "nosniff"
    Strict-Transport-Security = "max-age=31536000; includeSubDomains"
⚠️

I tag <meta http-equiv="Content-Security-Policy"> non supportano i wildcard di sottodominio in script-src. Per Firebase (e in generale per qualsiasi servizio che usa sottodomini dinamici) la CSP va sempre configurata negli header HTTP del server — su Netlify tramite netlify.toml.

UI: side drawer and image slideshow

The catalog shows cards with full-coverage image (object-fit: contain for terminals — tall and narrow — rather than cover which would crop them), name and price. On click, a side panel opens with a large image, description, plan buttons and detailed summary.

Each product can have up to three images (main + two supplementary uploaded to Firebase Storage). If present, the side panel automatically activates a slideshow with auto-advance every three seconds, manual navigation arrows and dot indicators. The timer is properly cleared when the panel closes to prevent it continuing in the background.

Security: Firebase rules and architecture

Real protection isn't about hiding buttons in JavaScript, but in Firebase Security Rules. The final structure:

Firebase RTDB — Security Rules
{
  "rules": {
    "products": {
      ".read": true,
      ".write": "auth != null"   // solo admin autenticato
    },
    "tariffs": {
      ".read": true,
      ".write": "auth != null"
    },
    "orders": {
      ".read":  "auth != null",  // solo venditori autenticati
      ".write": "auth != null"
    },
    "counters": {
      ".read":  false,            // mai leggibile dal client
      ".write": "auth != null"
    }
  }
}

The next step — not yet completed in this session — is replacing the generic auth != null on the orders section with a Firebase custom claim (role: 'venditore') assigned by the Netlify Function that validates the management panel token. This way only vendors with a valid panel-issued token will have access to orders, not just any authenticated user.

When a custom e-commerce on Firebase makes sense (and when it doesn't)

When to use it

  • Catalog with non-standard pricing logic (global pricing variants assigned per product, as in this case) that closed e-commerce platforms don't handle without plugins or workarounds.
  • Moderate order volume, where building on Firebase stays more cost-effective than the recurring costs of a SaaS e-commerce platform.
  • Full control over UI and admin flow, without template constraints imposed by platforms like Shopify or WooCommerce.
  • Team already used to the Firebase stack for other projects, reducing the learning cost compared to an external e-commerce CMS.

When not to use it

  • E-commerce with standard needs (simple catalog, classic checkout, few integrations): an established platform (Shopify, WooCommerce) offers faster launch and ready-made features (invoicing, shipping, marketing) without building them from scratch.
  • Team without web security experience: building authentication, Firebase rules and CSP from scratch requires attention a mature platform already handles by default.
  • Need for ready-made integrations (shipping carriers, marketplaces, e-invoicing): custom requires building every integration manually.

Alternatives

For a more generic management panel without the e-commerce specifics, see the leaner approach described in PanelControl, a vanilla JS + Firebase management panel. For those who just want payment simplicity without managing the whole order flow, a direct integration with a payment provider remains a leaner option than an entire custom e-commerce system.

Common mistakes

  • Treating 0 as falsy in JavaScript: a price or quantity of zero gets discarded by checks like if (value) — an explicit !== undefined or !== null check is needed.
  • Forgetting CSP for dynamic Firebase subdomains: Storage and Firestore use subdomains that change, and an overly strict Content Security Policy silently blocks requests.
  • Relying only on Firebase security rules without client-side validation: rules are the last line of defense, not the only one — a UI that assumes data is always valid breaks silently when unexpected data arrives.

The result

A fully custom e-commerce, without off-the-shelf platforms, built on a familiar stack: Firebase for data, Netlify for deployment, vanilla JS for everything else. The vendor opens the site from the management panel with a token in the URL, gets recognized automatically, selects the terminal and plan, fills in customer details, and the order lands on Firebase with a guaranteed-unique sequential number and their name attached. The customer will receive an email with the T&C PDF for the chosen plan — as soon as the Netlify Function for emails is wired up.

The most instructive part of the project wasn't the technical complexity, but how many architectural decisions depend on seemingly simple requirements: separating databases, keeping plans global, using transactions instead of simple writes, moving the CSP from HTML headers to HTTP headers. Each choice prevented a problem that would have surfaced in production.

Frequently asked questions

How do you protect an admin panel with a password without a dedicated backend?

With PBKDF2 via the browser's native Web Crypto API: only the hash and salt live in the source code, never the original password. PBKDF2 applies the hash function thousands of times (310,000 iterations in this case), making brute-force computationally too expensive compared to plain SHA-256.

How do you generate an always-unique order number even with concurrent writes to Firebase?

With a Firebase Realtime Database transaction on the /counters node, which has ".read": false in the security rules — it's not readable from the client, only the transaction itself can increment it and return the guaranteed-unique number in its own response.

Why was a zero-euro price displayed as "price to be defined" instead of as 0?

The problem is JavaScript's falsy evaluation: patterns like p.price || null treat zero as an absent value, clearing the field on Firebase or showing the fallback in the template. The fix is using != null instead of || or an unguarded ternary, since != null is true for any number, including zero.