सभी लेख
webhook securityHMACsignature verificationbest practices

वेबहुक सिग्नेचर सत्यापन, बिना रहस्य के

अधिकांश वेबहुक सिग्नेचर सत्यापन विफलताएँ क्रिप्टो बग नहीं हैं। वे पार्सिंग बग हैं। किसी ने सत्यापन से पहले body पार्स कर लिया। किसी ने hex इस्तेमाल किया जबकि प्रोवाइडर base64 चाहता था। किसी ने === से तुलना की जबकि timing-safe तुलना चाहिए थी। यह गाइड हर उस सिग्नेचर समस्या का संक्षिप्त रूप है जिसे हमने कभी डिबग किया।

प्रोवाइडर जो मॉडल इस्तेमाल करते हैं

हर बड़ा वेबहुक प्रोवाइडर — Stripe, GitHub, Shopify, Twilio, Slack, Discord — मूलतः एक ही काम अलग-अलग रूप में करता है:

  1. आप और प्रोवाइडर एक secret साझा करते हैं।
  2. प्रोवाइडर रिक्वेस्ट body पर एक सिग्नेचर की गणना करता है (कभी timestamp के साथ, कभी URL के साथ)।
  3. सिग्नेचर एक हेडर में आता है।
  4. आपका हैंडलर उसी secret से सिग्नेचर दोबारा गणना करता है और मेल न खाने पर रिक्वेस्ट अस्वीकार करता है।

प्रोवाइडर के बीच जो बदलता है: एल्गोरिथम (HMAC SHA-256 सबसे आम; Discord Ed25519 इस्तेमाल करता है), एन्कोडिंग (hex बनाम base64), क्या साइन होता है (body, body+timestamp, URL+body+पैरामीटर), और कौन सा हेडर सिग्नेचर वहन करता है।

चार बग जो लगभग सब कुछ समेटते हैं

1. पहले body पार्स करना

अधिकांश फ़्रेमवर्क में एक JSON मिडलवेयर होता है जो आने वाले body को ऑब्जेक्ट में पार्स करता है। जैसे ही यह चलता है, raw bytes चले जाते हैं, और आपका गणना किया हुआ कोई भी सिग्नेचर मेल नहीं खाएगा — भले ही आप पार्स किए ऑब्जेक्ट को फिर से stringify करें, whitespace और key क्रम भिन्न हो सकते हैं।

समाधान: वेबहुक रूट पर किसी भी मिडलवेयर से पहले raw body पढ़ें। Express में express.raw({ type: 'application/json' }) केवल वेबहुक पाथ पर माउंट करें। Next.js App Router में await request.text() सीधे पढ़ें। Django में किसी भी DRF parser के चलने से पहले @csrf_exempt + request.body इस्तेमाल करें।

2. ग़लत एन्कोडिंग

Stripe और GitHub hex इस्तेमाल करते हैं। Shopify base64 इस्तेमाल करता है। एक ही secret हर एक में अलग दिखने वाला आउटपुट देता है। हमने इसे Shopify गाइड में कवर किया है — यह सबको परेशान करता है।

3. timing-safe न होना

स्ट्रिंग समानता (===) पहले बेमेल अक्षर पर जल्दी लौट आती है। "पहला अक्षर मेल खाया" और "सभी 64 अक्षर मेल खाए" के बीच का समय अंतर secret के बारे में जानकारी लीक करता है। timing-safe तुलना इस्तेमाल करें:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

व्यवहार में, सार्वजनिक इंटरनेट पर किसी वेबहुक एंडपॉइंट के विरुद्ध यह हमला करना कठिन है, पर सुरक्षित तुलना इतनी सस्ती है कि इसे न इस्तेमाल करने का कोई कारण नहीं।

4. timestamp सत्यापित न होना

यदि सिग्नेचर सिर्फ़ body को कवर करता है, तो एक मान्य रिक्वेस्ट को पकड़ने वाला हमलावर उसे हमेशा के लिए replay कर सकता है। प्रोवाइडर इसे सिग्नेचर पेलोड में timestamp शामिल करके और डॉक्स में एक सहनशीलता विंडो देकर हल करते हैं (Stripe: 5 मिनट, GitHub: भी छोटी)। आपके हैंडलर को सहनशीलता से पुराने timestamp वाले किसी भी सिग्नेचर को अस्वीकार करना चाहिए।

