← blog
Node.js Netlify Functions Senza Firebase Auth Sicurezza

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.

netlify/functions/otp.js — generazione
// 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.

netlify/functions/otp.js — verifica timing-safe
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.

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

When to use this email OTP pattern

This snippet shows how to generate and verify a 6-digit OTP code via email using a Netlify Function, without depending on Firebase Authentication, with hash+salt, a 5-minute expiration, timing-safe comparison, and attempt limiting.

When to use it

When NOT to use it

Alternatives

Common mistakes

Frequently asked questions about email OTP

A normal string comparison stops checking at the first differing character, indirectly leaking information through response time. A timing-safe comparison always takes the same amount of time, regardless of how many characters match, reducing the risk of timing attacks.

Because if the database were compromised, a plain-text OTP would be immediately usable by an attacker. Hash and salt make the stored value unusable without redoing the calculation with the original salt.

No, it's specifically designed to not depend on an external authentication provider: it uses a Netlify Function and entirely custom OTP logic, useful for lightweight verified flows.