Ir al contenido

Verificar la firma

Su endpoint es una dirección pública: cualquiera puede enviarle un POST. La firma es lo que prueba que la entrega vino de Faturei Hoje, con el secreto de su endpoint, y que el cuerpo no se alteró en el camino. Verifique la firma en cada entrega, antes de usar el cuerpo.

La firma sigue el estándar Standard Webhooks, así que las bibliotecas de ese estándar también sirven. Los ejemplos de abajo usan solo la biblioteca estándar de cada lenguaje.

  1. Lea los encabezados webhook-id, webhook-timestamp y webhook-signature.

  2. Rechace si webhook-timestamp está a más de 5 minutos de su reloj, hacia arriba o hacia abajo. Es lo que impide que alguien guarde una entrega verdadera y la envíe de nuevo más tarde.

  3. Arme el contenido firmado: webhook-id, punto, webhook-timestamp, punto, y el cuerpo crudo de la solicitud.

    evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f...
  4. Quite el prefijo whsec_ del secreto y decodifique el resto de base64. Esos bytes son la clave.

  5. Calcule el HMAC-SHA256 del contenido con la clave y codifique el resultado en base64.

  6. El encabezado webhook-signature trae una o más firmas separadas por espacio, cada una en el formato v1,<base64>. Acepte la entrega si cualquiera de ellas es igual a la que usted calculó, comparando en tiempo constante.

Firmar el JSON en lugar del cuerpo crudo. El contenido firmado es el texto exacto que llegó, byte a byte. Si su framework ya convirtió el cuerpo en objeto y usted lo vuelve a convertir en texto, los espacios, el orden de los campos y los acentos pueden cambiar, y la firma deja de coincidir. Lea el cuerpo crudo antes de cualquier interpretación.

Comparar con ==. La comparación común se detiene en el primer carácter distinto, y el tiempo que tarda le dice a un atacante cuántos caracteres acertó. Use la comparación en tiempo constante del lenguaje: crypto.timingSafeEqual en Node.js, hash_equals en PHP, hmac.compare_digest en Python.

Esperar una sola firma. Durante la rotación del secreto el encabezado trae dos. Separe por espacio y verifique cada una.

Cada ejemplo es un servidor completo, sin ninguna dependencia: recibe el POST, verifica la firma, responde 400 cuando no coincide y 200 cuando coincide. El secreto viene de la variable de entorno FH_WEBHOOK_SECRET.

webhook-receiver.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.FH_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody) // el cuerpo CRUDO, tal como llegó
.digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verifyWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
// Guarde el evento y procéselo después. Responda enseguida.
console.log(event.type, event.id);
res.writeHead(200).end();
});
}).listen(Number(process.env.PORT ?? 3000));

Ejecútelo con node webhook-receiver.mjs. En Express, use express.raw({ type: 'application/json' }) en la ruta del webhook, para que req.body llegue como el Buffer crudo, y pase req.body a verifyWebhook.

Los tres ejemplos se ejecutaron contra entregas firmadas por el mismo código que envía los webhooks: aceptan la entrega verdadera, con una o con dos firmas, y rechazan cuerpo alterado, firma alterada, secreto equivocado y marca de tiempo vieja.

POST /public/v1/webhook_endpoints/{id}/rotate_secret genera un secreto nuevo y lo devuelve en secret. Por la API solo aparece en esa respuesta, como en la creación; en el panel, el dueño y los administradores pueden revelarlo de nuevo, confirmando la contraseña.

Durante 24 horas después de la rotación, cada entrega sale con dos firmas en webhook-signature, separadas por espacio: primero la del secreto nuevo, después la del anterior. Así su servidor sigue aceptando las entregas mientras usted cambia el secreto, sea cual sea el que todavía usa. Pasadas las 24 horas, solo firma el secreto nuevo.

webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,3oZaGW1LqbpVYbfVQ5Yz8XJHsd3Wj/2f0sK7QpTnC1U=

Paso a paso:

  1. Rote y guarde el secreto nuevo.
  2. Cambie el secreto en su servidor.
  3. Confirme en el registro de entregas que las entregas siguen con éxito.

Rotar otra vez dentro de las 24 horas descarta el secreto más antiguo: conviven como máximo dos, el actual y el inmediatamente anterior. Si rotó dos veces seguidas, su servidor tiene que estar con uno de los dos últimos.

Rote cuando alguien que conocía el secreto deja el equipo o el proveedor, o cuando el secreto pasó por un lugar que usted no controla (un ticket, una conversación, un log).