Saltar al contenido

Fundamentos de seguridad

"No signatures found matching the expected signature" en Supabase

¿Tu webhook de Stripe en una Edge Function de Supabase dice "No signatures found matching the expected signature"? Tiene dos causas. Sigue verificando.

Vlad Tkachenko10 min de lectura
Un sobre cerrado con un sello de lacre turquesa y, al lado, un estampador cuyo dibujo no coincide con la marca, señalado en amarillo.

En resumen

  • "No signatures found matching the expected signature for payload" significa que tu función comprobó la firma de Stripe y no coincidió. O el cuerpo cambió antes de la comprobación, o el signing secret no es el que Stripe usó para firmar.
  • Lee el cuerpo con await req.text() y pasa ese texto, sin tocarlo, a constructEventAsync. Después compara el secreto whsec_ que tiene tu función con el del endpoint exacto desde el que envía Stripe.
  • No hagas desaparecer el error saltándote la comprobación. El webhook tiene que funcionar con verify_jwt = false, así que la firma es lo único que impide que un desconocido le diga a tu app que un pago se completó.

Conectaste Stripe a tu app, un pago de prueba salió bien y el webhook que debía marcar el pedido como pagado no hizo nada. El panel de Stripe muestra la entrega fallida, y los logs de tu Edge Function de Supabase dicen esto:

No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?

La buena noticia está en el origen del error. Lo lanza la parte de tu función que comprueba que la petición vino de verdad de Stripe, lo que significa que esa comprobación existe y está haciendo su trabajo. "No signatures found matching the expected signature for payload" tiene dos causas, y ninguna se arregla comprobando menos. El arreglo que aparece primero cuando alguien lo busca, envolver la comprobación para que el error se ignore, es el que permite a cualquiera marcar un pedido como pagado.

¿Qué significa "No signatures found matching the expected signature"?

Tu función comprobó la firma de Stripe en la petición, y no coincidió.

Piensa en cada webhook que envía Stripe como una carta cerrada con un sello de lacre. Stripe presiona el sello con un estampador que solo tienen Stripe y tú: el signing secret de tu endpoint, un valor que empieza por whsec_. Cuando llega la carta, tu función presiona su propia copia del estampador contra el lacre. Si los dibujos encajan, la carta es de Stripe y nadie la ha tocado por el camino.

El sello cubre la carta exactamente como se escribió. La librería de Stripe toma el texto de la petición, la hora del encabezado Stripe-Signature y tu secreto, calcula cómo debería ser la firma y la busca en ese encabezado. El mensaje de arriba está escrito en el propio stripe-node, y es lo que lanza cuando nada ahí coincide. La página de Stripe sobre errores de firma lo dice sin rodeos: al menos uno de los tres valores que pasaste está mal.

Así que hay dos sitios donde mirar. O la carta se cambió antes de que tu función comprobara el sello, o tu función tiene el estampador equivocado.

Causa 1: el cuerpo cambió antes de la comprobación

La versión más común en una Edge Function es un cuerpo que primero se leyó como JSON y luego se volvió a convertir en texto para la comprobación.

const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)

Se lee con naturalidad y falla siempre. req.json() se queda con los datos y tira el texto: los espacios, los saltos de línea, el orden en que Stripe escribió las cosas. JSON.stringify escribe luego un texto nuevo con los mismos datos dentro. El sello se presionó sobre el texto de Stripe, y esto es la copia de otra persona con otra letra, así que nunca puede coincidir.

Lo que hace el propio ejemplo de webhook de Stripe de Supabase es leer el cuerpo una sola vez, como texto, y pasarlo sin tocar:

const signature = req.headers.get('Stripe-Signature')
const body = await req.text()
const event = await stripe.webhooks.constructEventAsync(
  body,
  signature,
  Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET'),
  undefined,
  cryptoProvider,
)

El comentario del ejemplo explica por qué: la verificación depende del cuerpo crudo de la petición y no del JSON ya interpretado. No tienes que interpretar nada tú después, porque constructEventAsync devuelve el evento ya interpretado en cuanto el sello cuadra.

La firma cubre el texto que envió Stripe, no los datos que contiene. Un texto reescrito con los mismos datos no pasa la comprobación.

Lo mismo ocurre fuera de tu código. La página de Stripe menciona frameworks que interpretan o reformatean el cuerpo antes de que tu código lo vea, y el mensaje de stripe-node añade que una herramienta que te reenvía webhooks tiene que conservar el cuerpo exacto, incluidos el formato del JSON y el tipo de salto de línea. Si hay un proxy o un servicio de reenvío entre Stripe y tu función, comprueba que pase el cuerpo tal como llegó.

Causa 2: el signing secret no es el correcto

Si el cuerpo está intacto, el estampador es el equivocado, y la página de Stripe dice que esta es la causa más común de todas.

