DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

Electronic signature with OTP on Firebase: audit trail, PDF hash and certificate with pdf-lib

I needed to send internal documents to team members — regulations, policy updates — and collect traceable proof that each person had read and accepted them. The classic proposal: Firebase Authentication with SMS OTP, Firestore, PDF certificate. Let me tell you what I actually built instead, and every real problem I hit along the way.


First: it's not a "digital signature"

Before writing a line of code, terminology matters. In Italy (and more broadly under eIDAS), "firma digitale" (digital signature) is a precise legal concept: a qualified electronic signature, issued by an accredited certification authority (think Aruba, InfoCert) with a hardware token or remote signing service.

What we're building is a simple electronic signature with audit trail: name, timestamp, IP, document hash, OTP verified. Perfectly valid for internal use, but calling it "firma digitale" in the UI creates legal expectations the system cannot meet. I used "acknowledgement confirmation with OTP code verification" instead.


Why not Firebase Authentication for OTP

The original proposal used Firebase Phone Auth (SMS OTP). Problems:

  • Users are already identified inside the app — a second Firebase Auth layer adds nothing
  • Firebase Phone Auth has non-trivial cost for Italian SMS numbers
  • It requires reCAPTCHA setup and verified phone numbers

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


System architecture

Layer Component Responsibility
Admin UI + Firebase Storage Upload PDF to NuoviAccordi/{opKey}/, create Firestore record, push notification to user
Server Netlify Function firma-otp.js Generate OTP, save hash+salt to Firestore, send email, verify code, capture IP, update final record
Operator PWA UI + pdf-lib CDN View PDF in iframe, compute SHA-256 hash client-side, enter OTP, receive confirmation

Firestore data structure: a single firmeDocumenti collection with composite key {docId}__{opKey}. Created at send time with status in_attesa, updated after successful OTP verification with status firmato (+ timestamp, IP, PDF hash, certificate path).


OTP generation: hash + salt, never plaintext

// Generate 6-digit code
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 minutes

// Save only hash+salt — never the plaintext code
await fsSetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`, {
  otpHash, salt, scadenza,
  tentativi: 0,
  stato: 'in_attesa'
});
Enter fullscreen mode Exit fullscreen mode

OTP verification: timing-safe comparison + max 5 attempts

const rec = await fsGetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`);

if (Date.now() > rec.scadenza)  return err(400, 'Code expired');
if (rec.tentativi >= 5)         return err(400, 'Too many attempts');

// Timing-safe compare — avoids 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)) {
  await fsSetDoc(idToken, key, { ...rec, tentativi: rec.tentativi + 1 });
  return err(401, `Wrong code. Attempts left: ${4 - rec.tentativi}`);
}

// OTP correct — capture IP server-side
const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'unknown';
Enter fullscreen mode Exit fullscreen mode

Note on IP: the client can't reliably know its own public IP. Capturing it server-side in the Function, at the moment of OTP verification, guarantees it's authentic and not client-spoofable.


EmailJS server-side: public key vs private key

EmailJS client-side calls use the public key, which works only when the request comes from the authorised origin in the EmailJS dashboard. From a Netlify Function (origin: server), you need the private key, which bypasses the origin check.

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: 'template_otp_firma',
    user_id:     process.env.EMAILJS_PUBLIC_KEY,
    accessToken: process.env.EMAILJS_PRIVATE_KEY, // <-- private key bypasses origin check
    template_params: { to_email, nome, codice, documento }
  })
});
Enter fullscreen mode Exit fullscreen mode

Private key: EmailJS → Account → API Keys. Save as Netlify env var, never in frontend code.


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

Before the user can request the OTP, the PDF is downloaded from Storage and hashed client-side with the Web Crypto API — no library needed, available in all modern browsers.

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('');
}

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(); // keep for pdf-lib
_firmaHashPdf     = await calcolaHashPdf(blob);
Enter fullscreen mode Exit fullscreen mode

The Firebase Storage CORS problem

The first attempt to download the PDF with fetch(downloadURL) failed silently — no visible error in console, _firmaPdfBytes stayed null, no certificate was ever generated.

Cause: Firebase Storage doesn't auto-configure CORS for programmatic fetch() requests from external origins. The browser blocks the response before JavaScript can see it. Crucially, the <iframe> preview does work because browser navigation isn't subject to CORS — which made this very hard to spot.

Fix: one-time bucket configuration with gsutil:

