Vérifier les signatures de webhook

Vérifier les signatures de webhook6 min de lecture

Vérifier les signatures de webhook en Python

Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.

Preet SutharFondateur, Ikiform

Lisez le corps de la requête en octets avant que quoi que ce soit ne l'analyse : request.get_data() dans Flask, await request.body() dans FastAPI, request.body dans Django. Passez ces octets et les en-têtes à Webhook(secret).verify() du package standardwebhooks, ou calculez vous-même le HMAC-SHA256 avec hmac et comparez avec hmac.compare_digest. Répondez 400 en cas d'échec et n'analysez le JSON qu'une fois la vérification réussie.

Trois frameworks, deux façons de vérifier. Flask et Django utilisent le package standardwebhooks ; FastAPI utilise une fonction de la bibliothèque standard avec une suite pytest construite sur le vecteur de test. Combinez-les comme vous voulez : la fonction et le package sont interchangeables.

Ce qui diffère en Python

Les frameworks Python vous donnent le corps sous une forme qui semble brute mais ne l'est pas toujours. FastAPI est le plus piégeux : déclarez un modèle Pydantic en paramètre et FastAPI lit, analyse et valide le corps avant l'exécution de votre fonction. Vous obtenez un modèle, et les octets signés par Ikiform ne sont jamais entre vos mains. Le même piège existe dans Flask avec request.get_json() et dans Django REST framework avec request.data. Dans Starlette et FastAPI, le corps est un flux que await request.body() lit et met en cache, donc appelez-le avant que quoi que ce soit ne parcoure request.stream().

Un gestionnaire FastAPI avec un paramètre de corps Pydantic comparé à un gestionnaire qui lit le corps brut de la requêtemodèle Pydantic comme paramètre de corpsawait request.body()octets de larequêteanalysé etvalidéavantl'exécution devotre codemodel.model_dump_json()octetsdifférents400octets de larequêteoctetsexactementtels qu'envoyésvérifier,puisjson.loads204,travail enarrière-planUn gestionnaire FastAPI avec un paramètre de corps Pydantic comparé à un gestionnaire qui lit le corps brut de la requêtemodèle Pydantic comme paramètre de corpsawait request.body()octets de la requêteanalysé et validéavant l'exécution de votre codemodel.model_dump_json()octets différents400octets de la requêteoctetsexactement tels qu'envoyésvérifier, puis json.loads204, travail enarrière-plan
  1. Avec un modèle Pydantic comme paramètre de corps, FastAPI analyse et valide le JSON avant l'exécution du gestionnaire.
  2. Resérialiser le modèle produit des octets différents, donc la signature échoue.
  3. Prendre la Request et attendre request.body() donne les octets exacts.
  4. Le gestionnaire les vérifie, les analyse avec json.loads, répond 204 et confie le travail lent à BackgroundTasks.
Validez avec Pydantic après la vérification, si vous voulez un modèle : Event.model_validate_json(body).

Flask avec standardwebhooks

Installez le package avec pip install standardwebhooks.

app.py
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@app.post("/webhooks/ikiform")
def ikiform_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError as error:
        return str(error), 400
    if event["type"] == "submission.created":
        app.logger.info("submission %s %s", event["id"], event["data"]["fields"])
    return "", 204
  • request.get_data() renvoie le corps en octets. Flask le met en cache, donc appeler get_json() ensuite fonctionne toujours. Ne passez pas as_text=True : décoder d'abord est une étape qu'il vous faudrait ensuite défaire exactement.
  • Webhook(secret) prend le secret whsec_ complet et retire lui-même le préfixe. Construisez-le une fois à l'import, pas à chaque requête.
  • verify met les noms d'en-têtes en minuscules, donc dict(request.headers) fonctionne quelle que soit la casse utilisée par le proxy. Il vérifie le timestamp, chaque signature v1, et renvoie le JSON analysé.

FastAPI avec la bibliothèque standard

signatures.py
import base64
import binascii
import hashlib
import hmac
import re
import time
from collections.abc import Mapping

