cat blog/.md
Webhook security: HMAC signature và chống replay attack đúng cách
Cách đây vài tháng tôi nhận được một bug report khá lạ: một merchant phản ánh đơn hàng trên hệ thống bị “đánh dấu đã thanh toán” trong khi họ chưa nhận được tiền. Log thì cho thấy chính endpoint webhook /webhooks/payment của chúng tôi đã được gọi, payload hợp lệ, response 200 OK. Không có gì bất thường.
Vấn đề là: ai gọi endpoint đó? Endpoint public, payload không ký, chỉ kiểm tra một secret_token trong query string. Token đó đã bị leak ra một public repo của intern cách đây 2 tuần. Bất kỳ ai biết URL + token đều có thể bắn webhook giả vào hệ thống.
Đây là bug điển hình của webhook không bảo mật. Bài này tôi tổng hợp lại cách Stripe, GitHub, Shopify đang làm — và những lỗi nhỏ tôi từng dính khi tự implement.
Vì sao “secret token trong header” không đủ
Cách dễ nhất ai cũng nghĩ ra:
POST /webhooks/payment
X-Webhook-Secret: my-super-secret-token
Content-Type: application/json
{"order_id": 123, "status": "paid", "amount": 100000}
Server kiểm tra X-Webhook-Secret == os.environ["WEBHOOK_SECRET"] rồi xử lý. Đơn giản, nhanh, và sai.
Lý do:
- Secret là plaintext trên đường truyền. Nếu một proxy/log nào đó (nginx access log, CDN log, Cloudflare logs, APM) ghi lại request, secret nằm chềnh ềnh trong đó. HTTPS chỉ bảo vệ giữa client–server, không bảo vệ khỏi infra trung gian của chính bạn.
- Không xác thực được payload. Kẻ tấn công có thể giữ nguyên secret nhưng đổi
amounttừ 100.000đ → 10.000.000đ. Server vẫn pass auth. - Không chống được replay. Bắt được 1 request hợp lệ rồi gửi lại N lần — cùng đơn được đánh dấu đã trả nhiều lần.
Giải pháp chuẩn: HMAC signature trên payload + timestamp.
Cơ chế HMAC signature
Nguyên tắc: cả hai bên có chung một secret. Bên gửi dùng secret để ký payload (tạo signature). Bên nhận dùng cùng secret để ký lại và so sánh signature. Secret không bao giờ rời khỏi mỗi bên — chỉ signature đi trên dây.
Ký bằng HMAC-SHA256 vì:
- Nhanh, có sẵn trong stdlib mọi ngôn ngữ
- Không bị collision như MD5/SHA1
- Khác với hash thuần — HMAC cần key, không thể tính lại nếu không có secret
Phía gửi:
import hmac
import hashlib
import time
import json
def sign_payload(secret: str, payload: bytes, timestamp: int) -> str:
# Stripe style: ký "{timestamp}.{payload}"
signed = f"{timestamp}.".encode() + payload
sig = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return f"t={timestamp},v1={sig}"
body = json.dumps({"order_id": 123, "amount": 100000}).encode()
ts = int(time.time())
signature = sign_payload("whsec_xxx", body, ts)
# Gửi đi:
# POST /webhooks/payment
# Stripe-Signature: t=1717286400,v1=5257a869...
Phía nhận:
import hmac
import hashlib
import time
MAX_SKEW_SECONDS = 300 # 5 phút
class WebhookVerifyError(Exception):
pass
def verify_signature(secret: str, payload: bytes, header: str) -> None:
# Parse "t=...,v1=..."
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
received_sig = parts["v1"]
# 1. Reject nếu timestamp quá cũ (chống replay)
if abs(time.time() - ts) > MAX_SKEW_SECONDS:
raise WebhookVerifyError("timestamp out of window")
# 2. Ký lại và so sánh
signed = f"{ts}.".encode() + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# 3. So sánh constant-time — KHÔNG dùng == thường
if not hmac.compare_digest(expected, received_sig):
raise WebhookVerifyError("signature mismatch")
Bốn điểm cần để ý ở đoạn verify, đây mới là phần dễ làm sai.
Cạm bẫy 1: dùng == thay vì compare_digest
Đây là lỗi tinh vi nhất. So sánh chuỗi bằng == trong Python (và hầu hết ngôn ngữ) dừng sớm khi gặp ký tự khác nhau. Nghĩa là so sánh "abcd..." == "axxx..." kết thúc sau 2 byte, còn "abcd..." == "abcx..." mất 4 byte. Chênh nhau vài chục nanosecond.
Bình thường không ai care. Nhưng kẻ tấn công có thể đo response time qua hàng nghìn request để dò từng byte signature — gọi là timing attack. Có thật, đã từng được dùng để break các implementation API key của các fintech startup.
hmac.compare_digest() so sánh constant-time, luôn duyệt hết chuỗi dù sai từ byte đầu. Mọi ngôn ngữ đều có hàm tương đương: crypto.timingSafeEqual (Node.js), subtle.ConstantTimeCompare (Go), hash_equals (PHP).
Cạm bẫy 2: ký JSON sau khi parse
Tôi từng viết kiểu này:
body = await request.json() # đã parse thành dict
payload_bytes = json.dumps(body).encode() # serialize lại để ký
verify_signature(secret, payload_bytes, header) # FAIL
Vấn đề: json.dumps() của Python sắp xếp key, format khoảng trắng, escape unicode khác client. Chỉ cần 1 byte khác là signature toang.
Luôn ký trên raw bytes. Trong FastAPI:
@app.post("/webhooks/payment")
async def payment_webhook(request: Request):
body = await request.body() # raw bytes — đây mới đúng
sig_header = request.headers.get("X-Signature", "")
verify_signature(WEBHOOK_SECRET, body, sig_header)
# Parse SAU KHI verify
event = json.loads(body)
...
Một biến thể nguy hiểm hơn: middleware hoặc proxy can thiệp body (gzip decompress, charset convert, json minify). Test kỹ trên môi trường production — không chỉ localhost.
Cạm bẫy 3: bỏ timestamp, không chống replay
Có HMAC rồi không nghĩa là chống được replay. Nếu signature không gắn timestamp, kẻ tấn công bắt được 1 request hợp lệ là có thể gửi lại vĩnh viễn.
Hai lớp bảo vệ:
- Timestamp trong vùng cho phép: thường 5 phút. Quá cũ → reject. Đây là lý do tôi đưa
tsvào chuỗi ký — kẻ tấn công không sửa đượctsmà không break signature. - Idempotency key: lưu ID của event (Stripe gửi
evt_xxxtrong payload) vào DB, gặp lại thì skip. Đây là chốt cuối — vì 5 phút vẫn đủ để attacker replay nếu hắn bắt request rồi gửi lại ngay.
async def handle_event(event: dict, db):
event_id = event["id"] # vd: "evt_1HxxxxxKZ"
# Insert vào bảng processed_events với UNIQUE constraint trên event_id
try:
await db.execute(
"INSERT INTO processed_events (event_id, processed_at) VALUES (:id, NOW())",
{"id": event_id},
)
except IntegrityError:
# Đã xử lý rồi — bỏ qua, return 200 cho provider khỏi retry
return
await actually_process(event)
Bài về Idempotency Key tôi viết trước có nói kỹ hơn về phần này.
Cạm bẫy 4: rotate secret như thế nào
Một ngày đẹp trời bạn cần đổi WEBHOOK_SECRET (intern leak, ex-employee, audit yêu cầu…). Nếu chỉ có 1 secret, đổi xong là webhook đứt cho đến khi provider cập nhật.
Cách làm đúng: support nhiều secret cùng lúc, verify với từng secret cho đến khi pass.
ACTIVE_SECRETS = [
os.environ["WEBHOOK_SECRET_PRIMARY"],
os.environ.get("WEBHOOK_SECRET_PREVIOUS", ""),
]
def verify_with_rotation(payload: bytes, header: str) -> None:
last_error = None
for secret in ACTIVE_SECRETS:
if not secret:
continue
try:
verify_signature(secret, payload, header)
return # pass với 1 trong các secret
except WebhookVerifyError as e:
last_error = e
raise last_error or WebhookVerifyError("no valid secret")
Quy trình rotate:
- Sinh secret mới, set
WEBHOOK_SECRET_PRIMARY = new,WEBHOOK_SECRET_PREVIOUS = old - Cập nhật secret bên provider (dashboard Stripe, GitHub settings…)
- Đợi vài giờ cho mọi in-flight webhook xong
- Xóa
WEBHOOK_SECRET_PREVIOUS
Stripe gọi đây là “rolling secret”, Shopify cũng tương tự. GitHub thì khắt khe hơn: chỉ 1 secret tại một thời điểm, bạn phải tự xử downtime.
IP whitelist — có nên không?
Cá nhân tôi vẫn thêm IP whitelist như defense-in-depth, nhưng không bao giờ dùng thay HMAC. Lý do:
- Provider có thể đổi IP range bất cứ lúc nào (đã xảy ra với Stripe 2 lần trong 3 năm qua)
- Sau Cloudflare/Load Balancer, IP thấy được là LB chứ không phải client gốc — phải đọc
X-Forwarded-Forđúng cách - IP spoof khó nhưng không phải không thể (BGP hijack, SSRF qua chính hệ thống bạn)
HMAC là chốt chính, IP là tầng phụ.
Checklist khi build webhook endpoint
- Đọc
request.body()raw, không parse JSON trước verify - HMAC-SHA256 hoặc mạnh hơn, không SHA1/MD5
- So sánh signature bằng
compare_digest(constant-time) - Có timestamp trong vùng ký, reject nếu lệch quá 5 phút
- Idempotency check trên event ID (UNIQUE constraint trong DB)
- Support nhiều secret để rotate không downtime
- Return 2xx nhanh, xử lý nặng đưa vào queue (provider sẽ retry nếu timeout)
- Log signature không log secret
- HTTPS only, HSTS bật
Cái cuối cùng quan trọng nữa: idempotency của xử lý phía bạn. HMAC chống được giả mạo, idempotency chống được trùng lặp do retry hợp lệ (provider retry khi bạn 5xx). Cả hai phải có.
Lời kết
Webhook là kênh integration phổ biến nhất giữa các SaaS, nhưng cũng là attack surface bị xem nhẹ nhất. Đa số developer dừng lại ở “thêm secret vào header” và coi là xong.
Sau vụ leak token tôi kể ở đầu bài, tôi dành 2 buổi rewrite endpoint webhook đó theo đúng pattern Stripe: HMAC + timestamp + idempotency + rotation. Code dài thêm ~50 dòng, nhưng từ đó không còn lo ai gọi endpoint của mình ngoài provider thật.
Nếu hệ thống của bạn đang nhận webhook từ payment gateway, CRM, hoặc bất kỳ third-party nào — mở endpoint đó ra và check 7 dòng đầu của hàm verify. Đủ cả 4 thứ trên chưa?
cat comments.log