Основи безпеки
"No signatures found matching the expected signature" у Supabase
Вебхук Stripe в Edge Function Supabase пише "No signatures found matching the expected signature"? Причин дві. Не вимикайте перевірку.

Коротко
- "No signatures found matching the expected signature for payload" означає, що ваша функція перевірила підпис Stripe і він не збігся. Або тіло запиту змінили до перевірки, або signing secret не той, яким підписував Stripe.
- Прочитайте тіло через await req.text() і передайте цей рядок без змін у constructEventAsync. Потім порівняйте секрет whsec_, який тримає ваша функція, із секретом саме того ендпоінта, з якого надсилає Stripe.
- Не прибирайте помилку, пропускаючи перевірку. Вебхук має працювати з verify_jwt = false, тож підпис єдине, що не дає сторонній людині сказати вашому застосунку, що оплата пройшла.
Ви підключили Stripe до свого застосунку, тестова оплата пройшла, а вебхук, який мав позначити замовлення як оплачене, нічого не зробив. Панель Stripe показує невдалу доставку, а в логах вашої Edge Function у Supabase написано таке:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Добра новина в тому, звідки береться ця помилка. Її кидає та частина вашої функції, яка перевіряє, що запит справді прийшов від Stripe, тобто ця перевірка існує й робить свою роботу. "No signatures found matching the expected signature for payload" має дві причини, і жодну не виправити, перевіряючи менше. Виправлення, яке першим трапляється в пошуку, загорнути перевірку так, щоб помилку ігнорувати, якраз і дає будь-кому позначити замовлення як оплачене.
Що означає "No signatures found matching the expected signature"?
Ваша функція перевірила підпис Stripe на запиті, і він не збігся.
Уявіть кожен вебхук від Stripe як лист, закритий восковою печаткою. Stripe
притискає печатку штампом, який є лише у Stripe і у вас: signing secret вашого
ендпоінта, значення, що починається з whsec_. Коли лист приходить, ваша
функція притискає свою копію штампа до воску. Якщо візерунки збігаються, лист
від Stripe і дорогою його ніхто не чіпав.
Печатка покриває лист точно таким, яким його написали. Бібліотека Stripe бере
текст запиту, час із заголовка Stripe-Signature і ваш секрет, обчислює, яким
має бути підпис, і шукає його в цьому заголовку. Повідомлення вище записане
у самому stripe-node,
і саме його він кидає, коли там нічого не збігається.
Сторінка Stripe про помилки підпису
каже прямо: щонайменше одне з трьох значень, які ви передали, хибне.
Отже, дивитися треба у два місця. Або лист змінили до того, як ваша функція перевірила печатку, або ваша функція тримає не той штамп.
Причина 1: тіло змінили до перевірки
В Edge Function найчастіший варіант такий: тіло спершу прочитали як JSON, а для перевірки знову перетворили на текст.
const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)
Виглядає природно й щоразу не працює. req.json() зберігає дані й викидає
текст: пробіли, переноси рядків, порядок, у якому Stripe усе записав.
JSON.stringify потім пише новий текст із тими самими даними всередині. Печатку
притиснули до тексту Stripe, а це копія, написана кимось іншим іншим почерком,
тож вона ніколи не збіжиться.
Власний приклад вебхука Stripe від Supabase робить інакше: читає тіло один раз, як текст, і передає цей рядок без змін:
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,
)
Коментар у цьому прикладі пояснює чому: перевірка спирається на сире тіло
запиту, а не на розібраний JSON. Розбирати щось самостійно потім не треба,
бо constructEventAsync повертає вже розібрану подію, щойно печатка зійдеться.
Те саме буває й поза вашим кодом. Сторінка Stripe називає фреймворки, які розбирають або переформатовують тіло ще до того, як його побачить ваш код, а повідомлення stripe-node додає, що інструмент, який пересилає вам вебхуки, має зберігати тіло точно, разом із форматуванням JSON і видом переносів рядків. Якщо між Stripe і вашою функцією стоїть проксі чи сервіс пересилання, перевірте, що він передає тіло точно таким, яким воно прийшло.
Причина 2: signing secret не той
Якщо тіло ціле, хибний штамп, і сторінка Stripe називає це найчастішою причиною з усіх.
На місці signing secret можуть опинитися три різні значення, і вони схожі:
- Секрет іншого ендпоінта.Посібник Stripe з вебхуків каже, що кожен ендпоінт отримує власний секрет і що та сама адреса, налаштована в тестовому й живому режимі, має окремий секрет у кожному. Жива оплата, перевірена секретом тестового ендпоінта, не пройде, і навпаки так само.
- Секрет, який друкує
stripe listen. Коли ви пересилаєте події на свій комп'ютер інструментом командного рядка Stripe, він друкує власний секретwhsec_. Stripe каже, що він відрізняється від секрету ендпоінта в панелі й що їх плутають. Він працює для подій, які пересилає інструмент, і більше ні для чого. - Ваш API-ключ Stripe.
sk_live_…абоsk_test_…це ключ, яким ваша функція викликає Stripe. Це не signing secret, і перевірка з ним ніколи не збіжиться.
Щоб знайти правильний, відкрийте ендпоінт у панелі Stripe у Workbench, далі
Webhooks, і натисніть Reveal secret. Спершу перевірте, у якому ви
режимі. Потім порівняйте його з тим, який читає ваша функція. У прикладі вище
це STRIPE_WEBHOOK_SIGNING_SECRET, заданий на сторінці
Edge Function Secrets вашого проєкту Supabase або через
supabase secrets set.
Раз уже ви там, перевірте ще одне. Якщо значення вставили з пробілом чи переносом рядка в кінці, stripe-node додає до тієї самої помилки примітку: "The provided signing secret contains whitespace." Видаліть значення й вставте його знову, нічого не лишаючи після нього.
Повідомлення, які схожі, але означають інше
stripe-node кидає з тієї самої перевірки кілька помилок, і кожна вказує на іншу проблему. Усі вони записані у його коді вебхуків:
| Що пишуть ваші логи | Що сталося |
|---|---|
No signatures found matching the expected signature for payload | Тіло змінили, або секрет не той. Ця стаття. |
Webhook payload must be provided as a string or a Buffer | Ви передали розібраний об'єкт із req.json(). Передайте текст із req.text(). |
No stripe-signature header value was provided. | У запиті не було заголовка Stripe-Signature. Можливо, він узагалі прийшов не від Stripe. |
Timestamp outside the tolerance zone | Печатка збіглася, але подія старша за ліміт. Бібліотеки Stripe за замовчуванням дають 5 хвилин. |
SubtleCryptoProvider cannot be used in a synchronous context. | Ви викликали constructEvent. З провайдером Web Crypto, якого потребує Deno, беріть constructEventAsync. |
І одне, що до вашого коду взагалі не доходить. Якщо панель Stripe показує невдалу доставку з 401, а логи вашої функції порожні, платформа Supabase відхилила запит ще до запуску функції, через перевірку, яку для вебхука треба вимкнути.
Чому вашому вебхуку потрібен вимкнений verify_jwt
Бо Stripe не може довести, хто він, так, як це роблять користувачі вашого застосунку, і з увімкненою перевіркою кожну доставку відхиляють.
Supabase ставить перед кожною Edge Function перевірку verify_jwt, увімкнену
за замовчуванням. Вона вимагає токен Supabase у заголовку Authorization
запиту, а Stripe не має жодного, щоб надіслати.
Посібник Supabase з налаштування функцій
бере саме це як приклад, кажучи, що вебхуки Stripe мають бути публічно
доступними, і вимикає перевірку для цієї однієї функції в
supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
Команда supabase functions deploy приймає --no-verify-jwt для того самого.
Приклад Supabase для Stripe містить ту саму вказівку в коментарі: Stripe
перевіряє запит через його підпис, тож розгортайте з verify_jwt = false.
Коли перевірку платформи вимкнено, до вашого коду доходить будь-який запит з інтернету, і перевірка печатки у вашій функції єдине, що відрізняє листи Stripe від листів будь-кого іншого.
Чому перевірку підпису не можна пропускати ніколи
Бо без неї будь-хто може сказати вашому застосунку, що оплата пройшла.
Адреса функції вебхука це URL вашого проєкту, який будь-хто може прочитати у
вашому фронтенді, далі /functions/v1/ і назва функції, зазвичай очевидна.
Приклад із посібника з налаштування сам називає її stripe-webhook. З вимкненим
verify_jwt платформа пропускає до вашого коду кожен запит. Якщо ваш код більше
не перевіряє печатку, він вірить кожному листу, що прийшов.
Тож стороння людина надсилає вашій функції запит із
checkout.session.completed і замовленням або клієнтом на свій вибір. Ваша
функція робить те саме, що й зі справжнім: позначає замовлення оплаченим,
підвищує тариф облікового запису, відкриває завантаження. Ніхто не платив. У
Stripe немає жодного запису про це, бо Stripe його ніколи не надсилав.
Три способи пропустити перевірку закінчуються однаково:
- Видалити її, бо вона не проходила.
- Перехопити помилку й піти далі, записавши збій у лог і все одно обробивши подію. Приклад повертає 400 зі свого блоку catch, і саме цю форму варто лишити: запит, що не пройшов перевірку, на цьому й зупиняється.
- Поставити допуск за часом на 0. Посібник Stripe з вебхуків радить так не робити: 0 повністю вимикає перевірку віку, тож справжню подію, яку хтось перехопив, можна надіслати знову пізніше.
Активний секретний ключ Stripe у вашому фронтенді це інший шлях, яким оплати йдуть не так, і його ми бачимо ззовні: ми знайшли такий у 3 з 30 998 просканованих застосунків. Наше безкоштовне сканування читає JavaScript вашого опублікованого застосунку так, як прочитала б стороння людина, і називає кожен ключ, який знаходить, уточнюючи, чи має він там бути. Це займає близько 20 секунд і не потребує облікового запису: проскануйте свій застосунок.
Якщо підроблена оплата колись пройде
Перевірка, яку прибрав промпт, про себе не повідомляє. Білдер, якого ви просите знову запустити вебхук, може піти коротшим шляхом вище, і ваш застосунок тим часом працюватиме далі.
Reeve Monitor стежить за тим, що стороння людина бачить ззовні:
- усі девʼять перевірок щогодини, для трьох застосунків
- лист того дня, коли нове сканування оцінить ваш застосунок гірше за попереднє; секретний ключ у вашому JavaScript сам обмежує оцінку рівнем D
- чи застосунок доступний, кожні 60 секунд
- щомісячний звіт про те, що він бачив
Підроблена подія пише у вашу базу даних так само, як справжня. Reeve Care тримає копію вашої бази Supabase з часу до неї.
- зашифрована копія за розкладом, у власному сховищі Reeve в ЄС, поза вашим обліковим записом Supabase
- кожна копія перевірена, перш ніж її зарахують, підрахунком рядків у кожній таблиці
- відновлення в один клік, яке зберігає те, що є зараз, перш ніж замінити
- ваші завантажені файли теж, щойно ви приєднаєте доступ до Storage
- усе, що робить Monitor
Що зробити зараз
Що робити
- Відкрийте невдалу доставку в панелі Stripe і вкладку Logs вашої функції та прочитайте точне повідомлення, перш ніж щось змінювати.
- Читайте тіло через
await req.text()і передавайте цей рядок уconstructEventAsyncбез змін. Ніколи не перевіряйте подію, пропущену черезJSON.stringify. - Скопіюйте секрет
whsec_ендпоінта, з якого надсилає Stripe, у правильному режимі, і вставте в Edge Function Secrets, нічого не лишаючи після нього. - Лишіть
verify_jwt = falseна вебхуку, а перевірку підпису першим, що робить функція. - Повертайте 400, коли перевірка не проходить. Ніколи не перехоплюйте помилку, щоб усе одно обробити подію.
Решта того, що потрібно функції, яка тримає секрет, є в статті як перенести секрет в Edge Function, а якщо у вас загальна помилка від supabase-js, як прочитати код стану non-2xx пояснює, де справжнє повідомлення.
Поширені запитання
Що означає "No signatures found matching the expected signature for payload"?
Ваша функція вебхука обчислила підпис, який очікувала, з тіла запиту й вашого signing secret, і жоден підпис у заголовку Stripe-Signature з ним не збігся. Бібліотека Stripe кидає цю помилку, коли тіло, яке вона отримала, не збігається байт у байт із тим, що надіслав Stripe, або коли секрет не належить ендпоінту, який надіслав подію.
Чому не працює, якщо передати JSON.stringify події?
Бо підпис покриває точний текст, який надіслав Stripe, разом із пробілами й переносами рядків. Якщо прочитати тіло через req.json() і знову перетворити на текст через JSON.stringify, ви отримаєте ті самі дані в іншому тексті, а інший текст має інший підпис. Прочитайте тіло один раз через await req.text() і передайте цей рядок у constructEventAsync.
Який signing secret брати?
Секрет whsec_ саме того ендпоінта, на який доставляє Stripe. Кожен ендпоінт має свій, та сама адреса, налаштована в тестовому й живому режимі, має окремий секрет у кожному, а команда stripe listen друкує власний секрет, що працює лише для подій, які вона пересилає. Ваш API-ключ sk_ узагалі не signing secret.
Чи має моя Edge Function для вебхука Stripe мати вимкнений verify_jwt?
Так. Stripe не має токена Supabase, який міг би надіслати, тож з увімкненою перевіркою кожну доставку відхиляють із 401 ще до запуску вашого коду. Власний приклад Supabase для Stripe каже розгортати його з verify_jwt = false. Тоді перевірка підпису у вашій функції стає єдиною перевіркою взагалі, і її не можна ні пропускати, ні перехоплювати й ігнорувати.
Чи можна прибрати перевірку підпису, щоб оплати запрацювали?
Можна, і тоді будь-хто, хто знайде адресу функції, зможе надіслати їй подію про те, що оплата пройшла. Адреса це URL вашого проєкту, який є у вашому фронтенді, плюс назва функції. Без перевірки ваша функція вірить кожному запиту й надає те, за що, згідно з подією, заплатили.