~/blog/laravel-webhook-security-verification-guide-2026.md
網站安全與防護 · 2026 / 02 / 22

把 Webhook 鎖好:Laravel 簽名驗證與重放攻擊防護實戰

Eric — 浪花科技創辦人 / AI 架構師
Eric
浪花科技創辦人 · AI 架構師
把 Webhook 鎖好:Laravel 簽名驗證與重放攻擊防護實戰
目錄 table-of-contents.md

多數 Webhook 災難不是被駭客硬闖進來的,而是開發者自己把門敞開——端點收到請求就直接處理,連對方是誰都沒驗證。金流通知、訂單回拋這些走 Webhook 的關鍵流程,只要能被偽造或重放,損失就是真金白銀。這篇用 Laravel 實作簽名驗證與重放攻擊防護,把這扇門徹底鎖好。

現在已經是 2026 年了,如果你還在寫那種「收到請求就直接處理」的 Webhook,那我得嚴肅地告訴你:你的 API 正在網路上裸奔。在這個 AI bot 滿街跑、自動化攻擊腳本比外送員還勤勞的年代,Webhook 往往是駭客入侵系統最喜歡走的「後門」。為什麼?因為很多開發者會為了方便,忽略了驗證發送者的真實身份,結果就是資料庫被髒資料灌爆,甚至觸發了不該觸發的付款邏輯。

今天這篇文章,不講虛的理論,我們直接上 Laravel 實戰。我會帶大家從最基礎的簽名驗證(Signature Verification),一路講到防止重放攻擊(Replay Attack)的進階防禦,並且教你如何優雅地處理佇列(Queue),保證你的系統既安全又高效。

為什麼 Webhook 需要設防?不只是為了擋駭客

很多新手工程師會問:「Eric,我的 Webhook URL 只有我自己和第三方服務商(例如 Stripe、Line Pay 或 Slack)知道,網址設得複雜一點,像亂碼一樣,不就安全了嗎?」

這就是典型的「隱匿式安全」(Security by Obscurity)思維,在 2026 年這完全行不通。只要你的 URL 暴露在公網,就有可能被掃描到,或者因為日誌洩漏而被中間人截獲。一旦洩漏,任何人都可以偽造一個 POST 請求打進你的伺服器。

想像一下這個場景:你的系統有一個 Webhook 是用來接收「付款成功」通知的。如果我偽造了一個 Payload,告訴你的系統「訂單 #12345 已經付款」,而你沒有驗證簽名,你的程式碼就會乖乖地把商品出貨給還沒付錢的人。這不只是 Bug,這是災難。

第一道防線:簽名驗證 (Signature Verification)

這是最基本,也是絕對不能省的步驟。原理很簡單:發送方(Sender)會用一個只有你們雙方知道的「密鑰(Secret)」,配合當次請求的內容(Body),透過演算法(通常是 HMAC-SHA256)算出一個簽名(Signature),並放在 Header 裡傳給你。

你的任務就是:用同樣的密鑰和內容,算一次簽名,然後比對兩者是否一致。

Laravel Middleware 實作範例

千萬不要把驗證邏輯寫在 Controller 裡,那樣會讓你的程式碼像義大利麵一樣亂。Eric 強烈建議使用 Middleware 來處理這種請求過濾。

以下是一個適用於 2026 年 Laravel 環境(假設是 Laravel 12/13)的 Middleware 範例:


