Skip to content
Volver al blog
Seguridad

Verificación de firma de webhook fallida: causas y soluciones

¿Verificación de firma de webhook fallida? Suele ser el cuerpo sin procesar, la codificación del digest o un prefijo de marca de tiempo. Depúralo con HMAC.

15 min de lectura

¿Verificación de firma de webhook fallida? Encuentra tu causa

Un error de webhook signature verification failed significa una sola cosa: el digest que calculó tu código no es igual al digest que viene en la cabecera de la petición. Ese es todo el mensaje. No dice nada sobre permisos ni sobre expiración, y casi nunca se trata de un bug del SDK del proveedor. Algo difiere entre los bytes que el proveedor hasheó y los bytes que hasheaste tú.

Cuatro entradas deciden el resultado: qué bytes se firmaron, qué bytes de clave se usaron, qué algoritmo de hash se ejecutó y en qué codificación de texto hiciste la comparación. Equivócate en cualquiera de las cuatro y el fallo se ve idéntico. El error no trae ninguna pista sobre cuál fue, así que el trabajo consiste en acotar el espacio de entradas, no en leer el mensaje con más atención.

Elige por dónde empezar:

¿La firma no coincide? Tres ramas:
├─ ¿Tu framework parseó el JSON antes de que lo vieras?        → Sección 3
├─ ¿El valor de la cabecera lleva un prefijo o parece base64?  → Sección 4
└─ ¿La cabecera del proveedor incluye una marca de tiempo?     → Sección 2

1. Qué te dice una firma que no coincide

La verificación es una comparación de dos cadenas de bytes. Cuando falla, exactamente una de cuatro cosas está mal, y las cuatro son independientes entre sí.

Qué bytes se firmaron. El proveedor hasheó una secuencia concreta de bytes. Puede ser el cuerpo de la petición a secas, o una marca de tiempo pegada al frente del cuerpo. Si tu framework parseó el JSON y te entregó un objeto, ya no tienes esos bytes y no puedes reconstruirlos de forma confiable. De eso trata la sección 3, y es la causa más común por un margen enorme.

Qué bytes de clave se usaron. La misma cadena de secreto puede interpretarse como texto UTF-8, como hex o como base64, y cada lectura produce una clave distinta. Lo mismo pasa con un secreto que arrastra un salto de línea extra que el cargador de configuración conservó. En esta dimensión se esconde un segundo fallo: puede que el secreto sea directamente el secreto equivocado, y no una lectura equivocada del correcto, que es la sección 6.

En qué codificación comparaste. Un digest son 32 bytes crudos en SHA-256. Hex y base64 son dos maneras de escribir esos mismos bytes como texto, y nunca se parecen. Compara una contra la otra y obtienes un hmac signature mismatch permanente aunque los bytes de fondo coincidan.

Qué algoritmo de hash se ejecutó. Casi todos los proveedores usan SHA-256 y lo documentan, así que esta dimensión no suele costarte nada. GitHub es la excepción que conviene conocer: cada entrega trae X-Hub-Signature (HMAC-SHA1) al lado de X-Hub-Signature-256 (HMAC-SHA256), y la propia documentación de GitHub dice que la cabecera SHA-1 «se incluye únicamente por motivos de compatibilidad con lo anterior» mientras recomienda la variante 256. Lee la equivocada y la longitud te delata antes que los bytes. El cuerpo de la sección 2, firmado con el mismo secreto bajo SHA-1, da sha1=ba2954d180839d8170b08b32cd38483775aaae96: 40 caracteres hex frente a los 64 de su digest SHA-256.

Mantén esas cuatro dimensiones separadas mientras depuras. La forma más rápida de aislar una dimensión es calcular el digest fuera de tu aplicación, con entradas que tú controlas: pega un cuerpo y un secreto en el generador HMAC y mira qué sale. Corre por completo en tu navegador y el secreto nunca sale de la página, así que puedes pegar ahí un secreto de firma de producción sin riesgo. HMAC ejecuta la misma primitiva SHA-256 que un hash SHA-256 simple, solo que con tu secreto como llave, así que si logras reproducir a mano el valor del proveedor, la criptografía está bien y el bug está en el manejo de la petición.

2. Qué firma cada uno de los cuatro grandes proveedores

