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.
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().
- Avec un modèle Pydantic comme paramètre de corps, FastAPI analyse et valide le JSON avant l'exécution du gestionnaire.
- Resérialiser le modèle produit des octets différents, donc la signature échoue.
- Prendre la Request et attendre request.body() donne les octets exacts.
- Le gestionnaire les vérifie, les analyse avec json.loads, répond 204 et confie le travail lent à BackgroundTasks.
Flask avec standardwebhooks
Installez le package avec pip install standardwebhooks.
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 "", 204request.get_data()renvoie le corps en octets. Flask le met en cache, donc appelerget_json()ensuite fonctionne toujours. Ne passez pasas_text=True: décoder d'abord est une étape qu'il vous faudrait ensuite défaire exactement.Webhook(secret)prend le secretwhsec_complet et retire lui-même le préfixe. Construisez-le une fois à l'import, pas à chaque requête.verifymet les noms d'en-têtes en minuscules, doncdict(request.headers)fonctionne quelle que soit la casse utilisée par le proxy. Il vérifie le timestamp, chaque signaturev1, et renvoie le JSON analysé.
FastAPI avec la bibliothèque standard
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")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 litawait request.body()en premier. request.headersn'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_digestcompare les octets décodés en temps constant.validate=Truefait d'une signature malformée une non-correspondance au lieu d'une valeur partielle décodée silencieusement.BackgroundTaskss'exécute après l'envoi du204, 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
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
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 :
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 :
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 TrueAppelez 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.
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
- Lancez
pytest. Les quatre tests passent. - Démarrez l'app derrière une URL
https:publique et ajoutez-la sous Webhooks dans le tableau de bord. - Définissez
IKIFORM_WEBHOOK_SECRETavec le secretwhsec_de l'endpoint et redémarrez le processus. - Choisissez Send test. Le journal de livraison affiche
204, et le journal de votre application affiche l'événementendpoint.testavec son identifiantdlv_. - Envoyez le formulaire une fois avec un nom accentué, comme
Zoë, et vérifiez qu'il arrive intact.
Pièges
| Problème | Cause et solution |
|---|---|
| Le gestionnaire FastAPI échoue toujours avec un modèle de corps | Le 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 Django | Le middleware CSRF. Ajoutez @csrf_exempt à la seule vue du webhook |
missing_headers dans la version de la bibliothèque standard | Un 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émarrage | Le secret a été copié sans son = final. Copiez à nouveau toute la valeur whsec_ |
timestamp_too_old à chaque livraison | L'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
Prenez l'objet Request en paramètre et appelez await request.body(). Ne déclarez pas de modèle Pydantic comme paramètre de corps pour une route de webhook ; il analyse le JSON avant que vous puissiez le vérifier.
Oui. Installez-le avec pip install standardwebhooks et appelez Webhook(secret).verify(body, headers) avec le secret whsec_ du tableau de bord. Il lève WebhookVerificationError quand une vérification échoue.
== peut s'arrêter dès qu'un caractère diffère, ce qui laisse fuiter une information temporelle sur la part correcte d'une tentative. compare_digest prend le même temps dans tous les cas.
Oui. Le middleware CSRF de Django refuse les envois sans jeton CSRF, et Ikiform n'en envoie pas. C'est la vérification de signature qui protège la vue à la place.
hmac.new a besoin de bytes pour la clé comme pour le message. Décodez le secret avec base64.b64decode, qui renvoie des bytes, et construisez le message avec f"{msg_id}.{timestamp}.".encode() + body, où body correspond aux octets bruts de la requête.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.