इस कदम को छोड़ें और आपने एक replay-असुरक्षित सिस्टम बना लिया, चाहे HMAC कितना ही सटीक काम करे।

हेडर में वास्तव में क्या है

प्रोवाइडर हेडर सिर्फ़ "सिग्नेचर" से अधिक वहन करते हैं। Stripe t=1234567890,v1=abcdef...,v0=... भेजता है — timestamp प्लस कई सिग्नेचर संस्करण। GitHub उपसर्ग के साथ sha256=abcdef... भेजता है। Twilio रिक्वेस्ट URL को साइनिंग पेलोड में शामिल करता है पर सिग्नेचर स्वयं केवल X-Twilio-Signature में भेजता है। जिस प्रोवाइडर के विरुद्ध आप इंटीग्रेट कर रहे हैं उसके डॉक्स पढ़ें, फिर सटीक फ़ॉर्मेट के विरुद्ध लागू करें।

लोकल टेस्टिंग इसे डिबग करने योग्य बनाती है

सिग्नेचर सत्यापन सबसे बुरी तरह का बग है: चुपचाप और स्टेजिंग पर दोबारा पैदा करना कठिन। टनल और रिक्वेस्ट कैप्चर के साथ लोकल टेस्टिंग आपको वह सटीक पेलोड देती है जो विफल हुआ, वह सटीक हेडर जो आया, और एक रीप्ले बटन ताकि प्रोवाइडर को परेशान किए बिना फ़िक्स दोबारा टेस्ट कर सकें।

जब सत्यापन किसी चीज़ को अस्वीकार करे, डिबग के लिए पर्याप्त लॉग करें: raw body की लंबाई, जो हेडर आपने पढ़ा, जो सिग्नेचर आपने गणना किया। secret को लॉग न करें। (हमने लोगों को secret लॉग करते देखा है। मत करें।)

एक न्यूनतम सही वेरिफ़ायर

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

जब सिग्नेचर सत्यापन फिर भी विफल हो

यदि ऊपर सब सही है और फिर भी विफल होता है, तीन और चीज़ें जाँचें: secret में आपकी env फ़ाइल से अंत का whitespace है, आपके टनल ने एक हेडर फिर से लिखा (अधिकांश नहीं करते, पर रिक्वेस्ट कैप्चर से पुष्टि करना सार्थक है), या आप live secret के विरुद्ध टेस्ट कर रहे हैं जबकि डैशबोर्ड test secret दिखाता है। हमने तीनों का सामना किया है।

प्रोवाइडर-विशिष्ट पेचों के लिए, Stripe, GitHub और Shopify गाइड देखें। या यदि आप अभी एक 401 घूर रहे हैं, तो वेबहुक 401 त्रुटियाँ डिबग करना पर जाएँ। इसे डिबग करने योग्य बनाने वाले टनल + कैप्चर के लिए PortPreview की वेटलिस्ट में शामिल हों

अक्सर पूछे जाने वाले प्रश्न

मेरा वेबहुक सिग्नेचर सत्यापन विफल क्यों हो रहा है?
अधिकांश बार सत्यापन चलने से पहले मिडलवेयर ने body पार्स कर लिया। अन्य आम कारण: hex बनाम base64 एन्कोडिंग का बेमेल, कुछ edge cases में विफल होने वाली non-timing-safe तुलना, या ग़लत secret का उपयोग (test बनाम live, dashboard बनाम CLI)।
क्या वेबहुक सिग्नेचर के लिए मुझे सचमुच timing-safe तुलना चाहिए?
हाँ। सादी स्ट्रिंग समानता secret के बारे में समय की जानकारी एक-एक बाइट लीक करती है। हमला व्यवहार में कठिन है पर सुरक्षित तुलना कोड की एक लाइन है, इसलिए इसे न इस्तेमाल करने का कोई कारण नहीं।
मेरे वेबहुक सिग्नेचर में timestamp क्यों होता है?
timestamp के बिना, एक मान्य साइन की गई रिक्वेस्ट को पकड़ने वाला हमलावर उसे हमेशा के लिए replay कर सकता है। timestamp आपके हैंडलर को प्रोवाइडर की सहनशीलता विंडो (आमतौर पर कुछ मिनट) से पुरानी किसी भी चीज़ को अस्वीकार करने देता है।