← blog
OTP Firma elettronica Audit trail Sicurezza

OTP and simple electronic signature: verification pattern and audit trail

How to build a traceable acknowledgement confirmation β€” OTP code via email, document hash, audit trail β€” with no Firebase Authentication, no SMS, and without calling it a "digital signature" if it isn't one.

The terminology distinction to clarify first

In Italy, "digital signature" is a precise legal term (qualified electronic signature under the Digital Administration Code, issued by an accredited certifier, with a smart card or remote signing). A pattern with OTP + hash + audit trail is a simple electronic signature: name, timestamp, IP, document hash, verified code. It's a reasonable approach for internal use β€” but using the wrong term in the UI creates legal expectations the system doesn't meet. In the UI, "acknowledgement confirmation with code verification" is the safer label.

Why not Firebase Auth / SMS OTP

If users are already identified inside the app with their own account, a second "official" SMS authentication layer is often redundant β€” on top of a non-trivial SMS cost and reCAPTCHA setup. The simpler pattern: generate the 6-digit OTP server-side, store only its hash, and send it via email through a service already used in the app. Zero new infrastructure.

Generation: hash + salt, never plaintext

Even in case of unauthorized database access, the code must not be recoverable: only its SHA-256 hash with a random salt is stored, with a short expiry (5 minutes).

Node.js β€” generazione OTP con hash+salt
// 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 in chiaro
await saveRecord({
  otpHash, salt, scadenza,
  tentativi: 0,
  stato: 'in_attesa'
});

Verification: timing-safe comparison and attempt limit

A "normal" string comparison (===) can leak, through execution time, information about match length β€” a timing attack. crypto.timingSafeEqual compares in constant time. After 5 failed attempts the record should be automatically invalidated.

Node.js β€” verifica OTP timing-safe
const rec = await getRecord(recordKey);

// 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)) {
  await saveRecord({ ...rec, tentativi: rec.tentativi + 1 });
  return err(401, `Codice errato. Tentativi rimasti: ${4 - rec.tentativi}`);
}

// OTP corretto β€” cattura IP server-side e registra
const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'sconosciuto';

PerchΓ© l'IP va letto server-side: il client non conosce in modo affidabile il proprio IP pubblico. Va letto dall'header x-forwarded-for al momento della verifica, garantendo che sia autentico e non manipolabile dal client.

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

Before the user can request the OTP, the document must be downloaded and its SHA-256 hash computed client-side with the Web Crypto API β€” no external library needed. The hash is sent along with the verification request and saved in the record: it ensures the specific document viewed is the one attested in the audit trail.

JavaScript β€” hash SHA-256 con Web Crypto API
async function calcolaHashDocumento(blob) {
  const arrayBuffer = await blob.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('');
}

Trappola CORS su Firebase Storage: un fetch(downloadURL) puΓ² fallire silenziosamente β€” nessun errore visibile in console, ma il blob resta vuoto. Firebase Storage non configura CORS automaticamente per fetch da origini esterne. Serve una configurazione one-time del bucket con gsutil, nessuna modifica al codice.

Checklist for a credible audit trail

Frequently asked questions

Does this system count as a legal digital signature?

No. It's a simple electronic signature with audit trail (name, timestamp, IP, hash, verified OTP), not a qualified electronic signature under Italy's Digital Administration Code, which requires an accredited certifier.

Why store only the OTP code's hash and not the code itself?

So that even in case of unauthorized database access, the code isn't recoverable. Only the SHA-256 hash with a random salt is stored, never the plaintext value.

Why use crypto.timingSafeEqual instead of a normal string comparison?

A normal string comparison can leak, through execution time, information about match length β€” a timing attack. timingSafeEqual compares in constant time.

Related article
The full system: Firestore audit trail, PDF hash and pdf-lib certificate
β†’

When to use this OTP-based electronic signature pattern

This guide describes a simple electronic signature pattern based on email OTP, with hash+salt, timing-safe comparison, SHA-256 hash of the signed document, and a complete audit trail of the operation.

When to use it

When NOT to use it

Alternatives

Common mistakes