Tres valores distintos pueden acabar en el sitio del signing secret, y se parecen:

  • El secreto de otro endpoint. La guía de webhooks de Stripe dice que cada endpoint recibe su propio secreto y que la misma dirección configurada en modo de prueba y en modo live tiene un secreto distinto en cada uno. Un pago live comprobado con el secreto del endpoint de prueba falla, y al revés también.
  • El secreto que imprime stripe listen. Cuando reenvías eventos a tu máquina con la herramienta de línea de comandos de Stripe, imprime un secreto whsec_ propio. Stripe dice que es distinto del secreto del endpoint del panel y que es fácil confundirlos. Sirve para los eventos que reenvía la herramienta y para nada más.
  • Tu clave API de Stripe. sk_live_… o sk_test_… es la clave con la que tu función llama a Stripe. No es un signing secret, y una comprobación hecha con ella nunca puede coincidir.

Para encontrar el correcto, abre el endpoint en el panel de Stripe en Workbench y luego Webhooks, y pulsa Reveal secret. Antes, mira en qué modo estás. Después compáralo con el que lee tu función. En el ejemplo de arriba es STRIPE_WEBHOOK_SIGNING_SECRET, definido en la página Edge Function Secrets de tu proyecto de Supabase o con supabase secrets set.

Ya que estás ahí, revisa una cosa más. Si el valor se pegó con un espacio o un salto de línea al final, stripe-node añade una nota al mismo error: "The provided signing secret contains whitespace." Borra el valor y vuelve a pegarlo sin nada detrás.

Cada endpoint, cada modo y la herramienta de línea de comandos firman con su propio secreto. Solo el que firmó este evento puede comprobarlo.

Los mensajes que se parecen y significan otra cosa

stripe-node lanza varios errores desde la misma comprobación, y cada uno apunta a un problema distinto. Todos están escritos en su código de webhooks:

Lo que dicen tus logsLo que pasó
No signatures found matching the expected signature for payloadEl cuerpo cambió, o el secreto no es el correcto. Este artículo.
Webhook payload must be provided as a string or a BufferPasaste el objeto interpretado de req.json(). Pasa el texto de req.text().
No stripe-signature header value was provided.La petición no traía el encabezado Stripe-Signature. Puede que ni siquiera viniera de Stripe.
Timestamp outside the tolerance zoneEl sello coincidió, pero el evento es más antiguo que el límite. Las librerías de Stripe usan 5 minutos.
SubtleCryptoProvider cannot be used in a synchronous context.Llamaste a constructEvent. Con el proveedor Web Crypto que necesita Deno, usa constructEventAsync.

Y uno que nunca llega a tu código. Si el panel de Stripe muestra la entrega fallida con un 401 y los logs de tu función están vacíos, la plataforma de Supabase rechazó la petición antes de que tu función corriera, por una comprobación que en un webhook tiene que estar desactivada.

Por qué tu webhook necesita verify_jwt desactivado

Porque Stripe no puede demostrar quién es como lo hacen los usuarios de tu app, y con la comprobación activada cada entrega se rechaza.

Supabase pone delante de cada Edge Function una comprobación llamada verify_jwt, activada por defecto. Pide un token de Supabase en el encabezado Authorization de la petición, y Stripe no tiene ninguno que enviar. La guía de configuración de funciones de Supabase usa justo esto como ejemplo, diciendo que los webhooks de Stripe tienen que ser accesibles públicamente, y desactiva la comprobación para esa única función en supabase/config.toml:

[functions.stripe-webhook]
verify_jwt = false

El comando supabase functions deploy acepta --no-verify-jwt para lo mismo. El ejemplo de Stripe de Supabase lleva la misma instrucción en un comentario: Stripe verifica la petición mediante su firma, así que despliégalo con verify_jwt = false.

Con la comprobación de la plataforma desactivada, cualquier petición de internet llega a tu código, y la comprobación del sello dentro de tu función es lo único que distingue las cartas de Stripe de las de cualquier otro.

Por qué nunca deberías saltarte la comprobación de firma

Porque sin ella, cualquiera puede decirle a tu app que un pago se completó.

La dirección de una función de webhook es la URL de tu proyecto, que está en tu frontend para quien quiera leerla, seguida de /functions/v1/ y del nombre de la función, que suele ser el obvio. El ejemplo de la guía de configuración la llama stripe-webhook. Con verify_jwt desactivado, la plataforma deja pasar cada petición hasta tu código. Si tu código ya no comprueba el sello, se cree cada carta que recibe.

Así que un desconocido envía a tu función una petición que dice checkout.session.completed, con un pedido o un cliente elegido por él. Tu función hace lo que hace con una real: marca el pedido como pagado, sube de plan la cuenta, desbloquea la descarga. Nadie pagó. Stripe no tiene registro de ello, porque Stripe nunca lo envió.

