Context and the terminology distinction

The requirement was to send internal documents to team members — regulations, procedural updates — and collect traceable confirmation that they had been read and accepted. The starting point was a classic proposal: Firebase Authentication with SMS OTP, Firestore storage, PDF certificate generation.

Before writing a single line of code, it was worth clarifying the terminology. In Italy, "firma digitale" (digital signature) is a precise legal term (qualified electronic signature under the Digital Administration Code, issued by an accredited certifier such as Aruba or InfoCert, with smart card or remote signing). What is built here is a simple electronic signature with audit trail: name, timestamp, IP, document hash, verified OTP code. It's a common and reasonable approach for internal use, but using the wrong term in the UI creates legal expectations the system cannot meet. In the UI I used "acknowledgement confirmation with OTP code verification".

Why not Firebase Authentication for OTP

The original proposal included Firebase Authentication with Phone Auth (SMS OTP). The problem: users are already identified inside the PWA with their own account, a second "official" authentication layer isn't needed. And Firebase Phone Auth has a non-trivial cost for Italian SMS messages, requires reCAPTCHA configuration and verified phone numbers.

The simpler solution: generate the 6-digit OTP server-side in a Netlify Function, save only the hash (never the plaintext code) to Firestore with a 5-minute expiry, and send it via email using EmailJS — a channel already in use in the PWA. Zero new infrastructure, zero extra costs.

System architecture

The flow is structured in three distinct layers:

Layer Componente Responsabilità
Admin UI + Firebase Storage Carica PDF in NuoviAccordi/{opKey}/, crea record Firestore, notifica l'utente via push FCM
Server Netlify Function firma-otp.js Genera OTP, salva hash+salt su Firestore, invia email, verifica codice, cattura IP, aggiorna record finale
Operatore UI PWA + pdf-lib CDN Visualizza PDF in iframe, calcola hash SHA-256 client-side, inserisce OTP, riceve conferma, apre certificato

The Firestore data structure is a single firmeDocumenti collection with composite key {docId}__{opKey}. The record is created by the admin at send time with status in_attesa and updated by the Netlify Function after successful OTP verification with status firmato, including timestamp, IP, PDF hash and certificate path.

The Netlify Function: OTP generation and verification

The function handles two distinct actions on the same endpoint: richiedi (generate and send the code) and verifica (check the code entered by the user). Both are protected by a shared x-internal-secret header, the same pattern already used in the project's other functions.

Generation: hash + salt, never plaintext

The 6-digit OTP code is generated with crypto.randomInt, but the plaintext code is never saved to Firestore. Only its SHA-256 hash with a random salt is stored — so even in case of unauthorised database access, the code cannot be recovered.

Node.js — generazione OTP con hash+salt (Netlify Function)
// Genera codice a 6 cifre
const otp    = String(crypto.randomInt(100000, 999999));
const salt   = crypto.randomBytes(16).toString('hex');
const otpHash = crypto.createHash('sha256')
  .update(otp + salt)
  .digest('hex');
const scadenza = Date.now() + 5 * 60 * 1000; // 5 minuti

// Salva solo hash+salt, mai il codice
await fsSetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`, {
  otpHash, salt, scadenza,
  tentativi: 0,
  stato: 'in_attesa'
});

Verification: timing-safe comparison and max 5 attempts

Verification uses crypto.timingSafeEqual to compare hashes, avoiding timing attacks where a normal string comparison could reveal information about match length. After 5 failed attempts the record is automatically invalidated.

Node.js — verifica OTP timing-safe
const rec = await fsGetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`);

// Controllo scadenza
if (Date.now() > rec.scadenza) {
  return err(400, 'Codice scaduto');
}
// Controllo max tentativi
if (rec.tentativi >= 5) {
  return err(400, 'Troppi tentativi');
}

// Timing-safe compare — evita timing attacks
const hashInput  = crypto.createHash('sha256').update(codice + rec.salt).digest('hex');
const bufInput   = Buffer.from(hashInput,   'hex');
const bufStored  = Buffer.from(rec.otpHash, 'hex');

if (bufInput.length !== bufStored.length ||
    !crypto.timingSafeEqual(bufInput, bufStored)) {
  // Incrementa contatore tentativi
  await fsSetDoc(idToken, key, { ...rec, tentativi: rec.tentativi + 1 });
  return err(401, `Codice errato. Tentativi rimasti: ${4 - rec.tentativi}`);
}

// OTP corretto — cattura IP e registra firma
const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'sconosciuto';
💡

