ตรวจสอบความถูกต้องของ Webhook ด้วยลายเซ็น HMAC
อ่าน 2 นาที
ไม่มีรูปภาพ
Webhook คือประตูที่เปิดให้โลกภายนอกเรียกเข้ามาที่ระบบของคุณ ถ้าไม่ตรวจว่าใครเป็นผู้ส่ง ใครก็ตามที่รู้ URL สามารถปลอมเหตุการณ์ "ชำระเงินสำเร็จ" ได้ บทความนี้อธิบายกลไกลายเซ็นที่ PPay ใช้กับ Webhook และเหตุผลของแต่ละข้อกำหนด เพื่อให้คุณเขียน handler ที่ปลอดภัยโดยไม่ต้องเดา
ทำไมต้องตรวจลายเซ็น
URL ของ Webhook เป็นที่อยู่สาธารณะ หากผู้ไม่หวังดีส่ง JSON ที่หน้าตาเหมือนผลตรวจสลิป โดยระบุว่า valid เป็น true แล้วระบบคุณเชื่อทันที ออเดอร์อาจถูกอนุมัติโดยไม่มีเงินเข้าจริง ลายเซ็นจึงเป็นข้อพิสูจน์ว่าข้อความมาจากระบบที่รู้ความลับร่วมกัน (คีย์ลายเซ็นขึ้นต้นด้วย whsec_) และเนื้อหาไม่ถูกแก้ระหว่างทาง
รูปแบบลายเซ็นตามคู่มือ
ทุกคำขอมี header X-PPay-Signature ในรูปแบบ t=<unix seconds>,v1=<hex> โดย v1 คือ HMAC-SHA256 ของข้อความ "<t>.<raw body>" ด้วยคีย์ลายเซ็นของคุณ ยังมี header อื่นที่เป็นประโยชน์ เช่น X-PPay-Event (ชนิดเหตุการณ์), X-PPay-Delivery (รหัสการส่ง) และ X-PPay-Attempt (ครั้งที่ส่ง) แต่ความน่าเชื่อถือต้องมาจากลายเซ็นเท่านั้น
คีย์ลายเซ็นแสดงให้เห็นครั้งเดียวตอนตั้งค่า หากหายให้กด "สร้างคีย์ใหม่" ในหน้า API สำหรับนักพัฒนา และเก็บคีย์ไว้ใน environment variable เช่นเดียวกับ API key
สามกฎที่ห้ามพลาด
- ใช้ raw body — ต้องคำนวณจากไบต์ที่ได้รับจริงก่อน parse JSON เพราะแค่ช่องว่างหรือลำดับคีย์ที่ต่างไปก็ทำให้ลายเซ็นไม่ตรง ข้อผิดพลาดยอดนิยมคือใช้ middleware ที่ parse JSON ไปก่อนแล้วนำวัตถุมาแปลงกลับเป็นข้อความ
- เทียบแบบ constant-time — ใช้
timingSafeEqual(Node.js),hash_equals(PHP) หรือcompare_digest(Python) เพราะการเทียบสตริงตรง ๆ อาจรั่วข้อมูลจากเวลาที่ใช้เทียบ - ตรวจเวลา — ปฏิเสธคำขอที่ค่า
tต่างจากเวลาปัจจุบันเกิน 5 นาที เพื่อกันการนำคำขอเก่ามาส่งซ้ำ (replay) ดังนั้นนาฬิกาของเซิร์ฟเวอร์คุณต้องตรงโดยซิงก์กับ NTP
ตัวอย่างโค้ด Node.js
ตัวอย่างต่อไปนี้เป็นแนวทางตามคู่มือ ใช้ Express พร้อม express.raw เพื่อเก็บ body ตามที่ได้รับ
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.PPAY_WEBHOOK_SECRET; // whsec_...
const app = express();
function verify(raw, header, secret) {
const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(parts.t + "." + raw).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
app.post("/webhooks/ppay", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.get("x-ppay-signature"), SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // acknowledge first, then work
// handle event.type here
});
คู่มือมีตัวอย่างสำหรับ PHP (hash_hmac กับ hash_equals และอ่าน body จาก php://input) และ Python Flask (hmac.compare_digest กับ request.get_data()) ในหลักการเดียวกัน
หลังตรวจลายเซ็นผ่านแล้ว
- ตอบ 2xx ภายใน 8 วินาที ตอบก่อนแล้วค่อยประมวลผลต่อเบื้องหลัง
- กันซ้ำด้วย id ของเหตุการณ์ หรือ
data.jobIdเพราะระบบส่งแบบ at-least-once อ่านต่อที่ การรับ webhook ซ้ำและล่าช้า - เทียบข้อมูลกับออเดอร์ของคุณ แม้ลายเซ็นถูก ก็ควรตรวจว่า
referenceมีอยู่จริง ยอดตรง และออเดอร์ยังรอชำระอยู่ - ตอบ 400 เมื่อลายเซ็นผิด และบันทึก log (ไม่ใส่คีย์ลับลงใน log) เพื่อดูว่ามีความพยายามปลอมหรือไม่
ข้อผิดพลาดที่พบบ่อย
| อาการ | สาเหตุที่เป็นไปได้ |
|---|---|
| ลายเซ็นไม่ตรงทุกครั้ง | ใช้ body ที่ parse แล้ว หรือใช้คีย์ผิดตัว (ใช้ API key แทนคีย์ whsec) |
| ผ่านบ้างไม่ผ่านบ้าง | ตัวกลางเช่น proxy แก้ไข body หรือแปลงการเข้ารหัสอักขระ |
| ปฏิเสธเพราะเวลา | นาฬิกาเครื่องเซิร์ฟเวอร์คลาดเคลื่อนเกิน 5 นาที |
| ถูกส่งซ้ำตลอด | ตอบไม่ใช่ 2xx เช่นตอบ redirect ซึ่งระบบไม่ตามการ redirect |
เช็กลิสต์
- เก็บ whsec ใน secret manager
- อ่าน raw body ก่อน parse
- เทียบ constant-time
- ตรวจ t ภายใน 5 นาที
- กันซ้ำและเทียบกับออเดอร์ก่อนอนุมัติ
ลองทดสอบด้วยปุ่ม "ส่งทดสอบ" ก่อนใช้งานจริง ดูรายละเอียดครบที่ คู่มือ API และอ่านภาพรวมของ Webhook ที่ พื้นฐาน webhook สำหรับการชำระเงิน