La suposición que hunde a la mayoría de las integraciones es que todo proveedor firma el cuerpo de la petición y nada más. Dos de los cuatro más grandes no lo hacen. Esto es lo que cada uno hashea en realidad, verificado contra la documentación vigente de cada proveedor:

ProveedorCabeceraCadena firmadaCodificaciónPrefijo del valorSecretoTolerancia de la marca de tiempo
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…secreto de firma del endpoint (prefijo whsec_)5 minutos (300 segundos)
GitHubX-Hub-Signature-256rawBody (sin prefijo)hexsha256=token secreto del webhookninguna (no envía marca de tiempo)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=secreto de firma (signing secret)5 minutos
ShopifyX-Shopify-Hmac-SHA256rawBodybase64ningunoclient secret de la app (no un secreto de webhook aparte)ninguna

Esos cuatro cubren, de casualidad, tres ejes ortogonales. La cadena firmada es o bien el cuerpo solo o bien una concatenación con la marca de tiempo, y hasta el separador cambia: Stripe usa . y Slack usa :. La codificación es hex en tres casos y base64 en uno. El secreto viene de una credencial de webhook dedicada en tres casos, y del client secret de la app en Shopify, que es el detalle que más gente confunde porque en el panel de administración hay un campo etiquetado «webhook» que no es el que necesitas. El patrón no termina en esos cuatro: en Latinoamérica, Mercado Pago también firma sus webhooks con HMAC, así que el razonamiento de esta sección aplica igual y lo único que cambia es la cadena exacta que firma, dato que está en su documentación.

Para volver tangibles las diferencias, aquí está un mismo cuerpo firmado de cuatro formas con un mismo secreto:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
FormaValor
Estilo GitHubsha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Estilo ShopifyCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Estilo Stripet=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Estilo Slackv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Lee las dos primeras filas juntas, porque son el mismo digest de 32 bytes escrito dos veces. Sesenta y cuatro caracteres hex, o cuarenta y cuatro caracteres base64 contando el relleno. Nada en esas dos cadenas sugiere que sean iguales, y por eso comparar entre codificaciones produce una discrepancia que sobrevive a todas las revisiones de «pero el secreto está bien» que se te ocurran.

Las dos últimas filas demuestran la otra mitad del punto. Mismo cuerpo, mismo secreto, mismo algoritmo, y ninguno de los dos digests se parece al de GitHub, porque la cadena que se hashea ahora empieza con una marca de tiempo. La mayoría de los reportes de un error stripe webhook signature verification failed se reducen a esa fila: el código hasheó el cuerpo por su cuenta y nunca puso delante el valor de t ni el punto. Reproduce las cuatro en el generador HMAC editando solo el campo del mensaje y cambiando el formato de salida, y el mecanismo deja de ser abstracto.

Una consecuencia práctica de la columna de tolerancia: un digest de Stripe o de Slack solo es válido unos minutos, así que no puedes capturar una firma hoy y reproducirla en una prueba mañana. Las firmas de GitHub y Shopify son estables para siempre, lo que las vuelve mucho más fáciles de depurar y también significa que la protección contra reenvío corre por tu cuenta.

3. El problema del cuerpo sin procesar

Tu framework ya destruyó los bytes

La mayoría de los reportes de webhook signature verification failed terminan aquí. Los frameworks web están hechos para ahorrarte el parseo, y esa comodidad es justo lo que rompe la verificación de firma: cuando tu handler se ejecuta, los bytes originales ya no existen.

express.json() lee el stream de la petición, lo parsea y reemplaza req.body con un objeto de JavaScript. El stream queda consumido y no se puede volver a leer. En FastAPI, declarar un modelo Pydantic o un parámetro de cuerpo dict implica que el framework lee y parsea antes de entrar a tu función. Rails llena params desde el cuerpo JSON mediante un middleware que corre antes de la acción de tu controlador. El conversor Jackson de Spring convierte el cuerpo en tu clase DTO y, por defecto, el input stream subyacente de HttpServletRequest solo se puede leer una vez.

Nada de esto es un bug. Cada uno hace exactamente lo que se configuró para hacer. El problema es que una firma cubre bytes, un objeto no son bytes, y convertir el objeto de vuelta a bytes es una operación distinta de la que ejecutó el proveedor.

Por qué a veces reserializar funciona