L'IP non può essere catturato dal client in modo affidabile — il client non conosce il proprio IP pubblico. Viene letto server-side dall'header x-forwarded-for al momento della verifica OTP, garantendo che sia autentico e non manipolabile dal client.

EmailJS server-side: the private key

EmailJS is already used in the PWA from the browser — but client-side calls use the public key, which is sufficient only when the request comes from the origin authorised in the EmailJS dashboard. From a Netlify Function (origin: server, not the app's domain), the private key is needed instead, which bypasses the origin check.

The private key is found at EmailJS → Account → API Keys. It should be saved as a Netlify environment variable (EMAILJS_PRIVATE_KEY) and must never be put in frontend code. The server-side call uses the EmailJS REST endpoint with the accessToken field instead of publicKey.

Node.js — chiamata EmailJS server-side con private key
const emailRes = await fetch('https://api.emailjs.com/api/v1.0/email/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    service_id:  process.env.EMAILJS_SERVICE_ID,
    template_id: process.env.EMAILJS_TEMPLATE_OTP || 'template_otp_firma',
    user_id:     process.env.EMAILJS_PUBLIC_KEY,
    // accessToken = private key — bypassa il check sull'origin
    accessToken: process.env.EMAILJS_PRIVATE_KEY,
    template_params: {
      to_email:  emailOperatore,
      nome:      nomeOperatore,
      codice:    otp,
      documento: titoloDoc
    }
  })
});
// Il template EmailJS deve avere le variabili: {{to_email}}, {{nome}}, {{codice}}, {{documento}}

PDF SHA-256 hash: crypto.subtle on the client

Before the user can request the OTP code, the PDF is downloaded from Firebase Storage and the SHA-256 hash is computed client-side with the Web Crypto API — no external library, available in all modern browsers. The hash is then sent to the Netlify Function along with the verification request, which saves it in the signature record. This ensures that the specific document viewed is the one attested in the audit trail.

JavaScript — hash SHA-256 del PDF con Web Crypto API
async function calcolaHashPdf(pdfBlob) {
  const arrayBuffer = await pdfBlob.arrayBuffer();
  const hashBuffer  = await crypto.subtle.digest('SHA-256', arrayBuffer);
  const hashArray   = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

// Scarica blob da Storage e calcola hash prima di mostrare il documento
const pdfRef  = storageRef(storage, item.pdfPath);
const url     = await getDownloadURL(pdfRef);
const res     = await fetch(url);
const blob    = await res.blob();
_firmaPdfBytes = await blob.arrayBuffer();  // conserva per pdf-lib
_firmaHashPdf  = await calcolaHashPdf(blob);

The Firebase Storage CORS problem

The first attempt to download the PDF with fetch(downloadURL) failed silently — no visible error in console, but _firmaPdfBytes stayed null and the certificate was never generated. The reason: Firebase Storage does not automatically configure CORS policies for fetch() requests from external origins. The browser blocks the response before JavaScript can even see it.

The solution is a one-time bucket configuration via gsutil (Google Cloud SDK). No code changes required — it's a bucket-level configuration:

cors.json — configurazione CORS per Firebase Storage
[
  {
    "origin": ["https://[APP_DOMAIN]"],
    "method": ["GET"],
    "maxAgeSeconds": 3600,
    "responseHeader": ["Content-Type", "Content-Disposition"]
  }
]
Shell — applicazione e verifica CORS con gsutil
# Applica la configurazione al bucket
gsutil cors set cors.json gs://[FIREBASE_PROJECT_ID].firebasestorage.app

# Verifica che sia stata applicata correttamente
gsutil cors get gs://[FIREBASE_PROJECT_ID].firebasestorage.app

# Output atteso:
# [{"maxAgeSeconds": 3600, "method": ["GET"], "origin": ["https://[APP_DOMAIN]"], ...}]
⚠️

L'<iframe> per la preview del PDF funziona senza CORS configurato perché è una navigazione browser, non una fetch(). Il CORS è richiesto solo per i download programmati via JavaScript. Questo spiega perché la preview è visibile ma i byte non sono leggibili dal codice.

The Netlify 4KB env var limit: goodbye service account

Netlify Functions have a 4KB total limit for environment variables injected into each function. A Firebase service account RSA private key takes up about 1.7-2KB — combined with other already-present variables, the limit is exceeded and the deploy fails with an HTTP 400 error.

The solution: eliminate the service account and authenticate to Firebase using the same shared account credentials already used by the PWA. This returns an ID token that satisfies Firestore security rules (request.auth != null) without needing any 1.7KB PEM.

Node.js — login Firebase REST API per ottenere ID token (no service account)
async function getFirebaseToken() {
  const res = await fetch(
    `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword` +
    `?key=${process.env.FIREBASE_WEB_API_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email:             process.env.FB_AUTH_EMAIL,
        password:          process.env.FB_AUTH_PASSWORD,
        returnSecureToken: true
      })
    }
  );
  const data = await res.json();
  return data.idToken; // valido 1h, soddisfa request.auth != null
}

// Uso: l'ID token va nell'header Authorization delle chiamate Firestore REST
const idToken = await getFirebaseToken();
const doc     = await fsGetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`);
💡

Questo pattern è appropriato per app interne con un singolo account condiviso. Per app multi-utente dove ogni utente ha un account Firebase separato, il pattern corretto è passare dal client il proprio ID token alla Function (che lo verifica prima di agire), anziché fare login con credenziali fisse server-side.

Firestore rules: deny-all by default

Unlike the Realtime Database (which has rule blocks with fallbacks), Firestore denies by default any access to collections not explicitly named in the rules. Adding a new collection (firmeDocumenti) without updating the rules generates a "Missing or insufficient permissions" error that's invisible on the surface — the document appears unsaved but the error is a security one, not a code one.

Firestore Security Rules — aggiunta della nuova collezione
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /activityLog/{doc} {
      allow read, write: if request.auth != null;
    }
    // Nuova collezione — stesso pattern: autenticato = autorizzato
    match /firmeDocumenti/{doc} {
      allow read, write: if request.auth != null;
    }
  }
}