// cors.json
[{
  "origin": ["https://[YOUR_APP_DOMAIN]"],
  "method": ["GET"],
  "maxAgeSeconds": 3600,
  "responseHeader": ["Content-Type", "Content-Disposition"]
}]
Enter fullscreen mode Exit fullscreen mode
gsutil cors set cors.json gs://[YOUR_FIREBASE_PROJECT].firebasestorage.app
gsutil cors get gs://[YOUR_FIREBASE_PROJECT].firebasestorage.app
Enter fullscreen mode Exit fullscreen mode

No code changes, no redeploy needed — it's a bucket-level setting that takes effect immediately.


The Netlify 4KB env var limit: goodbye service account

Netlify Functions have a 4KB total limit for environment variables injected per function. A Firebase service account RSA private key is ~1.7-2KB. Combined with other existing variables, this blows the limit and the deploy fails with HTTP 400.

Solution: ditch the service account. 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) with just two small string env vars instead of a 1.7KB PEM.

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; // valid 1h, satisfies request.auth != null
}
Enter fullscreen mode Exit fullscreen mode

This pattern fits a single shared-account internal app. For multi-user apps, the correct pattern is to pass each user's own ID token from the client to the Function, which verifies it before acting.


pdf-lib: appending a certificate page to the PDF

After successful OTP verification, pdf-lib (pure JS, ~230KB gzip, no native deps) is loaded lazily from CDN and used to append a certificate page to the original PDF.

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 generateCertificate(item, firma) {
  if (!_firmaPdfBytes) return; // CORS not configured
  await _ensurePdfLib();

  const { PDFDocument, StandardFonts, rgb } = PDFLib;
  const pdfDoc = await PDFDocument.load(_firmaPdfBytes,
    { ignoreEncryption: true }); // some PDFs require this flag

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

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

  draw('CERTIFICATE OF ACKNOWLEDGEMENT', height - 80, 16);
  draw(`Operator: ${firma.nome}`,         height - 130, 11);
  draw(`Document: ${item.titolo}`,        height - 155, 11);
  draw(`Date/time: ${firma.dataLeggibile}`, height - 180, 11);
  draw(`Auth method: OTP via email`,      height - 205, 11);
  draw(`IP: ${firma.ip}`,                 height - 250, 10);
  draw(`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}_signed.pdf`);
  await uploadBytes(certRef, pdfBlob);
}
Enter fullscreen mode Exit fullscreen mode

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

The "Open" button on signed documents did nothing — no error. Cause: window.open() called inside a .then() or async function is no longer treated as "user-activated" by modern browsers. The original click activation expires before the promise resolves, so the popup is silently blocked.

// ❌ Blocked — window.open inside .then() is async
btn.onclick = async () => {
  const url = await getDownloadURL(ref);
  window.open(url, '_blank'); // too late, activation expired
};

// ✅ Works — window opened synchronously, navigated after
btn.onclick = async () => {
  const win = window.open('', '_blank');  // open immediately, synchronous
  const url = await getDownloadURL(ref); // then resolve the URL
  if (win) win.location.href = url;      // navigate the already-open window
};
Enter fullscreen mode Exit fullscreen mode

This applies to any window.open() called after any async operation — fetch, Firebase getDownloadURL, IndexedDB. The "open empty, navigate after" pattern works universally.


Firestore: deny-all by default

Unlike the Realtime Database (which has fallback rules), Firestore denies everything not explicitly listed in the rules. Adding a new collection (firmeDocumenti) without updating the rules generates a "Missing or insufficient permissions" error that looks like the document simply wasn't saved — no obvious indication it's a security rule failure.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /activityLog/{doc} {
      allow read, write: if request.auth != null;
    }
    // New collection — same pattern
    match /firmeDocumenti/{doc} {
      allow read, write: if request.auth != null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The result

After successful OTP verification, the system:

  1. Saves to Firestore: name, timestamp, IP, SHA-256 hash of the viewed PDF, document version, OTP verified flag
  2. Appends a certificate page to the original PDF with pdf-lib
  3. Uploads the modified PDF to Storage in the operator's folder
  4. 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 its human-readable representation. If a real qualified signature is needed in future, the record already contains all the data required for integration with an accredited certifier.


Full article with Italian version: roversia.it/blog-10-firma-elettronica-otp-firebase.html

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How did you handle cases where the OTP doesn't match, is there a retry limit or does it lock the user out? I'd love to swap ideas on implementing this with pdf-lib.