<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class VerifyWebhookSignature
{
    /**
     * 處理傳入的請求
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        // 1. 從 Header 獲取簽名,這裡以 Stripe 風格為例
        $signature = $request->header('X-Webhook-Signature');

        if (!$signature) {
            throw new AccessDeniedHttpException('Missing Webhook Signature');
        }

        // 2. 獲取原始 payload (Raw Body)
        // 注意:一定要用原始內容,不能用解析過的 JSON,因為空格或換行差異都會導致雜湊不同
        $payload = $request->getContent();

        // 3. 獲取雙方約定的密鑰 (建議放在 .env)
        $secret = config('services.webhook.secret');

        // 4. 計算預期簽名 (HMAC-SHA256)
        $expectedSignature = hash_hmac('sha256', $payload, $secret);

        // 5. 比對簽名 (使用 hash_equals 防止時序攻擊)
        if (!hash_equals($expectedSignature, $signature)) {
            // 記錄這類失敗通常很有價值,可能是攻擊嘗試
            \Log::warning('Webhook 簽名驗證失敗', ['ip' => $request->ip()]);
            throw new AccessDeniedHttpException('Invalid Webhook Signature');
        }

        return $next($request);
    }
}

Eric 的小囉嗦: 注意到了嗎?我在比對時使用了 hash_equals 而不是普通的 ===。這是為了防止時序攻擊(Timing Attack)。普通的字串比對一旦發現第一個字元不同就會停止,駭客可以利用回應時間的微小差異來猜測正確的簽名。hash_equals 則保證無論結果如何,運算時間都是恆定的。

第二道防線:防禦重放攻擊 (Replay Attack)

簽名驗證擋住了偽造內容的人,但擋不住「偷聽」的人。如果駭客攔截了一個合法的請求(包含正確的簽名),然後在十分鐘後原封不動地再發送一次給你,你的系統會再次驗證通過,導致重複扣款或重複觸發邏輯。

這就是重放攻擊。解決方法是檢查時間戳記(Timestamp)

現代的 Webhook Provider(如 Stripe, Line)通常會在 Header 裡包含發送時間。我們需要在 Middleware 裡加上這段邏輯:


// ... 接續上面的 Middleware

// 假設 Header 格式是 t=1735689600,v1=...
// 我們需要先解析出 timestamp
$timestamp = $this->extractTimestamp($signature); 

// 設定容許的時間差,例如 5 分鐘 (300秒)
$tolerance = 300;

if (time() - $timestamp > $tolerance) {
    throw new AccessDeniedHttpException('Webhook Timestamp Expired');
}

// 注意:在計算簽名時,通常也要把 timestamp 串接進 payload 裡一起算
// 具體規則要看該服務商的文件

第三道防線:等冪性 (Idempotency) 與佇列處理

就算沒有駭客,網路本身也是不可靠的。對方發送 Webhook 給你,如果你回應太慢或逾時(Timeout),對方通常會啟動 Retry 機制,導致你收到兩次一樣的請求。

1. 快速回應,非同步處理

Webhook 的處理原則是:接電話要快,辦事可以慢。不要在 Controller 裡做耗時的運算(如發送 Email、生成 PDF)。

  • 收到請求 -> 驗證簽名 -> Dispatch Job 到 Queue -> 回傳 HTTP 200 OK。
  • 讓對方知道你收到了,剩下的你自己慢慢做。

public function handleWebhook(Request $request)
{
    // 驗證邏輯已在 Middleware 處理

    $payload = $request->all();

    // 丟給佇列去跑
    ProcessWebhookJob::dispatch($payload);

    return response()->json(['status' => 'success']);
}

2. 處理重複請求 (Idempotency)

在你的 Job 裡面,要確保同一個 Event ID 只被處理一次。你可以利用資料庫的 Unique Key 或 Redis 來記錄已處理過的 ID。


public function handle()
{
    $eventId = $this->payload['id'];

    // 使用 Redis 原子鎖,避免併發時的問題
    // 在 2026 年,Laravel 的 Cache::lock 非常好用
    $lock = Cache::lock('webhook_event_' . $eventId, 10);

    if ($lock->get()) {
        // 檢查資料庫是否已存在此訂單紀錄
        if (Order::where('transaction_id', $eventId)->exists()) {
             return; // 已經處理過,直接跳過
        }

        // 執行你的業務邏輯...
        
        $lock->release();
    } else {
        // 拿不到鎖,代表有另一個 Process 正在處理這個 Webhook
        // 可以選擇 release job 稍後重試,或直接忽略
    }
}

總結:安全是設計出來的,不是補出來的

在 2026 年開發 Webhook,如果還停留在「有收到就好」的思維,那真的是在替自己埋地雷。總結一下今天的重點:

  • 驗證簽名 (Signature): 使用 Middleware 和 hash_equals,這是底線。
  • 檢查時間 (Timestamp): 設定合理的容忍窗口,杜絕重放攻擊。
  • 使用佇列 (Queue): 快速回應 200,避免對方逾時重送。
  • 實作等冪 (Idempotency): 確保同一個 Event ID 不會造成重複扣款或資料錯亂。

希望這篇文章能幫助大家把自家的 API 堡壘蓋得更堅固。寫程式最怕的就是「想當然爾」,多一層驗證,晚上就能多睡一小時的好覺。

如果你對於企業級的 Laravel 架構、API 資安防護,或是 WordPress 與 CRM 的深度串接有任何疑問,歡迎隨時找我們聊聊。浪花科技專注於解決複雜的系統整合難題,我們下次見!

延伸閱讀

您的企業系統需要最高規格的資安檢測與架構優化嗎?別讓潛在漏洞成為業務風險。

立即聯繫浪花科技,打造堅不可摧的數位防線
// FAQ

常見問題

把 Webhook 網址設得很複雜、像亂碼一樣,是不是就安全了?
這是典型的「隱匿式安全」(Security by Obscurity)思維,並不可靠。只要 URL 暴露在公網就可能被掃描到或因日誌洩漏而被截獲,一旦洩漏,任何人都能偽造 POST 請求打進伺服器。正確做法是用簽名驗證確認發送者身份。
Laravel 的 Webhook 簽名驗證該寫在哪一層?
建議寫在 Middleware,而不是塞在 Controller 裡,這樣能讓請求過濾與業務邏輯分離、程式碼更清晰好維護。Middleware 負責從 Header 取得簽名、用密鑰對原始 payload 重算 HMAC-SHA256,比對失敗就直接拒絕請求。
比對 Webhook 簽名時為什麼要用 hash_equals 而不是普通的 === 比對?
因為普通字串比對一旦發現第一個字元不同就會停止,駭客可利用回應時間的微小差異進行時序攻擊(Timing Attack)猜測正確簽名。hash_equals 無論結果如何運算時間都恆定,能防止這類攻擊。
處理 Webhook 時為什麼要先回傳 200 再非同步處理?
Webhook 的原則是「接電話要快,辦事可以慢」。若在 Controller 裡做發送 Email、生成 PDF 等耗時運算而回應太慢,對方會因逾時啟動 Retry 機制,導致你收到重複請求。正確流程是驗證簽名後把工作 Dispatch 到 Queue,立即回傳 HTTP 200,剩下的交給佇列慢慢處理。
如何避免重複的 Webhook 請求被處理兩次(等冪性)?
在 Job 裡確保同一個 Event ID 只被處理一次,可利用資料庫的 Unique Key 或 Redis 記錄已處理的 ID。文中以 Laravel 的 Cache::lock 取得原子鎖,並檢查資料庫是否已存在該筆紀錄,已處理過就直接跳過。
~/roamer-tech/newsletter // FREE
// newsletter

訂閱免費電子報

把 AI 自動化、企業系統設計與 WordPress / Laravel 開發的真實案例和可直接照做的技巧,整理成電子報寄給你。只寄精選內容、不灌垃圾信,一鍵就能退訂。

$
// final.exec()

準備好讓你的網站開始為你工作了嗎?