Email OTP with a Netlify Function: hashing, salting and timing-safe verification
Generate and verify a 6-digit OTP code via email, without Firebase Authentication — hash+salt never stored in plaintext, 5-minute expiry, constant-time comparison and attempt limit.
Why not Firebase Authentication
If users are already identified in the app with their own account, a second "official" authentication layer is often unnecessary. Firebase Phone Auth also has a non-trivial cost for Italian SMS and requires reCAPTCHA setup. A Netlify Function with email OTP is simpler and zero-cost.
Generation: never store the plaintext code
Generate the 6-digit code, combine it with a random salt, and store only the hash — never the code itself — with a 5-minute expiry.
// 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 salvaRecord(`otp/${userId}`, { otpHash, salt, scadenza, tentativi: 0, stato: 'in_attesa' }); // otp viene inviato via email — mai loggato, mai salvato
Verification: timing-safe comparison and attempt limit
Verification uses crypto.timingSafeEqual to compare hashes, avoiding timing attacks where a
normal string comparison could leak information about match length. After 5 failed attempts the record
is invalidated.
const rec = await leggiRecord(`otp/${userId}`); // 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 attack 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 salvaRecord(key, { ...rec, tentativi: rec.tentativi + 1 }); return err(401, `Codice errato. Tentativi rimasti: ${4 - rec.tentativi}`); } // OTP corretto — cattura IP server-side e registra esito const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'sconosciuto';
Why capture IP server-side: the client can't reliably know its own public IP. It must be read from the x-forwarded-for header at verification time, server-side, ensuring it's authentic and not manipulable by the client.
Legal terminology: this is a simple electronic signature with audit trail approach (name, timestamp, IP, document hash, verified OTP) — not a legally qualified "digital signature" issued by an accredited certifier. Use the correct term in the UI to avoid mismatched legal expectations.