Tres formas de saltarse la comprobación terminan ahí:

  • Borrarla porque no pasaba.
  • Capturar el error y seguir, registrando el fallo y procesando el evento de todos modos. El ejemplo devuelve un 400 desde su bloque catch, y esa es la forma que hay que mantener: una petición que no pasa la comprobación se queda ahí.
  • Poner la tolerancia de tiempo en 0. La guía de webhooks de Stripe dice que no se haga: 0 desactiva por completo la comprobación de antigüedad, así que un evento real que alguien capturó puede volver a enviarse más tarde.

Una clave secreta de Stripe activa en tu frontend es la otra forma en que los pagos salen mal, y es la que podemos ver desde fuera: encontramos una en 3 de 30.998 apps escaneadas. Nuestro escaneo gratuito lee el JavaScript de tu app publicada como lo haría un desconocido y nombra cada clave que encuentra, indicando si es de las que deben estar ahí. Tarda unos 20 segundos y no necesita cuenta: escanea tu app.

Si alguna vez se cuela un pago falso

Una comprobación que quitó un prompt no se anuncia. Un builder al que le pides que el webhook vuelva a funcionar puede tomar el atajo de arriba, y tu app sigue funcionando mientras tanto.

Reeve Monitor vigila lo que un desconocido puede ver desde fuera:

  • las nueve comprobaciones cada hora, en hasta tres apps
  • un correo el día en que un nuevo escaneo califica tu app peor que el anterior; una clave secreta en tu JavaScript limita por sí sola la nota a D
  • si la app está disponible, cada 60 segundos
  • un informe mensual de lo que vio

Un evento falso escribe en tu base de datos como uno real. Reeve Care guarda una copia de tu base de datos de Supabase de antes de que pasara.

  • una copia cifrada según un calendario, guardada en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • una restauración en un clic que guarda lo que hay ahora antes de reemplazarlo
  • también tus archivos subidos, en cuanto conectes una credencial de Storage
  • todo lo que hace Monitor

Qué hacer ahora

Qué hacer

  • Abre la entrega fallida en el panel de Stripe y la pestaña Logs de tu función, y lee el mensaje exacto antes de cambiar nada.
  • Lee el cuerpo con await req.text() y pasa ese texto a constructEventAsync sin tocarlo. Nunca compruebes un evento pasado por JSON.stringify.
  • Copia el secreto whsec_ del endpoint desde el que envía Stripe, en el modo correcto, y pégalo en Edge Function Secrets sin nada detrás.
  • Mantén verify_jwt = false en el webhook, y la comprobación de firma como lo primero que hace la función.
  • Devuelve un 400 cuando la comprobación falle. Nunca captures el error para procesar el evento igualmente.

Lo demás que necesita una función que guarda un secreto está en mover un secreto a una Edge Function, y si tu error es el genérico de supabase-js, leer un código de estado non-2xx explica dónde está el mensaje real.

Preguntas frecuentes

¿Qué significa "No signatures found matching the expected signature for payload"?

Tu función de webhook calculó la firma que esperaba a partir del cuerpo de la petición y de tu signing secret, y ninguna de las firmas del encabezado Stripe-Signature coincidió. La librería de Stripe lanza este error cuando el cuerpo que recibe no es, byte a byte, lo que Stripe envió, o cuando el secreto no es el del endpoint que envió el evento.

¿Por qué falla si paso JSON.stringify del evento?

Porque la firma cubre el texto exacto que envió Stripe, con sus espacios y saltos de línea. Si lees el cuerpo con req.json() y lo vuelves a convertir en texto con JSON.stringify, tienes los mismos datos en otro texto, y otro texto tiene otra firma. Lee el cuerpo una vez con await req.text() y pasa ese texto a constructEventAsync.

¿Qué signing secret uso?

El secreto whsec_ del endpoint exacto al que Stripe entrega. Cada endpoint tiene el suyo, la misma dirección configurada en modo de prueba y en modo live tiene un secreto distinto en cada uno, y el comando stripe listen imprime un secreto propio que solo sirve para los eventos que él reenvía. Tu clave API sk_ no es un signing secret.

¿Mi Edge Function de webhook de Stripe debe tener verify_jwt desactivado?

Sí. Stripe no tiene un token de Supabase que enviar, así que con la comprobación activada cada entrega se rechaza con un 401 antes de que tu código corra. El propio ejemplo de Stripe de Supabase dice que se despliegue con verify_jwt = false. Eso convierte la comprobación de firma dentro de tu función en la única que hay, así que nunca debe saltarse ni capturarse e ignorarse.

¿Puedo quitar la comprobación de firma para que funcionen los pagos?

Puedes, y entonces cualquiera que encuentre la dirección de la función podrá enviarle un evento que diga que un pago se completó. La dirección es la URL de tu proyecto, que está en tu frontend, más el nombre de la función. Sin la comprobación, tu función se cree cada petición que recibe y concede lo que el evento diga que se pagó.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

Haz una copia de tu base de datos de Supabase antes del próximo cambio

Reeve la guarda cifrada con regularidad y la restaura por ti si algo falla.

Ver copias de Supabase

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.