El consejo habitual es que reserializar cambia los bytes. Está incompleto, y la mitad que falta es lo que vuelve tan difícil de diagnosticar este fallo. A veces no cambia absolutamente nada.

Esto es JSON.stringify(JSON.parse(body)) === body medido sobre distintas formas de payload:

Forma del payloadBytes tras el round-tripCambio
{"id":42,"event":"user.created"}idénticosninguno, y por eso las pruebas locales pasan
{"amount":1.0}cambian{"amount":1}
{"n":1e3}cambian{"n":1000}
{"id":12345678901234567890}cambian{"id":12345678901234567000} (se pierde precisión)
{"name":"caf\u00e9"}cambian{"name":"café"} (6 bytes se vuelven 2)
{"a":1}\ncambianse traga el salto de línea final
{ "a" : 1 }cambianse traga los espacios interiores
{"v":-0.0}cambian{"v":0}
{"p":0.1000000000000000055511151231257827}cambian{"p":0.1}

Mira la primera fila. Un objeto plano con un entero y una cadena ASCII corta hace round-trip byte por byte, así que un verificador que parsea y vuelve a serializar pasa todas las pruebas que escribiste contra un fixture así. Luego despliegas, y falla el primer payload que trae un monto de 1.0, un ID más allá de 2^53 o un nombre de cliente con acento. Fallan solo esos, no todas las entregas.

Ese es el mecanismo detrás de «funciona en local, 401 intermitente en producción», y es bastante peor que un verificador que falla todo el tiempo. Un verificador que siempre falla se arregla en una hora. Uno que falla en el 3 % de los eventos se le achaca al proveedor, se reintenta, se escala y se aguanta durante semanas. Si tu tasa de fallos está estrictamente entre cero y cien por ciento, esta tabla es el primer lugar donde mirar.

El orden de las claves es la causa que la gente espera y la menos probable en la práctica, porque JSON.parse preserva el orden de inserción de las claves de tipo string. Los números y los espacios en blanco son los culpables reales.

Cómo obtener el cuerpo sin procesar en cada framework

Express, con el parser específico de la ruta registrado antes del parser JSON global:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Esta ruta debe registrarse ANTES de app.use(express.json()).
// body-parser marca la petición como parseada, así que un raw() posterior devuelve {} en silencio.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // un Buffer, no un objeto
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // hashea el Buffer directamente, sin toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // el resto de las rutas sigue recibiendo JSON parseado
app.listen(3000);

Si no puedes reordenar los middlewares, guarda una copia durante el parseo:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. Starlette guarda el cuerpo en caché, así que await request.body() devuelve los bytes originales incluso en un handler que también recibe un modelo parseado:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # bytes, exactamente como llegaron
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, donde request.raw_post te da el cuerpo sin parsear como string:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, donde lees el cuerpo tú mismo y debes recordar que después queda vacío:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Deserializa desde raw, nunca desde r.Body: ya no le quedan bytes.
	w.WriteHeader(http.StatusOK)
}

Spring, donde pedir byte[] se salta Jackson por completo:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper es la alternativa cuando la comprobación tiene que hacerla un filtro y no puedes cambiar la firma del controlador. Trae su propia trampa: getContentAsByteArray() devuelve bytes solo después de que algo más abajo haya leído el stream, así que llamarlo antes de chain.doFilter(...) te da un arreglo vacío.

4. Discrepancias de codificación: hex, base64 y la propia clave

Entre tu digest y el valor de la cabecera hay tres decisiones de codificación distintas, y cualquiera de ellas puede romper la comparación por su cuenta.

La codificación del digest. La salida de HMAC-SHA256 son 32 bytes. Escritos en hex minúscula son 64 caracteres; escritos en base64 estándar son 44 contando el relleno =. Las dos filas de la sección 2 lo muestran de golpe:

CodificaciónCaracteresLos mismos 32 bytes escritos como
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Una heurística rápida cuando tienes al frente una cabecera desconocida: si el valor son 64 caracteres de 0-9a-f, es hex. Si son 44 caracteres que terminan en =, o si contiene +, / o letras mayúsculas, es base64. Cuando quieras confirmarlo en vez de adivinar, pasa el valor base64 por el decodificador Base64 y verifica que dé 32 bytes; si los da, ambas cadenas describen el mismo digest y lo que estabas comparando eran formatos de texto, no firmas.

