Webhooky bezpečně: ověření HMAC podpisu, retry a idempotence
Jak správně přijímat webhooky: ověření HMAC podpisu nad raw tělem, ochrana proti replay útokům, rychlá odpověď, fronta a deduplikace událostí.
Webhook je obyčejný HTTP požadavek, kterým vám cizí služba (platební brána, GitHub, e-shopová platforma) oznamuje, že se něco stalo. Endpoint pro webhooky je veřejně dostupný, takže na něj může poslat požadavek kdokoli. Bez ověření by si útočník mohl „zaplatit“ objednávku jedním curlem.
Jak funguje podpis
Služba i vy znáte sdílené tajemství (webhook secret). Při odeslání služba spočítá HMAC-SHA256 z těla požadavku (často i s časovým razítkem) a pošle ho v hlavičce. Vy spočítáte totéž a porovnáte.
Konkrétní hlavičky se liší:
| Služba | Hlavička | Co se podepisuje |
|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=<hex> |
raw tělo |
| Stripe | Stripe-Signature: t=...,v1=... |
timestamp.tělo |
| Standard Webhooks | webhook-id, webhook-timestamp, webhook-signature |
id.timestamp.tělo |
Vždy se řiďte dokumentací konkrétní služby a pokud existuje oficiální SDK, použijte jeho funkci pro ověření.
Nejčastější chyba: podpis nad parsovaným JSONem
Podpis se počítá z přesných bajtů těla. Pokud framework tělo nejdřív naparsuje do objektu a vy ho znovu serializujete, změní se mezery nebo pořadí klíčů a podpis nesedí. Potřebujete raw tělo.
Ověření v Pythonu (Flask, GitHub)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/github")
def github_webhook():
signature = request.headers.get("X-Hub-Signature-256", "")
body = request.get_data() # raw bajty
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.headers.get("X-GitHub-Event")
delivery_id = request.headers.get("X-GitHub-Delivery")
enqueue(event, delivery_id, request.get_json())
return "", 204
hmac.compare_digest porovnává v konstantním čase. Běžné == skončí u prvního rozdílného znaku a útočník by mohl podle doby odezvy hádat podpis po znacích (timing attack).
Ověření v Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }), // req.body je Buffer
(req, res) => {
const signature = req.get("X-Hub-Signature-256") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString("utf8"));
// zařadit do fronty...
res.sendStatus(204);
}
);
express.raw() musí být na této routě a globální express.json() se jí nesmí dotknout dřív. timingSafeEqual vyhodí výjimku při různé délce bufferů, proto délku kontrolujeme předem.
Ochrana proti replay útoku
Pokud útočník zachytí platný webhook, může ho poslat znovu. Proto služby jako Stripe podepisují i časové razítko. Ověřte, že není starší než pár minut:
import time
timestamp = int(request.headers["webhook-timestamp"])
if abs(time.time() - timestamp) > 300:
abort(400)
Funguje to jen s podepsaným razítkem – nepodepsané si útočník přepíše. A server musí mít synchronizovaný čas (NTP).
Odpovídejte rychle
Odesílatelé čekají na odpověď jen omezenou dobu (u GitHubu 10 sekund) a při timeoutu nebo chybě doručení opakují. Endpoint by proto měl:
- ověřit podpis,
- uložit událost (databáze nebo fronta),
- okamžitě vrátit
2xx, - skutečnou práci (odeslání e-mailu, aktivace předplatného) udělat na pozadí.
Kdo v handleru synchronně volá další API a generuje PDF, dočká se timeoutů, opakovaných doručení a duplicitní práce.
Idempotence a deduplikace
Webhooky přicházejí alespoň jednou, ne právě jednou. Stejná událost může dorazit dvakrát a události mohou přijít v jiném pořadí, než vznikly.
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- ID události od odesílatele
source TEXT NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
INSERT INTO webhook_events (id, source, payload)
VALUES ($1, 'github', $2)
ON CONFLICT (id) DO NOTHING;
Pokud INSERT nevložil řádek, událost už znáte a nic dalšího neděláte. Zpracování navrhněte tak, aby opakování nevadilo – například „nastav stav na zaplaceno“ místo „přičti kredit“.
Kvůli pořadí se nespoléhejte jen na obsah události. U kritických stavů (platby, předplatné) si aktuální stav po přijetí webhooku dotáhněte z API služby.
Další doporučení
- HTTPS vždy.
- Tajemství v proměnných prostředí, s možností rotace (dočasně přijímat starý i nový klíč).
- Neodpovídejte detailně na neplatné požadavky – stačí
401nebo400. - Logujte ID doručení, typ události a výsledek ověření, ale ne citlivý obsah.
- Testujte lokálně – GitHub umí doručení znovu odeslat z nastavení webhooku, Stripe CLI přeposílá události na localhost (
stripe listen --forward-to).
Konkrétní implementaci pro platby najdete v článku Stripe Checkout integrace.