TOLERANCE_SECONDS = 5 * 60


class InvalidSignature(Exception):
    pass


def verify_signature(body: bytes, headers: Mapping[str, str], secret: str, now: float | None = None) -> None:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        raise InvalidSignature("missing_headers")
    if not re.fullmatch(r"[0-9]{1,12}", timestamp):
        raise InvalidSignature("invalid_timestamp")

    age = int(time.time() if now is None else now) - int(timestamp)
    if age > TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_old")
    if age < -TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_new")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1":
            continue
        try:
            received = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if hmac.compare_digest(received, expected):
            return
    raise InvalidSignature("no_matching_signature")
main.py
import json
import logging
import os

from fastapi import BackgroundTasks, FastAPI, Request, Response

from signatures import InvalidSignature, verify_signature

app = FastAPI()
log = logging.getLogger("webhooks")
SECRET = os.environ["IKIFORM_WEBHOOK_SECRET"]


def handle_submission(delivery_id: str, fields: dict) -> None:
    log.info("submission %s %s", delivery_id, fields)


@app.post("/webhooks/ikiform")
async def ikiform_webhook(request: Request, background: BackgroundTasks) -> Response:
    body = await request.body()
    try:
        verify_signature(body, request.headers, SECRET)
    except InvalidSignature as error:
        return Response(str(error), status_code=400)
    event = json.loads(body)
    if event["type"] == "submission.created":
        background.add_task(handle_submission, event["id"], event["data"]["fields"])
    return Response(status_code=204)
  • Le gestionnaire prend une Request, pas un modèle, et lit await request.body() en premier.
  • request.headers n'est pas sensible à la casse, donc .get("webhook-id") trouve l'en-tête quelle que soit la façon dont il a été envoyé.
  • hmac.compare_digest compare les octets décodés en temps constant. validate=True fait d'une signature malformée une non-correspondance au lieu d'une valeur partielle décodée silencieusement.
  • BackgroundTasks s'exécute après l'envoi du 204, donc le travail lent ne compte pas dans l'attente de 15 secondes d'Ikiform.
  • Les annotations de type nécessitent Python 3.10 ou plus récent.

Un test pytest

test_signatures.py
import json

import pytest

from signatures import InvalidSignature, verify_signature

SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
BODY = b'{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'
HEADERS = {
    "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
    "webhook-timestamp": "1792300000",
    "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
}
NOW = 1792300000 + 30


def test_accepts_the_vector():
    verify_signature(BODY, HEADERS, SECRET, now=NOW)


def test_accepts_either_secret_during_a_roll():
    both = {**HEADERS, "webhook-signature": HEADERS["webhook-signature"] + " v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak="}
    verify_signature(BODY, both, SECRET, now=NOW)
    verify_signature(BODY, both, ROLLED, now=NOW)


def test_rejects_a_reserialized_body():
    pretty = json.dumps(json.loads(BODY), indent=2).encode()
    with pytest.raises(InvalidSignature, match="no_matching_signature"):
        verify_signature(pretty, HEADERS, SECRET, now=NOW)


def test_rejects_a_stale_timestamp():
    with pytest.raises(InvalidSignature, match="timestamp_too_old"):
        verify_signature(BODY, HEADERS, SECRET, now=NOW + 600)

Lancez pytest. Le NOW fixe garde le timestamp du vecteur dans la fenêtre de 5 minutes. json.dumps(indent=2) est une façon réaliste dont un corps se retrouve modifié dans une base de code Python, donc ce cas a toute sa place.

Django

webhooks/views.py
import os

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@csrf_exempt
@require_POST
def ikiform_webhook(request):
    try:
        event = webhook.verify(request.body, dict(request.headers))
    except WebhookVerificationError as error:
        return HttpResponseBadRequest(str(error))
    if event["type"] == "submission.created":
        print("submission", event["id"])
    return HttpResponse(status=204)