El prefijo del valor. GitHub manda sha256= delante del hex. Slack manda v0=. Stripe envuelve todo en una lista de pares key=value separados por comas. Ninguno de esos caracteres forma parte del digest, así que o le quitas el prefijo a la cabecera o se lo agregas a tu propio valor. No hacer ninguna de las dos es la razón más común por la que una implementación correcta reporta un hmac signature mismatch y, en Node, ni siquiera reporta una discrepancia, como explica la sección 7.

La codificación de la clave. El secreto también son bytes, y la misma cadena leída como UTF-8, hex o base64 da tres claves distintas. Los proveedores que te entregan un token de texto tipo whsec_... esperan UTF-8, pero un montón de sistemas internos reparten secretos en base64 o hex que hay que decodificar antes de firmar. Este modo de fallo tiene la misma forma que la versión JWT del problema, y JWT invalid signature: todas las causas y cómo corregirlas lo cubre a fondo, incluido cómo saber si un secreto dado es base64 o texto plano.

5. Marca de tiempo, tolerancia y ventanas de reenvío

Puedes calcular un digest que coincida a la perfección y aun así ser rechazado. Los proveedores que incluyen una marca de tiempo esperan que la revises, y una marca vencida es una firma válida que tienes que rechazar igual.

ProveedorDónde vive la marca de tiempoVentana
Stripet= dentro de Stripe-Signature5 minutos (300 segundos)
Slackcabecera X-Slack-Request-Timestamp5 minutos
GitHubno se envíano aplica
Shopifyno se envíano aplica

Equivocarse con la ventana duele en ambas direcciones. Demasiado generosa, y una petición capturada sigue siendo reproducible todo el tiempo que se lo permitas, lo que elimina casi todo el sentido de revisar la marca de tiempo. Demasiado estrecha, y la deriva normal del reloj empieza a rechazar entregas reales. Cinco minutos es lo que eligieron ambos proveedores, y copiarlo es un valor por defecto sensato.

Antes de ampliar una tolerancia, revisa el reloj. Las imágenes de contenedor no corren NTP, y una VM restaurada desde un snapshot puede quedar minutos atrás de la hora real sin nada en los logs que lo indique. Un host que se desvía de forma sostenida produce fallos que empiezan siendo ocasionales y terminan siendo totales, lo cual se lee como una regresión de código y no lo es.

El otro bug de reloj es una discrepancia de unidades. Todos los proveedores de la tabla envían segundos de epoch. Compara uno contra un valor en milisegundos como el Date.now() de JavaScript y la diferencia es unas mil veces la antigüedad real, así que todo evento queda fuera de cualquier ventana plausible. El síntoma es una comprobación de tolerancia que rechaza el cien por ciento de las entregas mientras el digest en sí coincide. Si no tienes claro qué unidad traes en la mano, la longitud te lo dice, y segundos de epoch frente a milisegundos cubre las conversiones y las trampas de zona horaria alrededor.

Usa la cadena de marca de tiempo tal como viene en la cabecera cuando construyas la cadena firmada, no un número parseado y reformateado. Parsear 1700000000 a float e imprimirlo de vuelta puede dar 1700000000.0, y esa es una secuencia de bytes distinta.

6. Secreto equivocado, y secretos que rotan

Antes de meterte más adentro de las codificaciones, descarta la causa más simple: puede que el secreto no sea el que corresponde. La documentación de Stripe es explícita en que «Stripe genera una clave secreta única para cada endpoint» y en que, si apuntas la misma URL a las claves de prueba y a las de producción, «el secreto es distinto para cada una». De ahí salen tres versiones del mismo error.

El modo de prueba y el de producción tienen secretos separados, así que un valor copiado mientras el panel estaba en modo de prueba falla en todas las entregas de producción. Cada endpoint tiene el suyo, y la documentación añade que «si usas varios endpoints, tienes que obtener un secreto para cada uno en el que quieras verificar firmas»: apunta dos endpoints al mismo handler con un solo secreto en el entorno y la mitad de tu tráfico falla. Y stripe listen imprime un secreto de firma para el reenvío local de la CLI, que es un endpoint aparte de cualquiera registrado en el panel, así que los dos no son intercambiables.

Ninguno de estos casos parece un bug de codificación desde fuera. El digest está bien formado, la comparación es correcta y el valor en tu entorno es un secreto real de Stripe, solo que no el que firmó esta entrega.

La rotación es esa misma dimensión moviéndose bajo tus pies. Es el fallo que menos se parece a un problema de codificación y el que más se diagnostica mal como bug de código. Nada cambió en tu código, la verificación funcionaba ayer, y ahora falla una fracción de los eventos.

La ventana de solapamiento es intencional. Stripe mantiene válido el secreto anterior del endpoint hasta 24 horas después de que rotas, y durante ese periodo la cabecera Stripe-Signature lleva una firma v1 por cada secreto activo. Shopify va al revés: tras la rotación puede tardar hasta una hora en empezar a usar el secreto nuevo para calcular digests, así que mientras tanto el que necesitas es el viejo.

El comportamiento de Stripe es el que rompe código, porque la cabecera parece traer una sola firma. Partir por , y tomar el primer v1 que encuentres funciona hasta que hay dos, momento en el que aciertas más o menos la mitad de las veces según qué secreto firmó qué evento. Itera sobre todas:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // conserva la cadena original
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Dos detalles ahí importan más allá del bucle. La marca de tiempo entra al payload firmado como la cadena que llegó, y el cuerpo se concatena como bytes en vez de pasar por interpolación de plantillas, que lo decodificaría primero como UTF-8.

La misma forma aplica cuando rotas de tu lado: acepta el secreto viejo y el nuevo durante lo que dure el solapamiento, y luego descarta el viejo. Lo que uses como reemplazo necesita entropía completa, así que genéralo en vez de escribirlo, con algo como el generador de secretos de firma para un valor aleatorio de 256 bits.

7. Comparar firmas sin filtrar información de tiempo

Una vez que tienes dos digests, cómo los comparas es una decisión de seguridad. La igualdad de strings retorna en cuanto encuentra un byte distinto, así que el tiempo que tarda revela cuántos bytes iniciales eran correctos. Un atacante que pueda enviar muchas peticiones usa eso para recuperar una firma válida byte por byte. Es lento y ruidoso por internet, y perfectamente práctico en una red local.

Todo runtime trae una comparación de tiempo constante:

LenguajeComparación de tiempo constanteCuando las longitudes difieren
Nodecrypto.timingSafeEqual(a, b)lanza excepción
Pythonhmac.compare_digest(a, b)devuelve False
Gohmac.Equal(a, b)devuelve false
PHPhash_equals($known, $user)devuelve false
RubyOpenSSL.secure_compare(a, b)devuelve false

Esa última columna es de donde sale toda una clase de incidentes confusos. Node es la excepción, y no falla con elegancia:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Piensa cuándo se dispara eso. Un digest SHA-256 en hex son 64 caracteres. El valor de X-Hub-Signature-256 tiene 71, porque sha256= son siete caracteres. Olvida quitar el prefijo y los dos buffers tienen longitudes distintas, así que timingSafeEqual lanza excepción en lugar de devolver false. Sin capturar, esa excepción se propaga fuera de tu handler y Express la convierte en un 500.

Estás buscando una respuesta webhook 401 unauthorized y te sale un error de servidor, así que te vas a leer tu handler, tu llamada a la base de datos, tu despachador de eventos. El bug real está una línea arriba de la comparación. Comparar un digest hex de 64 caracteres contra uno base64 de 44 lanza excepción por la misma razón, lo que significa que en Node una discrepancia de codificación también aparece como un 500 y no como un rechazo limpio.

El arreglo es revisar la longitud tú mismo y devolver false:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // protege antes de la llamada
  return crypto.timingSafeEqual(a, b);
}

Filtrar la longitud es inofensivo; la longitud de un digest la fija el algoritmo y es pública. Lo que no debes filtrar es qué prefijo coincidió. La pestaña de verificación del generador HMAC incorpora la diferencia de longitud al mismo acumulador de tiempo constante en lugar de retornar antes, así que una longitud distinta vuelve como un false normal y no como una excepción, y puedes revisar un valor de cabecera contra tu digest calculado sin escribir código desechable.

8. Cuando la capa de transporte cambió tus bytes

Ya descartaste la cadena firmada, el cuerpo sin procesar, las codificaciones, el reloj y la rotación. Lo que queda es la posibilidad de que los bytes que llegan a tu proceso no sean los bytes que salieron del proveedor.

