บทความ › ความปลอดภัย

รับ Webhook ให้ปลอดภัย ตรวจ HMAC กัน replay ตอบเร็ว และกัน event ซ้ำ

endpoint รับ webhook เปิดให้ทั้งโลกยิงได้ ตรวจลายเซ็น HMAC บน raw body แบบ constant-time เช็กเวลากัน replay ตอบ 2xx ทันที แล้วกัน event ซ้ำและสลับลำดับ

กลาง อ่าน 12 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • ตรวจลายเซ็นจาก 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 bodyrequest ปลอม, body ถูกแก้ใครก็ยิง event ปลอมมาได้
2. เช็ก timestampเอา request จริงที่ดักได้มายิงซ้ำ (replay)request เก่าใช้ได้ตลอดไป
3. ตอบ 2xx เร็ว แล้วเข้า queuetimeout แล้วผู้ส่งนับว่าล้มเหลวevent หาย หรือโดนส่งซ้ำรัว ๆ
4. กันซ้ำด้วย event idผู้ส่ง retry ส่ง event เดิมอีกรอบส่งของสองรอบ ส่งอีเมลซ้ำ
5. ไม่พึ่งลำดับที่มาถึงevent มาสลับลำดับสถานะย้อนกลับไปเป็นค่าเก่า
รับ raw bodyห้าม parse ก่อนตรวจตรวจ HMAC + เวลาไม่ผ่าน = 400บันทึก inboxevent id ซ้ำ = ข้ามตอบ 200 ทันทีภายในไม่กี่วินาทีworker ทำงานจริงretry ได้ ไม่บล็อกผู้ส่ง
handler แค่ตรวจแล้วจด งานจริงไปทำใน worker

ด่าน 1: HMAC บน raw body และเทียบแบบ constant-time

ผู้ส่งกับเรามี secret ร่วมกันหนึ่งตัว ผู้ส่งคำนวณ HMAC-SHA256 ของ body ด้วย secret นั้นแล้วแนบมาใน header เราคำนวณซ้ำแล้วเทียบ ถ้าตรงแปลว่ามาจากคนที่มี secret และ body ไม่ถูกแก้ระหว่างทาง

StripeGitHub
headerStripe-Signature: t=...,v1=...X-Hub-Signature-256: sha256=...
ข้อความที่เซ็นtimestamp + . + raw bodyraw body
อัลกอริทึมHMAC-SHA256, hexHMAC-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เวลาที่เครื่องเราห่างผล
ส่งจริง17600000001760000180180 s ≤ 300รับ
ถูกยิงซ้ำภายหลัง17600000001760000400400 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 ปลายทางจริง

แหล่งอ้างอิง

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

ตรวจลายเซ็นแล้ว ยังต้องกัน 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 ด้วย ไม่ใช่พึ่งแค่ตารางกันซ้ำ

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Securityกลาง

จำกัดการยิง login และ API ด้วย nginx limit_req: burst, nodelay, 429, fail2ban

ตั้ง rate limit หน้า login และ API ด้วย nginx limit_req ให้ถูกความหมาย burst กับ nodelay คำนวณว่าสคริปต์ยิง 100 ครั้งต่อนาทีผ่านได้กี่ครั้ง และทำไมต้องจำกัดต่อบัญชีด้วย

อ่าน 12 นาที
#Securityกลาง

Passkeys ล็อกอินไม่ต้องใช้รหัสผ่าน: เซิร์ฟเวอร์ เก็บอะไร ต้องเช็กอะไร

Passkeys (WebAuthn) ไม่มีความลับฝั่งเซิร์ฟเวอร์และหลอก phishing ไม่ได้ ขั้นลงทะเบียน/ล็อกอิน ข้อมูลที่ต้องเก็บ โค้ด SimpleWebAuthn, py_webauthn และข้อผิดพลาดที่พบบ่อย

อ่าน 15 นาที
#Securityกลาง

แยกกุญแจตามหน้าที่ ระบบเดียวใช้ 4 แบบ หลุดดอกหนึ่ง อีกสามดอกยังปลอดภัย

เครื่องที่ส่งข้อมูล คนที่ล็อกอิน คนที่ดูหน้าสาธารณะ และคนที่ขอตามไม้ ต้องใช้กุญแจคนละแบบ ออกแบบ API key ผูกกับบัญชี session cookie และลิงก์สาธารณะ จากระบบที่รันจริง

อ่าน 6 นาที