รับ Webhook ให้ปลอดภัย ตรวจ HMAC กัน replay ตอบเร็ว และกัน event ซ้ำ
endpoint รับ webhook เปิดให้ทั้งโลกยิงได้ ตรวจลายเซ็น HMAC บน raw body แบบ constant-time เช็กเวลากัน replay ตอบ 2xx ทันที แล้วกัน event ซ้ำและสลับลำดับ
- ตรวจลายเซ็นจาก raw body ที่ได้รับมาเป๊ะ ๆ ก่อน parse JSON และเทียบด้วย hmac.compare_digest ไม่ใช่ ==
- timestamp ที่อยู่ในลายเซ็นกันการเอา request เก่ามายิงซ้ำ Stripe ใช้ค่าเผื่อ 5 นาที
- ตอบ 2xx ภายในไม่กี่วินาที แล้วให้ worker ทำงานจริงจาก queue
- ผู้ส่งส่งซ้ำได้และไม่รับประกันลำดับ กันซ้ำด้วย event id และอย่าพึ่งลำดับที่มาถึง
webhook คือการที่ระบบอื่น (payment gateway, GitHub, LINE) ยิง HTTP POST มาบอกเราว่ามีอะไรเกิดขึ้น เช่น “ใบแจ้งหนี้นี้จ่ายแล้ว” ปัญหาคือ URL ที่รับ webhook ต้องเปิดสาธารณะ ใครรู้ URL ก็ยิงมาได้ ถ้าเชื่อทุก request ที่เข้ามา คนอื่นก็สั่งให้ระบบเรา “ส่งของ” ได้โดยไม่ต้องจ่าย บทความนี้เป็นเช็กลิสต์ฝั่งผู้รับ ห้าข้อที่ต้องมีทุกครั้ง
ทดสอบบน Python 3.12 (hmac, hashlib, sqlite3 ใน stdlib) รูปแบบลายเซ็นตรวจกับเอกสาร Stripe และ GitHub ณ ตุลาคม 2026
ห้าด่านของ endpoint รับ webhook
| ด่าน | กันอะไร | ถ้าไม่ทำ |
|---|---|---|
| 1. ตรวจลายเซ็น HMAC บน raw body | request ปลอม, body ถูกแก้ | ใครก็ยิง event ปลอมมาได้ |
| 2. เช็ก timestamp | เอา request จริงที่ดักได้มายิงซ้ำ (replay) | request เก่าใช้ได้ตลอดไป |
| 3. ตอบ 2xx เร็ว แล้วเข้า queue | timeout แล้วผู้ส่งนับว่าล้มเหลว | event หาย หรือโดนส่งซ้ำรัว ๆ |
| 4. กันซ้ำด้วย event id | ผู้ส่ง retry ส่ง event เดิมอีกรอบ | ส่งของสองรอบ ส่งอีเมลซ้ำ |
| 5. ไม่พึ่งลำดับที่มาถึง | event มาสลับลำดับ | สถานะย้อนกลับไปเป็นค่าเก่า |
ด่าน 1: HMAC บน raw body และเทียบแบบ constant-time
ผู้ส่งกับเรามี secret ร่วมกันหนึ่งตัว ผู้ส่งคำนวณ HMAC-SHA256 ของ body ด้วย secret นั้นแล้วแนบมาใน header เราคำนวณซ้ำแล้วเทียบ ถ้าตรงแปลว่ามาจากคนที่มี secret และ body ไม่ถูกแก้ระหว่างทาง
| Stripe | GitHub | |
|---|---|---|
| header | Stripe-Signature: t=...,v1=... | X-Hub-Signature-256: sha256=... |
| ข้อความที่เซ็น | timestamp + . + raw body | raw body |
| อัลกอริทึม | HMAC-SHA256, hex | HMAC-SHA256, hex |
| timestamp ในลายเซ็น | มี | ไม่มี |
กฎสองข้อที่พังบ่อยที่สุด
- ต้องใช้ raw body ไบต์ต่อไบต์ เอกสาร Stripe เตือนว่า framework หลายตัว parse หรือแก้ body ก่อนถึงโค้ดเรา (เรียง key ใหม่ ตัดช่องว่าง เปลี่ยน encoding) แค่นั้นลายเซ็นก็ไม่ตรงแล้ว ใน Express ต้องวาง route webhook ก่อน
express.json()ใน Flask ใช้request.get_data()ไม่ใช่request.json - เทียบด้วยฟังก์ชัน constant-time เช่น
hmac.compare_digestใน Python หรือcrypto.timingSafeEqualใน Node ไม่ใช่==ซึ่งหยุดเทียบทันทีที่เจอตัวแรกที่ต่าง เวลาที่ต่างกันเล็กน้อยนั้นรั่วข้อมูลได้ ทั้ง Stripe และ GitHub เขียนไว้ตรงกัน
import hashlib, hmac, time
TOLERANCE = 300 # วินาที เท่ากับค่าเริ่มต้นของไลบรารี Stripe
def verify_stripe(raw: bytes, header: str, secret: str, now: float | None = None) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
ts = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"] # ไม่สน v0 หรือ scheme อื่น
if ts is None or not ts.isdigit() or not sigs:
return False
if abs((now or time.time()) - int(ts)) > TOLERANCE:
return False # เก่าเกินไป (หรือนาฬิกาเพี้ยน)
signed = ts.encode() + b"." + raw # ไบต์ตามที่ได้รับมาเป๊ะ ๆ
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
def verify_github(raw: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
ทำไมรับ v1 ได้หลายตัว ตอนหมุน secret (roll) Stripe ให้ secret เก่าใช้ต่อได้สูงสุด 24 ชั่วโมง ช่วงนั้นจะเซ็นมาหนึ่งลายเซ็นต่อ secret หนึ่งตัว ส่วนการไม่สน scheme อื่นนอกจาก v1 Stripe แนะนำไว้เพื่อกัน downgrade attack ถ้าใช้ไลบรารีทางการของผู้ส่งได้ ใช้ตัวนั้นก่อน โค้ดข้างบนมีไว้ให้เข้าใจว่าข้างในทำอะไร
secret ของ webhook เป็นคนละตัวกับ API key และควรแยกตาม endpoint หลักเดียวกับแยก credential ตามหน้าที่ เก็บใน environment หรือ secret manager ไม่ใช่ในโค้ด
ด่าน 2: timestamp กัน replay
ถ้ามีคนได้ request จริงไปหนึ่งตัว (เช่นจาก log ที่บันทึก body กับ header ไว้) ลายเซ็นยังถูกต้องเสมอ Stripe จึงใส่ timestamp ไว้ ในข้อความที่เซ็น แก้เวลาแล้วลายเซ็นจะพังทันที เราแค่ปฏิเสธ request ที่เวลาห่างจากตอนนี้เกินค่าเผื่อ
ตัวอย่างคำนวณ ค่าเผื่อ 300 วินาที
| timestamp ใน header | เวลาที่เครื่องเรา | ห่าง | ผล | |
|---|---|---|---|---|
| ส่งจริง | 1760000000 | 1760000180 | 180 s ≤ 300 | รับ |
| ถูกยิงซ้ำภายหลัง | 1760000000 | 1760000400 | 400 s > 300 | ปฏิเสธ |
สองเรื่องที่ต้องรู้ หนึ่ง Stripe สร้าง timestamp กับลายเซ็นใหม่ทุกครั้งที่ retry ดังนั้น retry จริงไม่โดนตัดทิ้ง สอง นาฬิกาเครื่องเราต้องตรง ให้ NTP ทำงาน และ ห้ามตั้งค่าเผื่อเป็น 0 เอกสาร Stripe บอกว่า 0 คือปิดการเช็กเวลาไปเลย
GitHub ไม่มี timestamp ในลายเซ็น แนะนำให้ใช้ header X-GitHub-Delivery ซึ่งไม่ซ้ำกันต่อหนึ่ง event กันซ้ำแทน (ด่าน 4) และถ้าสั่ง redeliver ค่า header นี้จะเท่าเดิม
ด่าน 3: ตอบ 2xx เร็ว งานจริงไปทำใน queue
ทั้ง Stripe และ GitHub บอกตรงกัน ให้ตอบ 2xx ก่อนทำ logic ที่ซับซ้อน GitHub กำหนดว่าต้องตอบภายใน 10 วินาที ไม่อย่างนั้นตัดการเชื่อมต่อและนับว่าล้มเหลว
ตัวอย่างคำนวณ ถ้า handler ทำงานจริงในตัวเอง ออกใบเสร็จ PDF 4 วินาที ส่งอีเมล 3 วินาที อัปเดตระบบบัญชี 5 วินาที รวม 4 + 3 + 5 = 12 วินาที เกิน 10 วินาทีของ GitHub ทุกครั้ง ผลที่ตามมาต่างกันตามผู้ส่ง
- GitHub ไม่ redeliver อัตโนมัติ event นั้นหายไปจนกว่าเราจะสั่งส่งใหม่เอง
- Stripe retry ต่อ ไปได้ถึงสามวันแบบ exponential backoff (live mode) ในขณะที่ handler ตัวแรกอาจทำงานจนเสร็จไปแล้ว ผลคือทำซ้ำ
ทางแก้คือ handler ทำแค่ ตรวจลายเซ็น → บันทึกลงตาราง inbox → ตอบ 200 ใช้เวลาระดับมิลลิวินาที แล้วให้ worker แยกดึงจาก inbox ไปทำ ข้อดีแถมคือ ตอนต้นเดือนที่ subscription ต่ออายุพร้อมกัน (ตัวอย่างที่ Stripe ยกเอง) worker ทำตามจังหวะที่ระบบรับไหว
handler ของ webhook มีหน้าที่เดียว คือรับให้ถูกตัวแล้วจดลงที่ปลอดภัย ไม่ใช่ทำงาน
ด่าน 4: กันซ้ำด้วย event id
webhook เป็นแบบ “at least once” ผู้ส่งจะส่งซ้ำเมื่อไม่แน่ใจว่าเราได้รับ Stripe บอกตรง ๆ ว่า endpoint อาจได้ event เดิมมากกว่าหนึ่งครั้ง ใช้ primary key บน event id ให้ฐานข้อมูลกันซ้ำ
import json, sqlite3
db = sqlite3.connect("webhooks.db", isolation_level=None)
db.execute("""CREATE TABLE IF NOT EXISTS webhook_inbox (
event_id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL,
received_at INTEGER NOT NULL,
processed_at INTEGER
) STRICT""")
def receive(raw: bytes, sig_header: str, secret: str) -> int:
if not verify_stripe(raw, sig_header, secret):
return 400
event = json.loads(raw) # parse หลังตรวจลายเซ็นเท่านั้น
db.execute("INSERT INTO webhook_inbox(event_id, type, payload, received_at) "
"VALUES (?,?,?,?) ON CONFLICT(event_id) DO NOTHING",
(event["id"], event["type"], raw.decode(), int(time.time())))
return 200 # ซ้ำก็ตอบ 200 ให้ผู้ส่งหยุด retry
ลองส่ง event เดิมสองครั้งด้วยลายเซ็นที่ถูกต้อง ได้ 200 200 และในตารางมี 1 แถว แก้ body หนึ่งตัวอักษรได้ 400 เลื่อนเวลาไป 301 วินาที verify_stripe คืน False
event ซ้ำต้องตอบ 2xx ไม่ใช่ 4xx ไม่อย่างนั้นผู้ส่งจะเข้าใจว่าล้มเหลวและส่งมาอีก ส่วนงานที่ worker ทำ เช่นตัดสต็อกหรือเรียก API อื่น ควรใช้ idempotency key ของมันเองด้วย (บทความ idempotency key) เพราะ Stripe ยอมรับว่าบางกรณีสร้าง Event สองตัวที่ id ต่างกันสำหรับเรื่องเดียว
ด่าน 5: event มาไม่ตรงลำดับ
Stripe ไม่รับประกันลำดับการส่ง ตัวอย่างในเอกสาร สร้าง subscription หนึ่งครั้งได้ customer.subscription.created, invoice.created, invoice.paid แต่ invoice.paid อาจมาถึงก่อน invoice.created และ Stripe เตือนว่า อย่าใช้ created เรียงลำดับ เพราะเป็นหน่วยวินาที หลาย event มีค่าเท่ากันได้
วิธีรับมือที่ใช้ได้จริง
- ถือว่า event เป็นแค่สัญญาณ ได้
invoice.paidแล้วไปดึง invoice ล่าสุดจาก API มาดูสถานะจริง แทนการเชื่อข้อมูลใน payload - ให้สถานะเดินหน้าอย่างเดียว เช่น
pending → paid → refundedถ้า event ที่มาทีหลังพาสถานะถอยหลัง ไม่ต้องทำตาม - ไม่เจอ object ที่อ้างถึง ไม่ใช่ error สร้างแถวขึ้นมาจาก API หรือเลื่อนไป retry ใน worker
ข้อผิดพลาดที่เจอบ่อย
- parse JSON ก่อนตรวจลายเซ็น แล้ว
json.dumpsกลับมาเซ็น ได้ไบต์ไม่ตรงกับที่ส่งมา ลายเซ็นพังทั้งที่ถูก หรือแย่กว่าคือปิดการตรวจทิ้งเพราะ “มันพังบ่อย” - เทียบลายเซ็นด้วย
== - ตั้ง tolerance เป็น 0 หรือปิดการเช็กเวลา ตอน debug แล้วลืมเปิดคืน
- ใช้ secret เดียวกันทุก endpoint หรือปนกับ secret ทดสอบ Stripe มี secret แยกต่อ endpoint และแยก test กับ live
- CSRF middleware ปฏิเสธ webhook ใน Rails หรือ Django ต้องยกเว้นเฉพาะ route นี้ ไม่ใช่ปิดทั้งแอป
- log body ทั้งก้อนพร้อม header ลายเซ็น log นั้นคือวัตถุดิบของ replay ถ้าไม่มี timestamp ในลายเซ็น
- endpoint redirect (เช่น http → https หรือเติม
/ท้าย) Stripe นับ 3xx เป็นความล้มเหลว ลงทะเบียน URL ปลายทางจริง
แหล่งอ้างอิง
- Receive Stripe events in your webhook endpoint (verify signatures, replay, ordering, retries) — Stripe Docs
- Manage webhook endpoints (raw body, duplicates, async) — Stripe Docs
- Validating webhook deliveries — GitHub Docs
- Best practices for using webhooks — GitHub Docs
- Handling failed webhook deliveries — GitHub Docs
- hmac — Keyed-Hashing for Message Authentication — Python Docs
คำถามที่พบบ่อย
ตรวจลายเซ็นแล้ว ยังต้องกัน IP อีกไหม
Stripe แนะนำให้ทำทั้งสองอย่าง คือตรวจลายเซ็นและรับเฉพาะ IP ที่ Stripe ประกาศ ลายเซ็นคือด่านหลักเพราะพิสูจน์ได้ว่าเนื้อหาไม่ถูกแก้ ส่วน IP allowlist ช่วยตัด request ขยะก่อนถึงแอป ให้ดึงรายชื่อจากหน้าเอกสารของผู้ส่ง อย่าพิมพ์เก็บไว้เองแล้วลืม
ตอบ status อะไรเมื่อลายเซ็นไม่ผ่าน
4xx เช่น 400 (ตัวอย่างของ Stripe) หรือ 403 (ตัวอย่างของ GitHub) และอย่าบอกเหตุผลละเอียดใน body ว่าผิดตรงไหน บันทึกไว้ใน log ฝั่งเราพอ
event ซ้ำแต่ event id ต่างกันเกิดได้ไหม
Stripe บอกว่าบางกรณีสร้าง Event สองตัวแยกกัน ให้ดู id ของ object ใน data.object คู่กับ event.type ประกอบ ดังนั้นงานที่ worker ทำต้อง idempotent ด้วย ไม่ใช่พึ่งแค่ตารางกันซ้ำ
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