Compresión. Un proveedor o un proxy puede enviar el cuerpo comprimido con Content-Encoding: gzip. La firma cubre el payload sin comprimir, así que tienes que hashear después de descomprimir. Algunos frameworks descomprimen de forma transparente y otros te entregan los bytes comprimidos; si en tu log el cuerpo parece basura binaria, ahí está la pista.

Transferencia por fragmentos. Con Transfer-Encoding: chunked no hay Content-Length, y el código que confía en esa cabecera para dimensionar un buffer de lectura trunca el cuerpo. El digest de un cuerpo truncado es un sinsentido válido: nunca va a coincidir, y nada se ve mal.

Proxies y WAF. Cualquier capa que lea y reescriba el cuerpo puede cambiarlo. AWS API Gateway puede codificar el cuerpo en base64 antes de que llegue a un Lambda, así que hay que decodificar antes de hashear. Los balanceadores de carga de aplicación, los service meshes y los firewalls de aplicaciones web también normalizan o recodifican payloads. Pruébalo comparando la longitud en bytes que ve tu handler contra el Content-Length que envió el proveedor.

Codificación de caracteres y BOM. Los payloads pueden contener caracteres no ASCII, y la documentación de GitHub es explícita en que el payload debe manejarse como UTF-8. Decodificar el cuerpo a string con el charset equivocado y volverlo a codificar destruye todos los caracteres multibyte. Una marca de orden de bytes UTF-8, EF BB BF, puesta al frente por un editor o serializador bienintencionado agrega tres bytes que nunca se firmaron.

Fin de línea y espacios sueltos. Un cuerpo que cruzó una frontera de archivo en modo texto puede llegar con LF reescrito como CRLF. Lee también la especificación del proveedor para la cadena de firma exacta: algunos agregan un carácter propio, y Typeform es un caso documentado en el que un salto de línea final forma parte de lo que se hashea. Cuando la documentación de un proveedor menciona cualquier carácter extra, tómalo literalmente.

9. Un flujo de depuración repetible

Ejecuta esto en orden. Cada paso encuentra el bug o elimina una rama, y detenerse pronto es justamente el punto.

  1. Registra los bytes crudos antes de que corra cualquier middleware. Escribe el cuerpo a un archivo, o registra su longitud en bytes más su SHA-256, desde el punto más temprano del ciclo de vida de la petición al que puedas llegar. La longitud por sí sola resuelve una cantidad sorprendente de casos: un valor una unidad mayor de lo esperado es un salto de línea final; tres mayor es un BOM.
  2. Calcula el digest a mano. Pega esos bytes exactos y tu secreto en el generador HMAC, elige SHA-256 y ajusta el formato de salida para que coincida con la cabecera. Este es el paso de mayor valor, porque parte el problema limpiamente en dos.
  3. Compara el valor calculado a mano con la cabecera. Iguales significa que los bytes y el secreto son correctos y el bug está en algún punto de tu ruta de código, así que ve a leer tu comparación. Distintos significa que una de las entradas está mal, así que continúa.
  4. Contrasta la cadena firmada con la tabla de la sección 2. ¿Este proveedor pone una marca de tiempo delante? ¿Con qué separador? Agrega el prefijo en la herramienta y recalcula.
  5. Cambia la codificación del digest. Recalcula en hex y en base64 y compara ambos contra la cabecera. Un valor de cabecera de 44 caracteres con un = al final es base64, sin importar qué supuso tu código.
  6. Cambia la codificación de la clave. Prueba el secreto como texto, luego como hex, luego como base64. Una de las tres suele producir una coincidencia, y eso te dice qué espera el proveedor.
  7. Revisa el reloj y el estado de la rotación. Compara la hora de tu servidor contra una fuente conocida, confirma que estás manejando segundos de epoch y revisa el panel del proveedor por si hubo una rotación en las últimas 24 horas.

Dos hábitos aceleran mucho este bucle. Primero, captura un payload que falle y trabaja con él offline en vez de esperar la siguiente entrega. Segundo, reenvía ese cuerpo capturado a tu endpoint con una firma fija para que la entrada nunca varíe entre intentos. El generador de comandos cURL arma la petición con las cabeceras exactas y un cuerpo leído desde un archivo, lo que mantiene los bytes estables entre corridas. Poder reproducir el fallo a voluntad es lo que convierte un reporte intermitente de webhook signature verification failed en un bug que se arregla de una sentada.

