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'
});
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';
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 }
})
});
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);
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"]
}]
gsutil cors set cors.json gs://[YOUR_FIREBASE_PROJECT].firebasestorage.app
gsutil cors get gs://[YOUR_FIREBASE_PROJECT].firebasestorage.app
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
}
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);
}
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
};
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;
}
}
}
The result
After successful OTP verification, the system:
- Saves to Firestore: name, timestamp, IP, SHA-256 hash of the viewed PDF, document version, OTP verified flag
- Appends a certificate page to the original PDF with pdf-lib
- Uploads the modified PDF 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 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)
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.