pdf-lib: appending a certificate page to the PDF

After successful OTP verification, the original PDF is modified client-side with pdf-lib — a pure JavaScript library (no native dependencies, ~230KB gzip) lazily loaded from CDN only at signing time. The library loads the original PDF, adds a new page with audit trail data, and returns the bytes of the modified PDF for upload to Storage.

JavaScript — generazione pagina certificato con pdf-lib
// Carica pdf-lib lazy da CDN
async function _ensurePdfLib() {
  if (window.PDFLib) return;
  await new Promise((res, rej) => {
    const s = document.createElement('script');
    s.src = 'https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js';
    s.onload = res; s.onerror = rej;
    document.head.appendChild(s);
  });
}

async function _firmaGeneraCertificato(item, firma) {
  if (!_firmaPdfBytes) return; // CORS non configurato
  await _ensurePdfLib();

  const { PDFDocument, StandardFonts, rgb } = PDFLib;
  const pdfDoc = await PDFDocument.load(_firmaPdfBytes,
    { ignoreEncryption: true }); // alcuni PDF richiedono questo flag

  const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
  const page = pdfDoc.addPage([595, 842]); // A4 in punti
  const { width, height } = page.getSize();

  const draw = (text, y, size=10, color=rgb(0.2,0.2,0.2)) =>
    page.drawText(text, { x:50, y, size, font, color });

  draw('CERTIFICATO DI PRESA VISIONE', height-80, 16, rgb(0,0,0));
  draw(`Operatore: ${firma.nome}`,            height-130, 11);
  draw(`Documento: ${item.titolo}`,           height-155, 11);
  draw(`Data e ora: ${firma.dataLeggibile}`,   height-180, 11);
  draw(`Autenticazione: OTP via email`,        height-205, 11);
  draw(`Versione documento: ${firma.versione}`, height-230, 10);
  draw(`IP: ${firma.ip}`,                      height-250, 10);
  draw(`Hash SHA-256: ${firma.hashPdf}`,        height-280, 7.5);

  const pdfBytes    = await pdfDoc.save();
  const pdfBlob     = new Blob([pdfBytes], { type: 'application/pdf' });
  const certRef     = storageRef(storage, `Accordi/${opKey}/${nome}_firmato.pdf`);
  await uploadBytes(certRef, pdfBlob);
}

window.open() and popup blockers: the synchronous fix

A subtle bug that generates no visible errors: the "Open" button on signed documents did nothing. The reason is that window.open() called inside a .then() or an async function is no longer considered "user-activated" by modern browsers — the original click activation expires before the promise resolves. The popup is silently blocked.

The fix: open the window synchronously in the click handler (while user activation is still valid), then navigate it to the URL when it arrives.

