# Webhook Security 2026: HMAC Signature ป้องกัน Replay Attack คู่มือ SME ไทย
ในยุคที่ระบบ SaaS เชื่อมต่อกันด้วย Webhook กว่า 90% ของ SME ไทยที่ใช้ Stripe, LINE OA, Omise, Shopify หรือ Slack ต่างต้องเปิด endpoint สาธารณะรับ payload จากภายนอก แต่ endpoint แบบนี้คือเป้าหมายแรก ๆ ของ attacker เพราะใคร ๆ ก็ POST เข้ามาได้
ปัญหาคือ ทีม dev จำนวนมากตรวจแค่ "URL ถูก" แล้วเชื่อ payload เลย ทำให้เกิดเหตุการณ์ยอดฮิตเช่น สั่งซื้อปลอม, อัปเดตสถานะ payment ผิด, หรือถูกยิง replay ซ้ำพันรอบจน DB เพี้ยน
บทความนี้จะอธิบายวิธีปกป้อง Webhook ระดับ production ด้วย HMAC Signature + Timestamp + Nonce พร้อมตัวอย่างโค้ดทั้งฝั่ง Laravel (PHP) และ Next.js (Node) ที่ทีม SME ไทยเอาไป plug-in ได้ทันที
1. ทำไม Webhook ถึงเสี่ยงกว่า REST API ทั่วไป
Webhook ต่างจาก API ปกติเพราะ:
ภัยหลัก ๆ ที่ต้องกลัว:
| ภัย | ผลกระทบ | วิธีป้องกัน |
|-----|----------|-------------|
| Spoofed payload | สั่ง mark ออเดอร์เป็น paid โดยไม่ได้จ่ายจริง | HMAC Signature |
| Replay attack | ส่ง webhook เดิมซ้ำ → ยิง stock/credit หลายรอบ | Timestamp + Nonce |
| Tampered payload | แก้ amount ใน body | HMAC ครอบ raw body |
| Slowloris / DoS | เปิด connection ค้าง | Rate limit + timeout |
| SSRF callback | บังคับให้ webhook ไป fetch URL อันตราย | Allowlist domain |
2. HMAC Signature ทำงานยังไง
HMAC = Hash-based Message Authentication Code คือการเอา shared secret มา hash รวมกับ payload เพื่อพิสูจน์ว่า "ผู้ส่งรู้ secret และ payload ไม่ถูกแก้"
ขั้นตอน:
1. ผู้ส่งและผู้รับมี secret ตัวเดียวกัน (เช่น `whsec_xxx`)
2. ผู้ส่งคำนวณ `signature = HMAC_SHA256(secret, raw_body)`
3. ส่ง header เช่น `X-Signature: sha256=<hex>`
4. ผู้รับคำนวณซ้ำด้วย body ที่ได้รับ แล้วเทียบ
ข้อสำคัญ:
3. Timestamp + Nonce ป้องกัน Replay Attack
HMAC อย่างเดียวยังไม่พอ เพราะถ้า attacker ดักจับ webhook + signature ไว้ได้ เขายิงซ้ำได้เรื่อย ๆ ใส่ timestamp + nonce เพิ่ม
แนวทาง:
4. ตัวอย่างโค้ด Laravel (PHP)
สร้าง middleware ตรวจ signature ก่อนส่งเข้า controller:
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$secret = config('services.partner.webhook_secret');
$signature = $request->header('X-Signature', '');
$timestamp = (int) $request->header('X-Timestamp', 0);
$eventId = $request->header('X-Event-Id', '');
$rawBody = $request->getContent();
// 1. Reject if too old (>5 minutes)
if (abs(time() - $timestamp) > 300) {
abort(400, 'Stale webhook');
}
// 2. Verify HMAC
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
abort(401, 'Invalid signature');
}
// 3. Reject duplicate event
$key = "webhook:seen:{$eventId}";
if (!Cache::add($key, 1, now()->addMinutes(10))) {
abort(409, 'Duplicate event');
}
return $next($request);
}
}
```
ลงทะเบียนใน `bootstrap/app.php` แล้ว apply เฉพาะ route webhook:
```php
Route::post('/webhooks/payment', [PaymentWebhookController::class, 'handle'])
->middleware('webhook.signature');
```
5. ตัวอย่างโค้ด Next.js (App Router)
```typescript
// app/api/webhooks/payment/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
import { redis } from '@/lib/redis';
export const runtime = 'nodejs'; // need raw body, not Edge
export async function POST(req: NextRequest) {
const secret = process.env.WEBHOOK_SECRET!;
const signature = req.headers.get('x-signature') ?? '';
const timestamp = Number(req.headers.get('x-timestamp') ?? 0);
const eventId = req.headers.get('x-event-id') ?? '';
const rawBody = await req.text(); // ห้าม req.json() ก่อนตรวจ
// 1. Stale check
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return NextResponse.json({ error: 'stale' }, { status: 400 });
}
// 2. HMAC verify with constant-time compare
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return NextResponse.json({ error: 'bad signature' }, { status: 401 });
}
// 3. Idempotency
const set = await redis.set(`webhook:${eventId}`, '1', { NX: true, EX: 600 });
if (!set) {
return NextResponse.json({ status: 'duplicate' }, { status: 200 });
}
// 4. Process
const event = JSON.parse(rawBody);
// ... handle event ...
return NextResponse.json({ status: 'ok' });
}
```
6. Checklist ก่อน Deploy
7. เปรียบเทียบ HMAC vs mTLS vs JWT
| แนวทาง | ใช้กรณี | ข้อดี | ข้อเสีย |
|--------|----------|--------|---------|
| HMAC Signature | ทั่วไป (Stripe, LINE, Slack) | ตั้งค่าง่าย, รองรับทุกภาษา | ต้องแชร์ secret |
| mTLS | B2B องค์กรใหญ่ | ปลอดภัยสูงสุด | จัดการ certificate ยาก |
| JWT (signed) | ระบบ federated | มี claim/expiry ในตัว | payload เปิดเผยถ้าไม่ encrypt |
สำหรับ SME ไทยส่วนใหญ่ HMAC เพียงพอและ ROI ดีที่สุด
8. สรุป + Call to Action
ทีม ADS FIT ช่วย SME ไทยออกแบบและตรวจสอบ Webhook security audit ครอบคลุม Stripe, Omise, LINE, GBPrimePay และระบบภายในตามมาตรฐาน OWASP ASVS หากอยากตรวจระบบของคุณ ติดต่อเราเพื่อขอ free 30-min consultation หรืออ่านบทความที่เกี่ยวข้องเพิ่มเติม เช่น [API Rate Limiting](#) และ [Secrets Management ด้วย Infisical](#)