Verify webhook signature ให้ถูก: sign บน bytes, เทียบ timing-safe, และผูกกับ timestamp+method+path
HMAC signature verification เป็นโค้ดไม่กี่บรรทัดที่ "ดูเหมือนทำงาน" แต่พังเงียบๆ ได้หลายทาง — hash ไม่ตรง bytes จริง, เทียบ === รั่ว timing, sign แค่ body เปิดช่อง replay. มาดูกันว่าทำให้ถูกต้องทำยังไง
อ่าน ~8 นาที
ปัญหา: verify ผ่านตอน test แต่ bypass ได้ตอน prod
ผมเคยรับ webhook จาก payment gateway เจ้าหนึ่ง โค้ด verify signature เขียนไปแล้ว test ก็ผ่าน request จริงก็เข้ามาปกติ ทุกอย่างดูเรียบร้อย จนวันที่ผมนั่งไล่อ่านโค้ดตัวเองด้วยสายตาคนที่พยายามจะโจมตีระบบ ถึงเห็นว่ามันมีรูอย่างน้อยสามจุดที่ทำให้ signature ทั้งชุด "ผ่าน" ได้โดยที่ผู้ส่งของจริงไม่ได้ sign เลย
นี่คือแก่นของบทความนี้: HMAC signature verification เป็นโค้ดสั้นๆ ที่หลอกตาได้ง่ายมาก เพราะมันทำงานถูกตอน request ปกติ ทุก pitfall ที่ผมจะเล่าไม่ทำให้ระบบ error ไม่ทำให้ test แดง มันแค่เปิดช่องให้ bypass ได้เงียบๆ วิธีคิดที่ถูกมีแค่สองข้อ: signature คือเรื่องของ bytes ที่ผู้ส่ง sign จริง และ เราต้อง authenticate เจตนาทั้งหมดของ request ไม่ใช่แค่เนื้อ body
Pitfall 1: hash บน object ที่ parse แล้ว ไม่ใช่ bytes ที่ส่งมาจริง
ผู้ส่ง webhook คำนวณ HMAC จาก raw body ที่เขาส่งออกมา — string ชุดหนึ่งที่มี byte เรียงกันแบบเฉพาะเจาะจง เว้นวรรค ลำดับ key ทศนิยม ทุกอย่าง fix หมดแล้ว ถ้าฝั่งเรา parse JSON ก่อน แล้วค่อย JSON.stringify กลับเพื่อเอาไป hash เราจะได้ bytes คนละชุด
ตัวอย่างที่พังเงียบๆ: ผู้ส่งส่ง {"amount":100.00,"note":"hello"} พอ JavaScript parse แล้ว stringify กลับ จะได้ {"amount":100,"note":"hello"} — 100.00 กลายเป็น 100, ลำดับ key อาจสลับ, unicode escape อาจต่างกัน byte ไม่ตรง signature ไม่ตรง ทันที และต่อให้บังเอิญตรงในเคสส่วนใหญ่ มันก็คือ verify ที่ไว้ใจไม่ได้
ทางแก้คือ capture raw body ก่อน JSON parse แล้วเอา string ดิบตัวนั้นไป hash โดยตรง ใน Express ทำได้ด้วย verify callback ของ body parser:
// เก็บ raw body ไว้ก่อนที่ JSON parser จะแตะมัน
app.use(express.json({
verify: (req, _res, buf) => {
// buf คือ Buffer ดิบ byte-identical กับที่ผู้ส่ง sign
req.rawBody = buf;
},
}));
แล้วตอน verify ให้ hash จาก req.rawBody เท่านั้น — อย่าไปแตะ req.body ที่ parse แล้วเด็ดขาด หลักการที่ transferable: signature ผูกกับ bytes บนสาย ไม่ใช่กับ object ในหน่วยความจำ ทุกครั้งที่คุณ serialize ใหม่ คุณกำลังเดา bytes ของคนอื่น ซึ่งเดาพลาดเมื่อไหร่ก็รู้ตัวไม่ได้
Pitfall 2: เทียบ signature ด้วย === รั่ว timing
สมมติ hash ถูกต้องแล้ว หลายคนเทียบแบบนี้:
// ผิด — เปรียบเทียบ string ด้วย === รั่ว timing
if (expectedSig === receivedSig) { /* ผ่าน */ }
ปัญหาคือการเทียบ string ปกติจะหยุดทันทีที่เจอ byte แรกที่ไม่ตรง แปลว่าเวลาที่ใช้เทียบขึ้นกับว่า attacker เดาถูกกี่ byte แรก ผู้โจมตีที่วัดเวลา response ละเอียดพอสามารถ brute-force signature ทีละ byte จากซ้ายไปขวาได้ เพราะ signature ที่เดาถูก 5 byte แรกจะ return ช้ากว่าที่ผิดตั้งแต่ byte แรก นี่คือ timing attack และมันเป็นของจริง
ทางแก้คือใช้ constant-time comparison ที่ใช้เวลาเท่ากันเสมอไม่ว่าตรงหรือไม่ตรง Node มี crypto.timingSafeEqual ให้อยู่แล้ว แต่มันมี trap: ถ้าสอง buffer ยาวไม่เท่ากันมันจะthrow ไม่ใช่ return false ฉะนั้นต้องมี length-guard ก่อน:
import crypto from 'crypto';
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
// length ต่างกัน = ไม่ตรงแน่นอน + timingSafeEqual จะ throw
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
บทเรียนที่ผมยึดตลอด: อย่าให้แต่ละ route เขียนตรรกะเทียบ signature เอง centralize primitive ตัวนี้ไว้ที่เดียว แล้วบังคับให้ทุก path เรียกผ่านมัน เพราะถ้าปล่อยให้ก็อปวางกันไปมา วันหนึ่งจะมีคนเผลอเขียน === กลับเข้ามาโดยไม่มีใครสังเกต security primitive ที่กระจัดกระจายคือ security primitive ที่จะรั่วในที่สุด
Pitfall 3: sign แค่ body เปิดช่อง replay และ endpoint-swap
สมมติเราแก้สองข้อบนแล้ว hash ตรง bytes เทียบ timing-safe เรียบร้อย ยังเหลือรูใหญ่ที่สุด: ถ้า signature ครอบแค่ body แปลว่า attacker ที่ดัก request เก่ามาได้หนึ่งอันสามารถส่งซ้ำได้เรื่อยๆ (replay) เพราะ body เดิม signature เดิม verify ผ่านทุกครั้ง และถ้าคุณมีหลาย endpoint ที่ใช้ secret เดียวกัน attacker เอา signed body ที่ตั้งใจส่งไป endpoint A ยิงไปที่ endpoint B ได้เลย (endpoint-swap) เพราะ signature ไม่รู้ว่ามันควรถูกส่งไปไหน
ทางแก้คือ sign เจตนาทั้งหมดของ request ไม่ใช่แค่เนื้อ body ประกอบ signing string จาก timestamp, HTTP method, path และ body รวมกัน แล้วเช็ค timestamp ให้อยู่ใน tolerance window:
function verifyWebhook(params: {
timestamp: string; // จาก header, ผู้ส่งใส่มา
method: string; // req.method
path: string; // req.originalUrl (path จริงที่ยิงมา)
rawBody: Buffer; // จาก verify callback
signature: string; // จาก header
secret: string;
}): boolean {
const { timestamp, method, path, rawBody, signature, secret } = params;
// 1) กัน replay: timestamp ต้องอยู่ในหน้าต่างที่ยอมรับ
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skewSeconds) || skewSeconds > 300) return false; // 5 นาที
// 2) signing string ผูกทุกอย่างที่ต้อง authenticate
const signingString = `${timestamp}.${method.toUpperCase()}.${path}.${rawBody.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signingString, 'utf8')
.digest('hex');
return safeEqual(expected, signature);
}
การใส่ timestamp เข้าไปใน HMAC ทำให้ attacker ปลอม timestamp ไม่ได้ — ถ้าแก้ timestamp เพื่อให้ผ่าน window signature ก็เพี้ยนทันที ส่วน tolerance window เป็น trade-off ตรงๆ: window แคบ = ทน clock skew ระหว่าง server สองฝั่งได้น้อย เสี่ยง reject ของจริง; window กว้าง = เปิดช่วงเวลาให้ replay ได้นานขึ้น ผมมักเริ่มที่ 5 นาทีแล้วปรับตาม NTP ของ infra จริง ถ้าอยากปิด replay ให้สนิทให้เก็บ signature/nonce ที่เคยเห็นไว้ใน cache ระยะสั้น แล้ว reject ตัวซ้ำ
การใส่ method กับ path ปิด endpoint-swap: signature ที่ sign ไว้สำหรับ POST /webhooks/payment จะใช้กับ POST /webhooks/refund ไม่ได้ เพราะ signing string ไม่ตรง
Pitfall 4: เอา webhook route ไปอยู่ใต้ auth group ปกติ
อีกกับดักเชิงสถาปัตยกรรมที่ผมเจอบ่อย: dev เอา webhook route ไปแขวนใต้ middleware ที่เช็ค session/JWT เหมือน route อื่น แล้วก็งงว่าทำไมมัน 401 ตลอด external caller เช่น payment gateway ไม่มี session ของคุณ ไม่มี user login ไม่มี cookie เขาไม่รู้จักระบบ auth ภายในของคุณเลย
สำหรับ webhook signature คือ auth — มันคือหลักฐานว่า request มาจากคนที่ถือ secret จริง แยก webhook route ออกจาก auth group ปกติให้ชัด อย่าให้ session middleware มาแตะ แล้วให้ signature verification เป็น gate เดียวของมัน กฎที่ผมใช้: ถ้า caller เป็นเครื่องภายนอกที่ไม่มีทาง login ได้ ตัว auth ของมันต้องเป็น cryptographic signature ไม่ใช่ระบบ identity ของ user
สรุป: "ดูเหมือน verify แล้ว" คือคำที่อันตรายที่สุด
ทุก pitfall ในบทความนี้มีลักษณะร่วมกันอย่างเดียว: signature ผ่านตอน request จริง แต่ bypass ได้ มันไม่ error ไม่ทำ test แดง เลยหลุด review ง่ายมาก ถ้าจะจำอะไรกลับไปข้อเดียว จำว่า signature คือเรื่องของ bytes ที่ส่งจริง และต้อง authenticate เจตนาทั้งหมดของ request
- Hash บน raw bytes — capture raw body ก่อน JSON parse แล้ว hash string เดิม อย่า re-serialize object ที่ parse แล้ว
- เทียบ constant-time — ใช้
timingSafeEqualพร้อม length-guard ก่อนเสมอ;===รั่ว timing ทีละ byte - Centralize primitive — มี safeEqual/verify ตัวเดียวให้ทุก route เรียก อย่าให้แต่ละ path เขียนเอง
- Sign เจตนาทั้งหมด — ผูก signature กับ
timestamp.METHOD.path.bodyเพื่อกัน replay และ endpoint-swap - Tolerance window = trade-off — แคบไปเสี่ยง clock skew, กว้างไปเปิด replay; เสริมด้วย nonce cache ถ้าต้องปิดสนิท
- Webhook route แยกจาก auth group — external caller ไม่มี session; signature คือ auth แทน
- Verify ด้วยสายตาคนโจมตี — ถามตัวเองว่า "ถ้าผมถือแค่ request เก่าหนึ่งอัน ผมปลอมอะไรผ่านได้บ้าง" ก่อนจะบอกว่างานเสร็จ




