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.

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.
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 secretowhsec_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_…osk_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.
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 logs | Lo que pasó |
|---|---|
No signatures found matching the expected signature for payload | El cuerpo cambió, o el secreto no es el correcto. Este artículo. |
Webhook payload must be provided as a string or a Buffer | Pasaste 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 zone | El 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 aconstructEventAsyncsin tocarlo. Nunca compruebes un evento pasado porJSON.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 = falseen 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ó.