Programování

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í.

12. 10. 2026 · 4 min čtení

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:

  1. ověřit podpis,
  2. uložit událost (databáze nebo fronta),
  3. okamžitě vrátit 2xx,
  4. 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čí 401 nebo 400.
  • 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.