การป้องกัน Webhook HMAC จากการส่งซ้ำ: Timestamp, Retry และ Idempotency
การป้องกัน Webhook HMAC จากการส่งซ้ำต้องใช้การควบคุมสองชั้นที่แยกจากกัน ชั้นแรก ตรวจ HMAC-SHA256 จาก timestamp ที่ตรงกันทุกตัวอักษรรวมกับ raw request body และปฏิเสธ delivery ที่อยู่นอกกรอบเวลาความสดใหม่ที่คุณกำหนดเอง ชั้นที่สอง ตัด event ID ที่ซ้ำกัน เพราะ delivery ที่ยืนยันตัวตนได้ก็ยังอาจถูก retry การตรวจลายเซ็นพิสูจน์ความถูกต้องครบถ้วนและยืนยันว่าผู้ส่งรู้ shared secret แต่ไม่ได้ทำให้ delivery เป็น exactly-once
การป้องกัน Webhook HMAC จากการส่งซ้ำทำงานอย่างไร
Receiver ที่ปลอดภัยควรตอบคำถามสี่ข้อตามลำดับ:
- มี signing header ครบและรูปแบบถูกต้องหรือไม่? เมื่อ endpoint เปิดใช้การเซ็น ให้ปฏิเสธคำขอที่ไม่มี timestamp, ลายเซ็น หรือ event ID
- คำขอยังใหม่พอหรือไม่? Parse timestamp แบบ RFC 3339 แล้วใช้กรอบเวลาความสดใหม่ที่เหมาะกับโครงสร้างพื้นฐานของคุณ
- Byte ที่ได้รับตรงกับลายเซ็นหรือไม่? คำนวณ HMAC-SHA256 จาก
<timestamp>.<raw body>แล้วเปรียบเทียบ digest แบบ constant-time - Event นี้เคยถูกยอมรับแล้วหรือยัง? เก็บ event ID ที่คงที่ด้วย unique constraint ก่อน acknowledge delivery
ขั้นตอนสุดท้ายสำคัญเพราะ retry กับ replay ไม่ใช่สิ่งเดียวกัน Retry คือการส่ง delivery เดิมอย่างถูกต้องหลังเกิด connection failure หรือได้รับ response ที่ไม่ใช่ 2xx ส่วน replay คือการนำคำขอที่เคยมีลายเซ็นถูกต้องกลับมาใช้อีกนอกเส้นทางประมวลผลที่คุณตั้งใจไว้ การตรวจความสดใหม่ของ timestamp จำกัดระยะเวลาที่คำขอซึ่งถูกเก็บไว้ยังได้รับการยอมรับ ขณะที่ durable idempotency ป้องกัน retry ที่ถูกต้องไม่ให้สร้าง ticket, คำตอบ หรือ workflow เดิมซ้ำสองครั้ง
ประเด็นสำคัญ
- ตรวจ raw request bytes ก่อน parse JSON หรือ serialize ใหม่
- ถือว่าความสดใหม่ของ timestamp เป็นนโยบายของแอปพลิเคชัน เพราะ UnifyPort ไม่ได้กำหนด tolerance เดียวสำหรับทุก deployment
- ใช้การเปรียบเทียบแบบ constant-time กับ digest buffer ที่มีความยาวเท่ากัน
- ตัด
X-Device-Event-Idที่ซ้ำ เพราะ delivery ของ UnifyPort เป็นแบบ at-least-once - ส่ง 2xx หลัง event ถูกยอมรับอย่าง durable แล้ว ไม่ต้องรอให้งาน downstream ทั้งหมดเสร็จ
ข้อตกลงลายเซ็นของ UnifyPort ที่ต้องใช้ตรงตัว
เมื่อ webhook endpoint มี signing_secret UnifyPort จะส่ง X-Device-Signature ที่ encode เป็น hex โดยค่าที่นำไปเซ็นคือ:
<X-Device-Timestamp>.<raw request body>
X-Device-Timestamp เป็นค่า UTC รูปแบบ RFC 3339 ไม่ใช่เลขจำนวนเต็ม Unix ส่วน X-Device-Event-Id จะคงเดิมตลอดการ retry ของ event เดียวกัน ขณะที่ X-Device-Delivery-Id ระบุ delivery แต่ละครั้ง หากไม่ระบุ signing_secret หรือปล่อยเป็นค่าว่าง การเซ็นจะถูกปิดและไม่มีการส่ง signature header
ข้อตกลงนี้สอดคล้องกับจุดประสงค์ของ HMAC ตามนิยามใน RFC 2104: สองฝ่ายที่ใช้ secret ร่วมกันสามารถตรวจความครบถ้วนของข้อความและยืนยันผู้ส่งที่รู้ secret นั้นได้ HMAC ไม่ได้เข้ารหัส body ไม่ได้พิสูจน์ความสดใหม่ด้วยตัวเอง และไม่ได้รับประกันการส่งเพียงครั้งเดียว การรับประกันเหล่านั้นมาจาก HTTPS, นโยบาย timestamp และ idempotent storage ที่ครอบขั้นตอนตรวจ HMAC
หากคุณกำลังสร้าง inbound workflow แบบครบวงจร บทสอน webhook WhatsApp สำหรับ n8n แสดงวิธีส่ง event ที่เซ็นแล้วเข้า workflow อัตโนมัติ ส่วน บทสอนคิว live DM ของ TikTok อธิบายว่าทำไมควรเก็บ envelope ที่ตรวจแล้วก่อน routing
ตรวจ Timestamp และ Raw Body ใน Node.js
Receiver ด้านล่างเก็บ body เป็น Buffer, อ่านค่า tolerance ของความสดใหม่จาก deployment configuration, เปรียบเทียบ binary digest ด้วย crypto.timingSafeEqual ของ Node.js และส่ง event ที่ตรวจแล้วไปยัง durable inbox โดย durableInbox.insertIfAbsent แทนการ insert ลง database ที่มี unique key บน event ID ให้คุณนำไปใช้กับ datastore ที่ service ใช้อยู่แล้ว
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);
if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}
app.post(
'/webhooks/unifyport',
express.raw({ type: 'application/json' }),
async (req, res) => {
const timestamp = req.get('X-Device-Timestamp') ?? '';
const signature = req.get('X-Device-Signature') ?? '';
const eventId = req.get('X-Device-Event-Id') ?? '';
if (!timestamp || !signature || !eventId) {
return res.sendStatus(401);
}
const signedAtMs = Date.parse(timestamp);
const ageMs = Math.abs(Date.now() - signedAtMs);
if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(req.body)
.digest();
const validHex = /^[0-9a-f]{64}$/i.test(signature);
const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
const validSignature =
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
if (!validSignature) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
const accepted = await durableInbox.insertIfAbsent({
id: eventId,
occurredAt: event.occurred_at,
payload: event,
});
return res.sendStatus(accepted ? 202 : 200);
},
);
การตรวจความสดใหม่อยู่ก่อนการเปรียบเทียบ HMAC แต่ timestamp จะเชื่อถือได้ก็ต่อเมื่อผ่านการตรวจทั้งสองขั้น Receiver เพียงคัด input ที่เก่าอย่างชัดเจนออกก่อน ค่า WEBHOOK_MAX_AGE_MS ควรสอดคล้องกับการซิงก์นาฬิกา, latency ปกติของ delivery, ขั้นตอนรับมือ incident และโมเดลความเสี่ยงของคุณ อย่าคัดลอก tolerance จาก provider อื่นแล้วคิดว่าจะเหมาะกับคิวของคุณ
เอกสาร Node.js ระบุว่า crypto.timingSafeEqual เหมาะสำหรับเปรียบเทียบ HMAC digest พร้อมเตือนว่า code รอบข้างต้องไม่สร้าง timing leak ด้วย ตรวจรูปแบบ hex และความยาว byte ก่อนเสมอ เพราะ timingSafeEqual ต้องได้รับ input ที่มีความยาวเท่ากัน
ทำให้เส้นทาง Acknowledge ปลอดภัยต่อ Retry
UnifyPort ถือ response 2xx ใด ๆ เป็น acknowledgement และไม่ใช้ response body ต่อ Connection error รวมถึง HTTP 408, 429 และ 5xx จะถูก retry ได้สูงสุดตาม retry_policy.max_attempts ที่ตั้งให้ endpoint โดยค่าเริ่มต้นคือสามครั้ง ส่วน response 4xx อื่นจะไม่ถูก retry และ event จะถูกส่งไป dead-letter
พฤติกรรมนี้นำไปสู่การออกแบบ receiver ที่ใช้งานได้จริงดังนี้:
| ผลลัพธ์ที่ Receiver | Response | เหตุผล |
|---|---|---|
| ลายเซ็นหาย เก่าเกินไป หรือไม่ถูกต้อง | 401 | คำขอไม่ควรเข้าสู่คิวที่เชื่อถือได้ |
| Event ที่ตรวจแล้วถูกเก็บไว้ก่อนหน้า | 200 | Acknowledge retry ได้โดยไม่ทำงานเดิมซ้ำ |
| Event ที่ตรวจแล้วถูก insert อย่าง durable | 202 | Worker ทำงานต่อแบบ asynchronous หลังรับ event ได้ |
| Durable inbox ใช้งานไม่ได้ชั่วคราว | 503 | Retry ปลอดภัยกว่าการ acknowledge event ที่ยังไม่ได้เก็บ |
ใช้ unique index บน X-Device-Event-Id แทน Set ที่อยู่เฉพาะใน process เพราะ local cache หายเมื่อ restart และประสานงานระหว่าง receiver หลาย instance ไม่ได้ ทำให้ downstream action เป็น idempotent ด้วย เพราะ queue consumer อาจหยุดหลังเรียก CRM หรือส่งคำตอบ แต่ก่อนบันทึกว่างานเสร็จแล้ว
ระบบไม่รับประกันลำดับ delivery จัดเรียงงานที่เปลี่ยน state ตาม occurred_at ใน event payload โดยใช้ event ID เป็นตัวตัดสินเมื่อค่าเท่ากัน แทนการสมมติลำดับจาก HTTP arrival เรื่องนี้สำคัญเป็นพิเศษเมื่อ read receipt มาถึง endpoint ก่อน message ที่มันอ้างถึง
UnifyPort อยู่ตรงไหนในระบบ
UnifyPort ส่ง standard event envelope รูปแบบเดียวกันผ่านช่องทางที่รองรับ โดยมี id, type, provider, account_id, occurred_at และ data ที่ต่างกันตามชนิด event ดังนั้น receiver ด้านบนจึงป้องกัน ingress path เดียว แทน handler แยกตามช่องทางหกชุด ลงทะเบียน endpoint ครั้งเดียว เปิด signing_secret, subscribe เฉพาะ event ที่ต้องใช้ และตรวจ timestamp, ลายเซ็น และ idempotency ด้วยกฎเดียวกันก่อน routing ตาม provider หรือ type
ขอบเขตสำคัญคือ storage: UnifyPort ไม่เก็บ message history ไว้ให้ backfill ภายหลัง Webhook event คือบันทึกของ traffic ดังนั้น receiver ควรรับ event อย่าง durable ก่อนตอบ 2xx การตรวจลายเซ็นปกป้องจุดส่งต่อ ส่วน inbox table หรือ queue ของคุณเก็บรักษา event ไว้
ข้อจำกัดและสิ่งที่ต้องแลก
- HMAC ยืนยันตัวตนและปกป้องความครบถ้วน แต่ไม่ได้เข้ารหัส JSON body ต้องเปิด HTTPS และปกป้อง log กับ queue แยกต่างหาก
- HMAC ที่ถูกต้องไม่ได้หยุดการประมวลผลซ้ำ ยังต้องมีการตรวจความสดใหม่ของ timestamp และ idempotency key
- UnifyPort ไม่ประกาศ tolerance ของ timestamp ค่าเดียวสำหรับทุกระบบ กรอบเวลาที่สั้นช่วยจำกัดการนำกลับมาใช้ แต่รองรับ clock drift หรือ delivery ล่าช้าได้น้อยลง
- หาก endpoint ปิดการเซ็น จะไม่มี
X-Device-SignatureProduction receiver ที่ต้องการ authentication ควรปฏิเสธเมื่อ header นี้หายไป - Webhook อย่างเป็นทางการของแต่ละ provider อาจใช้ header, encoding หรือ canonical string ต่างกัน ให้ทำตามข้อตกลงของ provider นั้นแทนการใช้รูปแบบสตริงของ UnifyPort กับ webhook ทุกแหล่ง
คำถามที่พบบ่อย
ทำไมลายเซ็น HMAC ของ Webhook จึงไม่ตรงกัน?
สาเหตุที่พบบ่อยที่สุดคือการตรวจ JSON ที่ parse หรือ serialize ใหม่ แทน raw bytes ที่ได้รับจริง ตรวจ timestamp แบบ RFC 3339, เครื่องหมายจุดที่เป็นตัวคั่น, signing_secret ที่ถูกต้อง, การ decode hex และดูว่า middleware อ่าน body ไปก่อนขั้นตอนตรวจหรือไม่
HMAC เพียงอย่างเดียวป้องกันการส่ง Webhook ซ้ำได้หรือไม่?
ไม่ได้ HMAC พิสูจน์ว่า byte ที่เซ็นตรงกับ shared secret ต้องเพิ่มการตรวจความสดใหม่ของ X-Device-Timestamp และ durable deduplication ของ X-Device-Event-Id เพื่อจำกัดการนำกลับมาใช้และการประมวลผลซ้ำ
Event ที่ซ้ำควรตอบ Error หรือไม่?
ไม่ควร หาก event ID เดิมถูกยอมรับอย่าง durable แล้ว ให้ตอบ 2xx การตอบ error จะกระตุ้น retry ที่ถูกต้องอีกรอบโดยไม่ได้ทำให้ระบบถูกต้องขึ้น
ควร Acknowledge ก่อนประมวลผล Event หรือไม่?
Acknowledge หลังรับ event อย่าง durable แต่ก่อนเริ่มงาน downstream ที่ช้า Insert event ลง inbox ที่มี database รองรับหรือ durable queue, ตอบ 2xx แล้วให้ worker จัดการเขียน CRM, ประมวลผล AI หรือส่งคำตอบแบบ idempotent
ควรใช้กรอบเวลาความสดใหม่เท่าไร?
เลือกและบันทึกค่าตามการซิงก์นาฬิกา, delivery latency ที่สังเกตได้, วิธีรับมือ incident และโมเดลความเสี่ยง ข้อตกลงลายเซ็นของ UnifyPort กำหนดให้ปฏิเสธ timestamp ที่ห่างจากนาฬิกาของคุณมากเกินไป แต่ไม่ได้กำหนดค่าตายตัว
ขั้นตอนถัดไป
ใช้ข้อตกลง header และ retry ให้ตรงตาม คู่มือ delivery ของ webhook และการตรวจลายเซ็น หากต้องตรวจแบบ byte-for-byte ขณะวิเคราะห์ลายเซ็นที่ไม่ตรงกัน ให้ใช้ HMAC Signature Generator เป็นเครื่องมือเสริมเพียงรายการเดียว
แหล่งข้อมูล
ตรวจสอบแหล่งข้อมูลอย่างเป็นทางการเมื่อวันที่ 17 กรกฎาคม 2026: