Bảo mật18 tháng 4, 20269 phút đọc

Xác minh chữ ký webhook của Stripe, GitHub và Shopify

Xác minh chữ ký webhook của từng nhà cung cấp: lỗi hay gặp, cách khoảng dung sai dấu thời gian hoạt động, và vì sao thử lại làm hỏng cách làm ngây thơ.

BA CHỮ KÝ, MỘT CÁI BẠN PHẢI XÁC MINHnhà cung cấpký trên byte thôStripe-Signatureedgexác minh, lưu lạiký lạimỗi lần gửidấu thời gian mớibạnheader gốc của nhà cung cấp được chuyển tiếp giữ nguyên dấu thời gian ban đầu, nên hết hạn khi thử lạiAnyHook-Signature được ký lại ở mỗi lần gửi, nên luôn nằm trong khoảng dung sai 5 phút
Có ba chữ ký. Handler của bạn chỉ nên phải kiểm tra một.

Nếu bạn xử lý webhook mà không xác minh chữ ký, bất kỳ ai trên internet cũng có thể giả mạo một payment_intent.succeeded gửi tới endpoint của bạn. Đây không phải lý thuyết. Đó là lỗ hổng xác thực phổ biến nhất trong các tích hợp webhook, và kẻ tấn công biết điều đó.

Bài này là hướng dẫn thực tế về xác minh chữ ký dành cho kỹ sư: mỗi nhà cung cấp làm gì, những cách tinh vi mà code triển khai hay làm sai, và cách xử lý gọn gàng dấu thời gian (timestamp) khi thử lại (retry).

TL;DR

  • Stripe, GitHub và Shopify đều dùng HMAC-SHA256 trên các byte thô của body. Đừng serialize lại JSON trước khi băm
  • Stripe gửi dấu thời gian t và chữ ký v1 dạng hex trong Stripe-Signature, với khoảng dung sai dấu thời gian 5 phút
  • GitHub gửi sha256=… trong X-Hub-Signature-256, không có dấu thời gian
  • Shopify gửi HMAC-SHA256 dạng base64 trong X-Shopify-Hmac-SHA256
  • Luôn so sánh trong thời gian hằng định (crypto.timingSafeEqual), không bao giờ dùng ===
  • Nếu các lần thử lại làm hỏng bước kiểm tra dấu thời gian, bạn cần một dịch vụ trung chuyển webhook (webhook relay) ký lại ở mỗi lần gửi

Vì sao nhà cung cấp ký webhook

URL webhook của bạn là công khai. Bất cứ thứ gì có URL đó đều có thể POST tới nó. Chữ ký chứng minh hai điều: request đến từ nhà cung cấp chứ không phải kẻ tấn công, và body không bị sửa trên đường truyền.

Cần nói rõ những gì nằm ngoài phạm vi đó, vì một bộ test toàn màu xanh ở đây là bằng chứng yếu hơn vẻ ngoài của nó. Bài Test chữ ký webhook của bạn chẳng chứng minh được gì có những fixture vẫn pass với đoạn code xác minh mà kẻ tấn công đi xuyên qua dễ dàng. Và chữ ký không nói gì về việc một sự kiện có đến hay không, đó là lý do một webhook thu hồi quyền bị bỏ sót là một sự cố bảo mật chứ không phải một khoảng trống trên biểu đồ, và vì sao nhật ký ghi lại những gì đã đến đáng được làm cho tamper-evident (mọi chỉnh sửa đều để lại dấu vết).

Không có xác minh, ai đoán ra hoặc tìm được URL webhook của bạn đều có thể gửi sự kiện giả. Có xác minh, họ còn cần thêm signing secret (khóa bí mật dùng để ký), thứ chỉ bạn và nhà cung cấp nắm giữ.

Stripe

Stripe gửi header Stripe-Signature có dạng:

t=1617223220,v1=3b8a92b...,v1=9d7ac...

t là dấu thời gian lúc webhook được gửi. v1 là HMAC-SHA256 của {timestamp}.{raw_body}, dạng hex. Trong lúc xoay vòng khóa, header có nhiều mục v1.

Các bước xác minh:

  1. Tách t và các giá trị v1 từ header
  2. Từ chối nếu t cũ hơn khoảng 5 phút (chống tấn công phát lại, tức replay attack)
  3. Tính HMAC_SHA256(signing_secret, t + "." + raw_body)
  4. So sánh trong thời gian hằng định với từng v1, và chấp nhận nếu có bất kỳ giá trị nào khớp
import crypto from "node:crypto";

// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyStripe(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
  );
  const t = parts.t;
  const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");

  return v1s.some((sig) => safeEqual(expected, sig));
}
HMAC TÍNH TRÊN BYTE, KHÔNG PHẢI TRÊN Ý NGHĨAbyte thô nhận được{"id":"evt_1","n":2}req.text()hmackhớpparse + stringify lại{ "n": 2, "id": "evt_1" }req.json()hmackhông khớpcùng dữ liệucùng dữ liệu. khác thứ tự key,khác khoảng trắng, và payloadtrông vẫn hoàn hảo trong log.
Hai object là cùng một sự kiện. Chỉ một trong hai còn băm ra đúng chữ ký mà nhà cung cấp đã gửi.

Lỗi thường gặp ở đây là parse body thành JSON trước rồi stringify lại. Việc đó làm thay đổi khoảng trắng. Chữ ký được tính trên các byte thô, nên bạn phải xác minh với đúng các byte bạn đã nhận.

GitHub

GitHub gửi cả X-Hub-Signature (SHA1, kiểu cũ) lẫn X-Hub-Signature-256 (SHA256). Luôn dùng phiên bản 256.

Định dạng: sha256=3b8a92b...

Xác minh:

function verifyGitHub(body: string, header: string, secret: string): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return safeEqual(expected, header);
}

Đơn giản hơn Stripe, không có dấu thời gian và không chống tấn công phát lại. GitHub dựa vào việc secret được giữ bí mật.

Shopify

Shopify gửi X-Shopify-Hmac-SHA256 là HMAC-SHA256 của raw body ở dạng base64 thay vì hex.

function verifyShopify(body: string, header: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("base64");
  return safeEqual(expected, header);
}

Cùng cái bẫy như Stripe: cho body đi qua một JSON serializer trước khi băm sẽ làm hỏng chữ ký. Hãy băm các byte thô.

Vấn đề dấu thời gian khi thử lại

Khoảng dung sai dấu thời gian 5 phút của Stripe có nghĩa là nếu Stripe thử lại một sự kiện 10 phút sau lần gửi đầu với header ban đầu, dấu thời gian của nó sẽ bị coi là quá cũ theo chính tiêu chuẩn của logic thử lại.

Trên thực tế, Stripe tạo lại chữ ký ở mỗi lần thử lại với một t mới, nên việc này vẫn ổn. Nhưng trong những hệ thống cache rồi phát lại (replay), hoặc đứng sau một hàng đợi, bạn có thể rốt cuộc phát lại sự kiện với dấu thời gian đã cũ. Cách sửa là ký lại với dấu thời gian mới tại ranh giới nơi bạn phát lại, bằng signing key của chính bạn.

Đó chính xác là điều AnyHook làm. Các header gốc của nhà cung cấp được chuyển tiếp nguyên vẹn, nên code xác minh của bạn vẫn chạy ở lần gửi đầu tiên (ở lần thử lại hoặc phát lại, chúng mang dấu thời gian gốc của nhà cung cấp), nhưng AnyHook còn ký mọi lượt gửi đi bằng header AnyHook-Signature của riêng mình. Header đó là HMAC-SHA256, dạng hex, theo cấu trúc tương thích với Stripe t={ts},v1={hex}. Nó được ký lại ở mọi lần thử lại với dấu thời gian mới, nên khoảng dung sai 5 phút vẫn hoạt động đúng ngay cả trong những chuỗi thử lại dài, và nó dùng signing secret riêng cho từng đích, nên lộ secret của một endpoint không làm lộ các endpoint khác.

function verifyAnyHook(body: string, header: string, secret: string): boolean {
  const t = header.match(/t=(\d+)/)?.[1];
  const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");
  return safeEqual(expected, v1);
}

Một hàm xác minh thay vì ba, và không còn chuyện dấu thời gian kỳ quặc khi thử lại. Nhà cung cấp vẫn ký request gốc, AnyHook xác minh chữ ký đó tại edge (lớp máy chủ biên của Cloudflare), rồi ký lại mỗi lượt gửi bằng chữ ký mới của riêng mình.

Checklist: những điều cần làm đúng

  • Xác minh trên các byte thô của body, không phải JSON đã serialize lại
  • So sánh trong thời gian hằng định (crypto.timingSafeEqual), không dùng ===
  • Từ chối request thiếu header chữ ký, và đừng mặc định “cho qua”
  • Khoảng dung sai dấu thời gian: 5 phút là chuẩn. Ngắn hơn thì an toàn hơn nhưng làm hỏng các lần thử lại hợp lệ
  • Lưu signing secret ở dạng mã hóa khi lưu trữ, không để trong biến môi trường trên laptop của developer
  • Xoay vòng signing secret theo lịch, và xử lý khoảng thời gian chuyển giao (cả khóa cũ và khóa mới đều hợp lệ trong 24 giờ)

Mục raw body là mục gây đau đầu nhất trên serverless, vì framework đọc hết stream trước khi code của bạn chạy. Bài Nhận webhook trên Vercel mà không bị mất có phiên bản cho App Router và Pages Router, bao gồm cả body parser âm thầm làm hỏng nó.

Điều cần nhớ

Hầu hết nhà cung cấp dùng HMAC-SHA256 trên raw body; Twilio, Intercom và Vercel dùng HMAC-SHA1, còn Discord, SendGrid và PayPal dùng chữ ký khóa công khai. Giữa các bên dùng HMAC, khác biệt nằm ở kiểu encoding (hex hay base64) và việc có kèm dấu thời gian hay không, và mọi cơ chế chúng tôi đã triển khai đều được lập bảng trong tài liệu tham khảo về các nhà cung cấp webhook. Nếu bạn muốn chỉ phải xác minh một header thay vì ba, hãy trỏ các nhà cung cấp tới một relay. AnyHook gom chúng về một mối (fan-in) và ký lại mỗi lượt gửi đi bằng một cơ chế thống nhất duy nhất.

Tất cả bài viết18 tháng 4, 2026 · 9 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.