JavaScript — apertura URL asincrono senza popup blocker
// ❌ Bloccato dai popup blocker — window.open dentro .then() è async
btn.onclick = async () => {
  const url = await getDownloadURL(ref);
  window.open(url, '_blank'); // troppo tardi, attivazione scaduta
};

// ✅ Funziona — finestra aperta in modo sincrono, navigata dopo
btn.onclick = async () => {
  const win = window.open('', '_blank'); // apre subito, sincrono
  const url = await getDownloadURL(ref);   // poi risolve l'URL
  if (win) win.location.href = url;         // e naviga la finestra già aperta
};
⚠️

Lo stesso problema si presenta con qualsiasi window.open() chiamato dopo una operazione asincrona — fetch, Firebase getDownloadURL, IndexedDB. Il pattern "apri subito con URL vuoto, naviga dopo" funziona universalmente. In alternativa, usare un <a target="_blank"> programmato è ugualmente efficace e non richiede il workaround.

When this system makes sense (and when it doesn't)

When to use it

  • Internal read-acknowledgment confirmation (regulations, procedures, company communications) where traceability is needed but not the legal value of a qualified signature.
  • Team with phone numbers already known to the company, where SMS OTP is a reasonable verification channel without introducing an external identity provider.
  • Limited budget and complexity: the whole system runs on a Netlify Function + Firestore, without integrating a paid qualified digital signature service.
  • Sufficient audit trail for the context: name, timestamp, IP and document hash are enough to prove a specific person viewed that content at that time.

When not to use it

  • Documents requiring full legal evidentiary value (binding contracts, filings to public administration): a qualified electronic signature from an accredited certifier (e.g. Aruba, InfoCert) is needed, not a custom OTP system.
  • Regulated contexts where industry rules mandate a specific signature standard (e.g. banking, healthcare): check the requirements before building a custom solution.
  • External communication (customers, suppliers) rather than internal team: external parties' legal expectations differ, and a "homemade" system may not be adequate.

Alternatives

For genuine qualified digital signature legal value, the alternative is integrating an accredited certifier (Aruba, InfoCert, Namirial) via their APIs, with recurring costs but full validity under the Italian Digital Administration Code. For just managing user session security tied to Firebase, see also the approach with Firebase Custom Token.

Common mistakes

  • Calling an OTP confirmation system "digital signature": creates legal expectations the system doesn't meet. The correct term in UI and documentation is "simple electronic signature" or equivalent unambiguous wording.
  • Using Firebase Authentication for one-time OTP: it's designed for persistent sessions, not one-off verification — it creates unnecessary session management complexity for this use case.
  • Underestimating Netlify's environment variable limits: bulky credentials like a service account key don't fit the 4KB limit and need to be handled differently (e.g. via a secret manager or a reduced format).

The result: complete flow and audit trail

The complete system works as follows: the admin uploads the PDF, selects the recipient and clicks Send. The system creates the Firestore record, uploads the file to Storage and sends a push notification to the operator. The operator opens the dedicated section in the PWA, views the PDF preview, requests the OTP code via email, enters it, and on successful verification the system:

  • Saves to Firestore: name, timestamp, IP, SHA-256 hash of the viewed PDF, document version, verified OTP
  • Generates a modified PDF (original + certificate page) with pdf-lib
  • Saves the certificate to Storage in the operator's folder
  • Shows the document in the Accordi section on both admin and operator sides

The Firestore record is the permanent audit trail — the PDF with the certificate page is the human-readable representation of the same data. If a real qualified signature needs to be added in future, the Firestore record already contains all the data needed for integration with an accredited certifier.

Frequently asked questions

Why did the PDF preview in an iframe work without CORS configuration, but the JavaScript download didn't?

An <iframe> is a browser navigation, not a fetch() call, so it isn't subject to the same restrictions. CORS is only required when reading the file's bytes programmatically via JavaScript — that's why the preview shows up but the download fails until the Storage bucket has an explicit CORS configuration.

How do you authenticate a Netlify Function to Firestore when the service account key exceeds the 4KB env var limit?

By eliminating the service account and authenticating with the same shared account credentials already used by the PWA, via Firebase's signInWithPassword REST API. This returns an ID token valid for one hour that satisfies request.auth != null in the Security Rules, with no need for a 1.7-2KB PEM.

Why does adding a new Firestore collection without updating the security rules make it look like the save failed?

Unlike the Realtime Database, Firestore denies access by default to any collection not explicitly named in the rules. The "Missing or insufficient permissions" error isn't always visible on the surface, so the document appears unsaved, but the write was actually blocked by the Security Rules, not by a code bug.