Saltearse al contenido

Verificar firma HMAC

Tu URL de webhook es pública por necesidad — INGALCA Pay tiene que poder pegarle. Eso significa que cualquiera podría intentar mandarte requests falsos simulando pagos exitosos. La firma HMAC-SHA256 te permite verificar que cada webhook realmente viene de nosotros.

Cómo se calcula

firma = "sha256=" + hmac_sha256(secret, body_json_exacto)
  • secret — tu webhook secret (whsec_...). Lo configurás en Configuración → Webhooks → Rotar secret. Es distinto por entorno.
  • body_json_exacto — el cuerpo de la request byte por byte como llega. No lo parsees ni lo reformatees antes de firmar; usá el raw body.

Cómo verificar

import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.INGALCA_WEBHOOK_SECRET;
// Importante: usar raw body, NO express.json() acá
app.post('/webhooks/ingalca', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-ingalca-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
// ... procesar event ...
res.sendStatus(200);
});

Qué NO hacer

  • No uses == para comparar firmas. Es vulnerable a timing attacks. Usá crypto.timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
  • No re-serialices el body. Si convertís el JSON a objeto y lo volvés a stringify, vas a alterar espacios/orden y la firma no va a matchear.
  • No expongas el secret en logs. Trátalo como una contraseña.

Rotar el secret

Si sospechás que tu secret se filtró, rotalo desde Configuración → Webhooks. El cambio es inmediato — los próximos webhooks van firmados con el secret nuevo. Asegurate de actualizar tu env var antes de rotar, sino vas a empezar a rechazar webhooks legítimos.

Replay attacks

El header X-Ingalca-Timestamp te permite rechazar webhooks demasiado viejos:

const timestamp = parseInt(req.headers['x-ingalca-timestamp'], 10);
const ageSeconds = Math.floor(Date.now() / 1000) - timestamp;
if (ageSeconds > 300) {
return res.status(401).send('webhook too old');
}

5 minutos de tolerancia suele ser razonable. Más tiempo te deja vulnerable a replays; menos tiempo puede causar falsos negativos por desfase de reloj.