request.body contient les octets bruts. @csrf_exempt est obligatoire, car Ikiform n'a pas de jeton CSRF et Django refuserait l'envoi avec 403 ; la vérification de signature remplace la protection CSRF pour cette seule vue. Lisez request.body avant de toucher à request.POST, ce qui ne compte que pour les envois de formulaire mais peut consommer le flux.

Événements typés après vérification

Pydantic reste utile, une étape plus tard. Vérifiez les octets, puis validez ces mêmes octets dans un modèle. Vous obtenez un accès typé au payload et une erreur claire si un champ sur lequel vous comptez manque, sans renoncer à la vérification de signature :

events.py
from pydantic import BaseModel


class Form(BaseModel):
    id: str
    name: str


class SubmissionData(BaseModel):
    submission_id: str
    form: Form
    fields: dict[str, object]
    created_at: str
    truncated: bool


class SubmissionCreated(BaseModel):
    id: str
    type: str
    data: SubmissionData


def parse_submission(body: bytes) -> SubmissionCreated:
    return SubmissionCreated.model_validate_json(body)

Pydantic ignore par défaut les clés supplémentaires, donc les nouveaux champs qu'Ikiform ajoute au payload ne cassent pas le modèle. N'appelez parse_submission(body) que pour submission.created ; un événement endpoint.test a une forme de data différente.

Ignorer les répétitions

Une livraison vérifiée peut arriver deux fois quand Ikiform ne reçoit pas votre réponse en 15 secondes et réessaie avec le même webhook-id. Dans Django, une colonne unique et une IntegrityError rendent la seconde sans effet :

webhooks/models.py
from django.db import IntegrityError, models, transaction


class Delivery(models.Model):
    delivery_id = models.CharField(max_length=64, unique=True)
    received_at = models.DateTimeField(auto_now_add=True)


def first_delivery(delivery_id: str) -> bool:
    try:
        with transaction.atomic():
            Delivery.objects.create(delivery_id=delivery_id)
    except IntegrityError:
        return False
    return True

Appelez first_delivery(event["id"]) après la vérification ; s'il renvoie False, répondez 204 et arrêtez. Le bloc atomic empêche un insert échoué de casser le reste de la transaction de la requête. Avec SQLAlchemy, insert(...).on_conflict_do_nothing() fait la même chose sur PostgreSQL et SQLite.

Le verify du package standardwebhooks décode le corps en UTF-8 avant le hachage, ce qui convient au JSON d'Ikiform. La version de la bibliothèque standard hache directement les octets, donc elle ne dépend d'aucun décodage.

Vérifiez que tout fonctionne

  1. Lancez pytest. Les quatre tests passent.
  2. Démarrez l'app derrière une URL https: publique et ajoutez-la sous Webhooks dans le tableau de bord.
  3. Définissez IKIFORM_WEBHOOK_SECRET avec le secret whsec_ de l'endpoint et redémarrez le processus.
  4. Choisissez Send test. Le journal de livraison affiche 204, et le journal de votre application affiche l'événement endpoint.test avec son identifiant dlv_.
  5. Envoyez le formulaire une fois avec un nom accentué, comme Zoë, et vérifiez qu'il arrive intact.

Pièges

ProblèmeCause et solution
Le gestionnaire FastAPI échoue toujours avec un modèle de corpsLe modèle a analysé le corps en premier. Prenez la Request, vérifiez await request.body(), puis Model.model_validate_json(body)
403 Forbidden depuis DjangoLe middleware CSRF. Ajoutez @csrf_exempt à la seule vue du webhook
missing_headers dans la version de la bibliothèque standardUn simple dict d'en-têtes a gardé la casse du serveur. Utilisez l'objet d'en-têtes insensible à la casse du framework, ou mettez les clés en minuscules
binascii.Error: Incorrect padding au démarrageLe secret a été copié sans son = final. Copiez à nouveau toute la valeur whsec_
timestamp_too_old à chaque livraisonL'horloge du serveur est décalée de plus de 5 minutes. Synchronisez-la avec NTP ; la signature elle-même est peut-être correcte

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.