Three vulnerabilities found in priority order in a vanilla JS Firebase management panel: XSS via direct interpolation of user data into innerHTML, Firebase email and password in plain text in the client source, and an HMAC key hardcoded and shared between two PWAs. The fix required two JavaScript helpers, a Node.js Cloud Function that issues custom tokens, and migrating the secret to Secret Manager — with complications from CORS, IAM permissions, and a Cloud Function URL that changes between deploys.
The context
The panel is an internal customer and order management tool, built entirely in vanilla JS with Firebase Realtime Database as the backend. It's used in real time by multiple operators simultaneously — every record change propagates via onValue() to all open sessions. Personal data (name, email, phone, notes) is entered by operators but also, in part, from onboarding forms filled in by customers themselves.
The audit started as a systematic review of the main file, roughly 3,500 lines. What emerged wasn't a single isolated problem but three distinct attack surfaces, each with a different risk level and fix complexity.
Vulnerability #1 — XSS via unsanitised innerHTML
The entire customer table is built with template literals and assigned directly to the container's innerHTML. Personal data is interpolated as-is:
// ❌ User data interpolated directly
rows += `<td class="col-nome" data-tip="${nome}" onclick="openViewModal('${id}')">${nome}</td>`;
rows += `<td class="col-email">${email}</td>`;
rows += `<td class="col-note">${nota}</td>`;
If a customer enters a name like "><img src=x onerror=alert(1)> in the onboarding form, that string lands unchanged in the DOM of every connected operator. With data coming from external forms, this isn't theoretical — a real payload could read session tokens, inject arbitrary code, or exfiltrate data, exploiting Firebase's real-time propagation to every open session for free.
The fix: two helpers near Firebase initialization, applied systematically to every interpolation point.
// Escape for visible HTML content (cells, modal bodies, notes)
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Additional escape for values inside onclick='...' and data-tip=""
// A single quote would break the JS string even after standard escapeHtml
function escapeJsAttr(str) {
return escapeHtml(str).replace(/'/g, '\\\'');
}
// ✅ After the fix
const eNome = escapeHtml(nome);
const eId = escapeJsAttr(id);
rows += `<td class="col-nome" data-tip="${eNome}" onclick="openViewModal('${eId}')">${eNome}</td>`;
The two helpers exist because the contexts differ: escapeHtml protects visible content, while escapeJsAttr handles the onclick attribute case, where a single quote in a customer name would break the JS string even after normal HTML escaping. Applied across the main table, mobile cards, detail modal, notes list, and trash table.
Vulnerability #2 — Firebase credentials in plain text in the client source
The Firebase initialization block of the main file contained this:
// ❌ Email and password in plaintext — visible via "View Source"
signInWithEmailAndPassword(auth, '[admin@example.com]', '[PASSWORD]');
Anyone opening "View source" gets the shared Firebase account credentials. If the Realtime Database rules are restricted to that auth.uid, those credentials are the full read/write key to the entire database — completely bypassing the app's own authentication layer.
The same block held a second, related issue: an HMAC key used to verify tokens issued by the companion app, also hardcoded in plain text:
// ❌ Anyone reading the source can forge valid HMAC tokens
var SECRET = '[HMAC_SECRET]';
// The same string was also present client-side in the companion app
The exposed HMAC key is arguably worse than the password: anyone who knows it can forge a valid token from scratch, bypassing authentication entirely without ever touching an operator account.
The solution: Cloud Function mintAuthToken + Secret Manager
The fix moves all sensitive logic server-side. The client no longer needs to know the Firebase password or the HMAC key for anything beyond a preliminary UX check. The new flow:
- The companion app generates an HMAC-signed token with the secret and passes it in the portal URL as
?auth=... - The portal calls a Cloud Function,
mintAuthToken, passing the token - The Cloud Function retrieves the HMAC secret from Secret Manager (never in code), re-verifies the token, and if valid calls
createCustomToken(uid) - The portal receives the custom token and calls
signInWithCustomToken(auth, customToken)— no password in the client, ever
import { onRequest } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';
import admin from 'firebase-admin';
import crypto from 'crypto';
admin.initializeApp();
const hmacSecret = defineSecret('PANELCONTROL_HMAC_SECRET');
// Fixed UID of the service Firebase account — not a secret
const FIXED_UID = '[UID_SERVICE_ACCOUNT]';
export const mintAuthToken = onRequest(
{ secrets: [hmacSecret], region: 'europe-west1' },
async (req, res) => {
// Manual CORS — Firebase v2 doesn't support the *.netlify.app wildcard
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.status(204).send(''); return; }
const { token, timestamp, nonce } = req.body;
if (!token || !timestamp || !nonce) {
res.status(400).json({ error: 'missing fields' }); return;
}
// Expired token (5-minute window)
if (Math.abs(Date.now() - Number(timestamp)) > 300_000) {
res.status(401).json({ error: 'token expired' }); return;
}
// Verify HMAC using the secret from Secret Manager
const secret = hmacSecret.value();
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}:${nonce}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
res.status(401).json({ error: 'invalid token' }); return;
}
// Issue the Firebase custom token — no password in the client
const customToken = await admin.auth().createCustomToken(FIXED_UID);
res.json({ customToken });
}
);
The HMAC secret is set once with firebase functions:secrets:set PANELCONTROL_HMAC_SECRET and never appears in code — not in the Cloud Function, not in the client. The function accesses it at runtime through Firebase's native Secret Manager integration.
Firebase CLI setup on Windows (from scratch)
Without Node.js installed, the first obstacle on Windows is PowerShell's execution policy blocking third-party .ps1 files — including npm.ps1.
# 1. Unblock script execution (one-time)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# 2. Install Firebase CLI (after installing Node.js LTS from nodejs.org)
npm install -g firebase-tools
# 3. Login and init (answers: JavaScript, ESLint → N, deps → Y)
firebase login
firebase init functions
# 4. Generate a secure HMAC key (don't reuse the old one)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# 5. Set the secret (value requested interactively — never in shell history)
firebase functions:secrets:set PANELCONTROL_HMAC_SECRET
# 6. Deploy
firebase deploy --only functions:mintAuthToken
The CORS wall with Cloud Functions v2
After the first deploy, the portal returned Failed to fetch — the function was reachable but rejecting the request. Firebase Cloud Functions v2 doesn't support the *.netlify.app wildcard in the cors option of the onRequest decorator. The fix: handle CORS manually.
// ❌ Doesn't work with dynamic Netlify subdomains (*.netlify.app)
onRequest({ cors: true, secrets: [hmacSecret] }, handler);
// ✅ Manual handling — works for any origin
async (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
// Response to the OPTIONS preflight is mandatory
if (req.method === 'OPTIONS') { res.status(204).send(''); return; }
// ... rest of the handler
}
Missing IAM permission: Service Account Token Creator
With CORS fixed, the function was reachable but returned HTTP 500. The reason: the Cloud Functions execution service account ([PROJECT_NUMBER]-compute@developer.gserviceaccount.com) didn't have the Service Account Token Creator role in Google Cloud IAM. That role is required for the Admin SDK's createCustomToken(), which internally signs a JWT using the service account's private key.
# Service account: [PROJECT_NUMBER]-compute@developer.gserviceaccount.com
# Roles already present by default:
# roles/firebase.sdkAdminServiceAgent
# roles/secretmanager.secretAccessor (added automatically by deploy)
# Role to add manually:
# roles/iam.serviceAccountTokenCreator
# → required for admin.auth().createCustomToken(uid)
Fix: Google Cloud Console → IAM & Admin → IAM → find the compute service account → edit → add Service Account Token Creator → save. No redeploy needed — the permission takes effect immediately.
HMAC secret rotation
The secret hardcoded in the original source has to be treated as compromised — anyone who ever viewed the page source knows it. Moving it to Secret Manager with the same value doesn't fix anything; it needs to be regenerated.
Rotation touches three points that must be updated with the same new value, atomically (otherwise logins break mid-transition):
-
Secret Manager —
firebase functions:secrets:set PANELCONTROL_HMAC_SECRET, answer Y to the redeploy prompt -
The management panel — replace the old value in the client-side
SECRETvariable (used only for the lock screen's immediate UX feedback, not real verification) - The companion app — replace the value in its own secret variable and redeploy
What remains — residual architecture and technical debt
Two structural issues stayed open at lower priority:
-
Full table rebuild on every sync.
renderTablerebuilds the entireinnerHTMLon everyonValue('clienti')— i.e. on every single-record edit by any operator. Debouncing withrequestAnimationFramegroups close-together changes but doesn't remove the rebuild cost itself. The next real performance step would be per-row diffing. -
Residual
transition: all. About ten isolated elements still use it instead of specific properties — minimal impact, low priority.
What we learned
- innerHTML + user data = XSS, always. In a realtime app fed by external forms, there's no way to know in advance what will arrive. Two centralized escape functions applied systematically beat any amount of upstream form validation.
- Credentials in client source are permanently exposed. No obfuscation is effective in the browser — source is always readable. Keep passwords and HMAC secrets server-side from day one.
-
signInWithCustomTokenis the right pattern for hybrid auth. When a separate system already handles primary authentication, Firebase should only be a secondary provider fronted by a secure Cloud Function bridge. -
CORS in Cloud Functions v2 needs manual handling when dynamic subdomains are involved —
cors: truedoesn't cover it. - Service Account Token Creator is a non-obvious permission. Not granted by default, and the error it produces (a generic HTTP 500) doesn't hint at IAM at all.
- An already-exposed secret must be rotated, not just relocated. Moving the old value to Secret Manager unchanged solves nothing — rotation is part of the fix, not an afterthought.
Full write-up on my blog: roversia.it/blog-12-security-audit-xss-firebase-custom-token.html
Top comments (1)
How did you identify the XSS vulnerability via innerHTML in the Firebase management panel, and what steps would you take to mitigate it? I'd love to swap ideas on securing PWAs.