Si aun así necesitas abrir un ticket de soporte, incluye la longitud en bytes del cuerpo que hasheaste, el valor textual de la cabecera, la construcción de la cadena firmada que usaste y la codificación del digest. Nunca incluyas el secreto en sí.

FAQ

¿Por qué mi firma de webhook funciona en local pero falla en producción?

Tu payload de prueba probablemente sobrevive un round-trip de JSON sin cambios, así que reserializarlo es inofensivo. Los payloads reales contienen floats, enteros grandes, escapes Unicode o espacios extra, y esos sí cambian los bytes. Firma el cuerpo sin procesar en vez de una copia reserializada; la tabla de la sección 3 muestra qué formas se rompen.

¿Debo incluir el prefijo sha256= al comparar firmas?

Quítalo, o agrégalo a tu propio valor para que ambas cadenas coincidan exactamente. Tu digest hex calculado tiene 64 caracteres y el valor de la cabecera tiene 71 con el prefijo. Algunas funciones de comparación devuelven false cuando las longitudes no coinciden, y el timingSafeEqual de Node lanza excepción en lugar de devolver false.

¿Puedo verificar la firma después de que mi framework parseó el JSON?

No de forma confiable. Reserializar reproduce los bytes originales solo en payloads sin floats, sin enteros más allá de 2^53, sin escapes Unicode y sin espacios extra. En el momento en que aparece uno el digest cambia, así que la verificación pasa en pruebas y falla en una fracción de los eventos de producción.

¿Por qué Stripe y GitHub producen firmas distintas para el mismo payload?

Porque hashean cadenas distintas. GitHub firma el cuerpo sin procesar a secas. Stripe firma la marca de tiempo, un . literal y luego el cuerpo, así que un mismo payload entregado en dos momentos distintos da dos digests distintos. Slack pone delante v0: y su propia marca de tiempo. Mismo algoritmo, entrada distinta.

¿Cuánto debe durar la tolerancia de la marca de tiempo?

Stripe y Slack usan cinco minutos, y ese valor sirve como punto de partida. Ventanas más cortas rechazan entregas legítimas en cuanto el reloj de tu servidor se desvía. Ventanas más largas amplían el periodo en que una petición capturada puede reenviarse. Sincroniza los relojes con NTP antes de relajar la tolerancia.

¿timingSafeEqual devuelve false cuando las longitudes difieren?

No. Node lanza RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Sin capturar, eso se vuelve un 500 en vez de un 401, lo que te manda a depurar tu handler y no la línea de arriba de la comparación. Compara las longitudes primero y devuelve false tú mismo.

¿Mi proveedor rotó el secreto, entonces por qué algunos webhooks siguen fallando?

Las ventanas de rotación se solapan. Stripe mantiene válido el secreto viejo hasta 24 horas y envía una firma v1 por cada secreto activo, así que el código que lee solo el primer v1 falla en aproximadamente la mitad de los eventos. Shopify puede tardar hasta una hora en empezar a usar el secreto nuevo.

Conclusión

La verificación es una comparación de bytes, así que webhook signature verification failed siempre se resuelve en un desacuerdo sobre bytes y no en algo criptográfico. Mantén separadas las dimensiones mientras depuras:

  • Qué bytes se firmaron. Captura el cuerpo sin procesar antes de que ningún parser lo toque. Nunca hashees un objeto reserializado, porque coincide lo suficiente para pasar tus pruebas y no lo suficiente para funcionar.
  • Qué bytes de clave se usaron. Las lecturas en texto, hex y base64 de un mismo secreto dan tres claves distintas.
  • En qué codificación comparaste. Hex son 64 caracteres, base64 son 44, y ambos describen los mismos 32 bytes.
  • Todo lo demás. El prefijo de la marca de tiempo, el prefijo del valor, la ventana de tolerancia, el solapamiento de la rotación y la capa de transporte, más o menos en ese orden de probabilidad.
  • Cómo comparaste. Protege la longitud y luego usa la función de tiempo constante de tu runtime.

Cuando necesites un valor confiable contra el que comparar, calcúlalo fuera de tu aplicación: pega el cuerpo y el secreto en el generador HMAC y deja que te diga qué lado está mal.

Etiquetas: webhook hmac api-security debugging authentication

Artículos relacionados

Ver todos los artículos