Verificar la firma de webhooks en un agente de IA
Resumen
Un webhook sin firma es un endpoint público: cualquiera puede inventar un mensaje de Slack, Telegram o GitHub y disparar tools. Esta guía compara HMAC de GitHub, Slack, Meta y el secret_token de Telegram, con código Node, replay window y el error de hashear el JSON parseado.

Qué resuelve
Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.
Un agente en producción no empieza en el prompt. Empieza en quién puede hablarle. Si el endpoint /webhooks/* acepta el body sin verificar origen, un atacante manda un JSON inventado, el loop llama tools y pagas tokens —o peor, ejecutas una acción. Idempotencia (no cobrar/enviar dos veces cuando el proveedor reintenta) es otro problema; aquí el único tema es autenticar el request antes de encolarlo.
La arquitectura mínima —validar, normalizar, encolar— está en webhooks, colas y memoria. El paso 1 de “validar” es esta firma.
Qué verifica cada canal
No hay un HMAC universal. Cada plataforma firma distinto y no puedes reutilizar el verificador.
| Canal | Header | Qué se firma | Secreto | Replay |
|---|---|---|---|---|
| GitHub | X-Hub-Signature-256 (sha256=…) | body crudo | webhook secret | no (usa delivery id aparte) |
| Slack | X-Slack-Signature (v0=…) | v0:{timestamp}:{raw body} | signing secret | sí: ±5 min vs X-Slack-Request-Timestamp |
| Meta / WhatsApp Cloud | X-Hub-Signature-256 (sha256=…) | body crudo | App Secret | no |
| Telegram | X-Telegram-Bot-Api-Secret-Token | no hay HMAC: igualdad con secret_token de setWebhook | token 1–256 chars [A-Za-z0-9_-] | no |
Fuentes: GitHub, Slack, Telegram Bot API (setWebhook / secret_token), Meta Webhooks.
GitHub todavía puede enviar X-Hub-Signature (SHA-1). No lo uses. Slack documenta la ventana de cinco minutos; si omites el timestamp, un request capturado se reenvía días después.
Telegram no firma el body. El secret_token solo prueba que quien llama conoce el valor que tú pasaste a setWebhook. Combínalo con HTTPS y, si puedes, restricción de IP. El bot de Telegram en sí está en bot de Telegram con IA.
Meta además exige el handshake GET: hub.mode=subscribe, hub.verify_token igual al que configuraste, responder hub.challenge en texto plano. Sin eso no hay POST firmados.

El error que rompe todas las firmas
HMAC se calcula sobre los bytes exactos que envió el proveedor. Si parseas JSON y vuelves a JSON.stringify, cambian espacios y el orden de claves: la firma nunca coincide y, peor, algunos equipos “arreglan” eso desactivando la verificación.
En Next.js App Router lee el raw body una vez:
export const runtime = "nodejs";
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer());
// verificar raw; recién después JSON.parse(raw.toString("utf8"))
}
En Express, express.json() consume el stream. Usa express.raw({ type: "application/json" }) en esa ruta, verifica, y parsea tú.
Compara con timingSafeEqual y misma longitud. Un === entre hex strings filtra por tiempo cuántos caracteres coinciden.
Código mínimo (Node crypto)
Un helper, cuatro adaptadores. El secreto vive en env, nunca en el repo —mismo criterio que prompt injection: no confíes en input externo.
import { createHmac, timingSafeEqual } from "node:crypto";
function safeEqual(a: string, b: string) {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
function hmacHex(secret: string, payload: Buffer) {
return createHmac("sha256", secret).update(payload).digest("hex");
}
export function verifyGitHubOrMeta(
raw: Buffer,
header: string | undefined,
secret: string,
) {
if (!header?.startsWith("sha256=")) return false;
return safeEqual(`sha256=${hmacHex(secret, raw)}`, header);
}
export function verifySlack(
raw: Buffer,
timestamp: string | undefined,
signature: string | undefined,
secret: string,
nowMs = Date.now(),
) {
if (!timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > 60 * 5) return false;
const base = `v0:${timestamp}:${raw.toString("utf8")}`;
const expected = `v0=${createHmac("sha256", secret).update(base).digest("hex")}`;
return safeEqual(expected, signature);
}
export function verifyTelegram(header: string | undefined, secret: string) {
if (!header || !secret) return false;
return safeEqual(header, secret);
}
Ruta Slack: si falla, 401 y no encoles. Slack reintenta; un 200 con body ignorado enseña al atacante que el endpoint “traga” basura. El ack del canal (típicamente < 3 s) es otro problema: verifica en milisegundos, encola, responde 200.
GitHub documenta el mismo HMAC para redeliveries. El X-GitHub-Delivery sirve para idempotencia, no para autenticar.
Prueba sin LLM
No necesitas un modelo para saber si la firma funciona. Un test de 20 líneas falla si alguien parsea antes de hashear:
import { createHmac } from "node:crypto";
import { verifyGitHubOrMeta } from "./verify";
const secret = "test-secret";
const raw = Buffer.from('{"ok":true,"id":"a1"}');
const header =
"sha256=" + createHmac("sha256", secret).update(raw).digest("hex");
console.assert(verifyGitHubOrMeta(raw, header, secret) === true);
console.assert(
verifyGitHubOrMeta(Buffer.from(JSON.stringify(JSON.parse(raw.toString()))), header, secret)
=== false || raw.equals(Buffer.from(JSON.stringify(JSON.parse(raw.toString())))),
);
Si el segundo assert te sorprende: a veces stringify coincide; a veces no. Por eso el contrato es raw, no “JSON canónico”.

Checklist de producción
- Un secreto distinto por canal y por entorno (preview ≠ production).
- Verificar antes de parsear, loguear o llamar al LLM.
- 401 si falta header, si el secreto está vacío o si
timingSafeEqualfalla. - Slack: rechazar timestamp fuera de 5 minutos.
- GitHub/Meta: exigir
sha256=; ignorar SHA-1. - Telegram:
secret_tokenensetWebhooky comparar el header; rota con un nuevosetWebhook. - Meta: implementar el GET
hub.challengesin HMAC (es el handshake, no el evento). - No loguear el body ni el secreto. Un log de “firma inválida” +
deliveryIdbasta. - Tests de tools sin red: firma buena, firma mala, body re-serializado, replay viejo.
- Preview deployments: no apuntes el webhook de producción a una URL de preview.
Multicanal (WhatsApp + Telegram + Slack) implica tres verificadores, no un if (token === process.env.WEBHOOK_TOKEN) compartido. El mapa está en deploy multicanal.
Preguntas frecuentes
¿HTTPS no alcanza? No. HTTPS autentica el canal hasta tu servidor, no al cliente. Cualquiera que descubra la URL puede POSTear.
¿Puedo verificar después de encolar para no bloquear el ack? No. Si encolas basura, el worker gasta tokens y puede ejecutar tools. Verifica síncrono; es CPU de microsegundos.
¿Y los webhooks de Vercel/GitHub Actions hacia el agente? Misma regla: HMAC o secret de cabecera. Un cron que pega a /run sin firma es equivalente a un webhook abierto.
¿Sirve un allowlist de IPs? Como defensa extra (Telegram publica rangos a veces), nunca como único control: los rangos cambian y no cubren proxies.
El siguiente paso
Copia el helper, apunta un webhook de prueba y manda un POST a mano: debe ser 401. Luego el de la consola de Slack/GitHub: 200 y un job en cola. El curso gratis de instalación arma el primer agente; el hub de seguridad y costo agrupa operación. Sin firma, el resto del playbook es teatro.
Lecturas relacionadas
Sigue explorando Seguridad y otras piezas para builders.



