บทความทั้งหมด
webhook securityHMACsignature verificationbest practices

การตรวจสอบลายเซ็น Webhook แบบไม่ลึกลับ

ความล้มเหลวของการตรวจลายเซ็น Webhook ส่วนใหญ่ไม่ใช่บั๊กคริปโต แต่เป็นบั๊กการ parse บางคน parse body ก่อนตรวจสอบ บางคนใช้ hex ทั้งที่ผู้ให้บริการต้องการ base64 บางคนเทียบด้วย === ทั้งที่ต้องใช้การเทียบแบบ timing-safe คู่มือนี้คือฉบับย่อของทุกปัญหาลายเซ็นที่เราเคยดีบัก

โมเดลที่ผู้ให้บริการใช้

ผู้ให้บริการ Webhook รายใหญ่ทุกราย — Stripe, GitHub, Shopify, Twilio, Slack, Discord — ทำสิ่งเดียวกันในเสื้อผ้าที่ต่างกัน:

  1. คุณกับผู้ให้บริการแชร์ secret ร่วมกัน
  2. ผู้ให้บริการคำนวณลายเซ็นบน body ของคำขอ (บางครั้งบวก timestamp บางครั้งบวก URL)
  3. ลายเซ็นเดินทางอยู่ใน header
  4. handler ของคุณคำนวณลายเซ็นใหม่ด้วย secret เดียวกันและปฏิเสธคำขอหากไม่ตรงกัน

สิ่งที่ต่างกันระหว่างผู้ให้บริการ: อัลกอริทึม (HMAC SHA-256 พบบ่อยที่สุด; Discord ใช้ Ed25519), การเข้ารหัส (hex กับ base64), สิ่งที่ถูกเซ็น (body, body+timestamp, URL+body+พารามิเตอร์) และ header ใดที่นำลายเซ็นมา

สี่บั๊กที่อธิบายเกือบทุกอย่าง

1. parse body ก่อน

เฟรมเวิร์กส่วนใหญ่มี middleware JSON ที่ parse body ขาเข้าเป็นออบเจกต์ ทันทีที่มันทำงาน ไบต์ดิบจะหายไป และลายเซ็นใดที่คุณคำนวณจะไม่ตรง — แม้คุณ stringify ออบเจกต์ที่ parse แล้วใหม่ ช่องว่างและลำดับคีย์อาจต่างกัน

วิธีแก้: อ่าน body ดิบก่อน middleware ใดๆ บน route ของ Webhook ใน Express ให้ mount express.raw({ type: 'application/json' }) เฉพาะบน path ของ Webhook ใน Next.js App Router ให้อ่าน await request.text() โดยตรง ใน Django ให้ใช้ @csrf_exempt + request.body ก่อนที่ parser ของ DRF จะทำงาน

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)

ในทางปฏิบัติ การโจมตีนี้ทำได้ยากกับ endpoint ของ Webhook ผ่านอินเทอร์เน็ตสาธารณะ แต่การเทียบแบบปลอดภัยถูกมากจนไม่มีเหตุผลที่จะไม่ใช้

4. ไม่ตรวจสอบ timestamp

หากลายเซ็นครอบคลุมแค่ body ผู้โจมตีที่จับคำขอที่ถูกต้องได้สามารถ replay มันได้ตลอดไป ผู้ให้บริการแก้ปัญหานี้โดยใส่ timestamp ใน payload ของลายเซ็นและระบุหน้าต่างความคลาดเคลื่อนในเอกสาร (Stripe: 5 นาที, GitHub: ก็สั้นเช่นกัน) handler ของคุณควรปฏิเสธลายเซ็นใดที่มี timestamp เก่ากว่าค่าความคลาดเคลื่อน

ข้ามขั้นตอนนี้ แล้วคุณก็สร้างระบบที่เสี่ยงต่อ replay ไม่ว่า HMAC จะทำงานสมบูรณ์แค่ไหน

สิ่งที่อยู่ใน header จริงๆ

header ของผู้ให้บริการนำพามากกว่าแค่ "ลายเซ็น" Stripe ส่ง t=1234567890,v1=abcdef...,v0=... — timestamp บวกลายเซ็นหลายเวอร์ชัน GitHub ส่ง sha256=abcdef... พร้อมคำนำหน้า Twilio รวม URL ของคำขอไว้ใน payload ที่เซ็น แต่ส่งเฉพาะตัวลายเซ็นใน X-Twilio-Signature อ่านเอกสารของผู้ให้บริการที่คุณ integrate แล้วเขียนโค้ดให้ตรงกับรูปแบบที่แน่นอน

การทดสอบบนเครื่องโลคัลทำให้ดีบักได้

การตรวจลายเซ็นคือบั๊กชนิดที่แย่ที่สุด: เงียบและทำซ้ำได้ยากบน staging การทดสอบโลคัลด้วย ทันเนล และการจับคำขอให้ payload ที่ล้มเหลวแบบเป๊ะ header ที่มาถึงแบบเป๊ะ และปุ่มรีเพลย์เพื่อทดสอบการแก้ไขใหม่โดยไม่รบกวนผู้ให้บริการ

เมื่อการตรวจสอบปฏิเสธบางอย่าง ให้ log ให้พอสำหรับดีบัก: ความยาวของ body ดิบ, header ที่คุณอ่าน, ลายเซ็นที่คุณคำนวณ อย่า log secret (เราเคยเห็นคน log 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, ทันเนลของคุณเขียน header ใหม่ (ส่วนใหญ่ไม่ทำ แต่ควรยืนยันด้วยการจับคำขอ), หรือคุณกำลังทดสอบกับ secret live ขณะที่แดชบอร์ดแสดง secret test เราเจอมาทั้งสามแบบ

สำหรับข้อควรระวังเฉพาะผู้ให้บริการ ดูคู่มือ Stripe, GitHub และ Shopify หรือถ้าตอนนี้คุณกำลังจ้อง 401 อยู่ ให้ไปที่ การดีบักข้อผิดพลาด 401 ของ Webhook เข้าร่วม waitlist ของ PortPreview สำหรับทันเนล + การจับคำขอที่ทำให้สิ่งนี้ดีบักได้

คำถามที่พบบ่อย

ทำไมการตรวจลายเซ็น Webhook ของฉันจึงล้มเหลว?
ส่วนใหญ่ body ถูก parse โดย middleware ก่อนที่การตรวจสอบจะทำงาน สาเหตุอื่นที่พบบ่อย: ความสับสน hex กับ base64, การเทียบที่ไม่ timing-safe ที่ล้มเหลวในบางกรณี หรือใช้ secret ผิด (test กับ live, dashboard กับ CLI)
ฉันจำเป็นต้องใช้การเทียบแบบ timing-safe สำหรับลายเซ็น Webhook จริงไหม?
ใช่ การเทียบสตริงธรรมดารั่วไหลข้อมูลเวลาเกี่ยวกับ secret ทีละไบต์ การโจมตีทำได้ยากในทางปฏิบัติ แต่การเทียบแบบปลอดภัยคือโค้ดบรรทัดเดียว จึงไม่มีเหตุผลที่จะไม่ใช้
ทำไมลายเซ็น Webhook ของฉันจึงมี timestamp?
หากไม่มี timestamp ผู้โจมตีที่จับคำขอที่ลงนามถูกต้องได้สามารถ replay มันได้ตลอดไป timestamp ช่วยให้ handler ปฏิเสธสิ่งที่เก่ากว่าหน้าต่างความคลาดเคลื่อนของผู้ให้บริการ ซึ่งโดยทั่วไปคือไม่กี่นาที