Development

Webhook Security 2026: HMAC Signature ป้องกัน Replay Attack คู่มือ SME ไทย

คู่มือออกแบบ Webhook ให้ปลอดภัย ใช้ HMAC Signature ตรวจ payload, ป้องกัน Replay Attack ด้วย Timestamp + Nonce พร้อมตัวอย่างโค้ด Laravel และ Next.js สำหรับทีม dev SME ไทยปี 2026

AF
ADS FIT Team
·8 นาที
Share:

# 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 ปกติเพราะ:

  • **เปิด public** ไม่มี user login กั้น
  • **trigger ตามเหตุการณ์** ผู้รับ predict ไม่ได้ว่าจะมาเมื่อไหร่
  • **payload สำคัญทางการเงิน/ธุรกิจ** เช่น order paid, refund, subscription cancelled
  • ภัยหลัก ๆ ที่ต้องกลัว:

    | ภัย | ผลกระทบ | วิธีป้องกัน |

    |-----|----------|-------------|

    | 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 ที่ได้รับ แล้วเทียบ

    ข้อสำคัญ:

  • ใช้ **raw body** ก่อน parse JSON เสมอ (parse แล้ว whitespace เปลี่ยน hash จะเพี้ยน)
  • เทียบด้วย **constant-time compare** (`hash_equals` ใน PHP, `crypto.timingSafeEqual` ใน Node) ห้ามใช้ `===`
  • ใช้ `SHA-256` ขึ้นไป อย่าใช้ MD5/SHA-1
  • 3. Timestamp + Nonce ป้องกัน Replay Attack

    HMAC อย่างเดียวยังไม่พอ เพราะถ้า attacker ดักจับ webhook + signature ไว้ได้ เขายิงซ้ำได้เรื่อย ๆ ใส่ timestamp + nonce เพิ่ม

    แนวทาง:

  • ผู้ส่งฝัง timestamp ลง header เช่น `X-Timestamp: 1735689600`
  • HMAC คำนวณบน `timestamp.body` แทน body ล้วน
  • ผู้รับปฏิเสธถ้า `|now - timestamp| > 300` วินาที
  • เก็บ nonce/event_id ที่ประมวลผลไปแล้วใน Redis 10 นาที ถ้าซ้ำ → reject
  • 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

  • [ ] **Use HTTPS only** + HSTS, ห้าม endpoint webhook เป็น http://
  • [ ] **Verify signature ทุก request** อย่ายกเว้น "test mode"
  • [ ] **Use raw body** ก่อน parse
  • [ ] **Constant-time compare** ป้องกัน timing attack
  • [ ] **Reject if > 5 minutes**
  • [ ] **Idempotency key** เก็บ event_id ที่ใช้แล้วใน Redis
  • [ ] **Rotate secret ทุก 90 วัน** เก็บใน Vault/Infisical ไม่ใช่ `.env` ใน repo
  • [ ] **Return 2xx เร็ว** + push งานหนักลง queue
  • [ ] **Log raw payload + headers** เก็บไว้ debug 7 วัน
  • [ ] **Allowlist source IP** ถ้าผู้ส่งประกาศ IP range
  • 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

  • Webhook คือประตูหลังที่หลายทีมลืมล็อค → HMAC + Timestamp + Idempotency คือชุดเกราะมาตรฐาน
  • ใช้ raw body, constant-time compare, และ Redis เก็บ event_id เพื่อกัน replay
  • Rotate secret และเก็บใน secret manager เสมอ ไม่ใช่ .env ใน Git
  • ตั้ง SLA ให้ webhook handler ตอบกลับ <2 วินาที แล้วส่งงานหนักลง queue
  • ทีม ADS FIT ช่วย SME ไทยออกแบบและตรวจสอบ Webhook security audit ครอบคลุม Stripe, Omise, LINE, GBPrimePay และระบบภายในตามมาตรฐาน OWASP ASVS หากอยากตรวจระบบของคุณ ติดต่อเราเพื่อขอ free 30-min consultation หรืออ่านบทความที่เกี่ยวข้องเพิ่มเติม เช่น [API Rate Limiting](#) และ [Secrets Management ด้วย Infisical](#)

    Tags

    #Webhook Security#HMAC#API Security#Replay Attack#Laravel#Next.js

    สนใจโซลูชันนี้?

    ปรึกษาทีม ADS FIT ฟรี เราพร้อมออกแบบระบบที่ฟิตกับธุรกิจของคุณ

    ติดต่อเรา →

    บทความที่เกี่ยวข้อง