Guides

Guides13 min de lecture

Vérifier les signatures des webhooks de formulaire

Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.

Preet SutharFondateur, Ikiform

Recalculez vous-même la signature et comparez-la à celle de la requête. Ikiform suit Standard Webhooks : la signature est un HMAC-SHA256 en base64 de webhook-id, webhook-timestamp et du corps brut joints par des points, avec pour clé la partie décodée en base64 de votre secret whsec_, et envoyé sous la forme v1,<signature>. N'acceptez la requête que si une signature v1 correspond lors d'une comparaison en temps constant et que le timestamp date de moins de 5 minutes.

Un endpoint de webhook est une URL publique qui accepte les requêtes POST de n'importe qui. Sans vérification de signature, quiconque découvre l'URL peut injecter de fausses soumissions dans votre CRM, votre base de données ou le chat de votre équipe. La vérification tient en quelques lignes de code, et ce guide porte justement sur l'art d'écrire ces lignes exactement comme il faut.

Comment fonctionne la signature

Ikiform et votre serveur partagent un secret, affiché une seule fois quand vous ajoutez l'endpoint. Avant chaque tentative de livraison, Ikiform hache la requête avec ce secret et place le résultat dans un en-tête. Votre serveur refait le calcul sur ce qu'il a reçu. Seul quelqu'un qui détient le secret peut produire une valeur correspondante, et toute modification du corps, de l'identifiant ou du timestamp la casse.

Comment Ikiform signe une livraison de webhook et comment votre serveur la vérifieIkiform, à chaque tentativevotre gestionnairecorps JSONsubmission.createdid.timestamp.bodyle contenusignéHMAC-SHA256avec chaquesecret actifwebhook-signaturev1,<base64>corps brut +en-têtesavant touteanalysemême HMACavec votrecopie du secretcomparaisonen tempsconstantunecorrespondancev1, timestamprécent2xxpuis analyseret traiter400rien n'esttraitépas de correspondanceComment Ikiform signe une livraison de webhook et comment votre serveur la vérifieIkiform, à chaque tentativevotre gestionnairecorps JSONsubmission.createdid.timestamp.bodyle contenu signéHMAC-SHA256avec chaque secretactifwebhook-signaturev1,<base64>corps brut +en-têtesavant toute analysemême HMACavec votre copie dusecretcomparaison entemps constantune correspondancev1, timestamp récent2xxpuis analyser ettraiter400rien n'esttraitépas de correspondance
  1. Ikiform sérialise l'événement en JSON.
  2. Il joint l'identifiant de livraison, le timestamp Unix de la tentative et le corps avec des points.
  3. Il calcule un HMAC-SHA256 de cette chaîne avec chaque secret actif de l'endpoint.
  4. Il envoie chaque résultat, encodé en base64 et préfixé par v1, dans l'en-tête webhook-signature.
  5. Votre gestionnaire lit le corps brut et les trois en-têtes avant d'analyser quoi que ce soit.
  6. Il calcule le même HMAC avec sa copie du secret.
  7. Il compare en temps constant avec chaque signature v1 et vérifie que le timestamp date de moins de 5 minutes.
  8. S'il y a correspondance, il répond 2xx et traite l'événement. Sinon, il répond 400 et ne fait rien.
Rien de secret ne voyage avec la requête. La signature prouve que l'expéditeur connaissait le secret, et que les octets n'ont pas été modifiés en chemin.

Le schéma, précisément

Ikiform utilise Standard Webhooks, une spécification ouverte que partagent plusieurs fournisseurs de webhooks. Chaque livraison porte trois en-têtes :

En-têteValeurRemarques
webhook-iddlv_...L'identifiant de livraison. Identique à chaque nouvelle tentative, il sert donc aussi de clé d'idempotence
webhook-timestampSecondes Unix, comme 1792300000Le moment où cette tentative a été signée. Nouveau à chaque nouvelle tentative
webhook-signaturev1, suivi de base64Une ou plusieurs valeurs séparées par des espaces. Deux pendant la rotation d'un secret
Ce qui est signé
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature

Pour vérifier une livraison, dans cet ordre :

  1. Lisez le corps brut en octets ou en chaîne exacte. Ne l'analysez pas encore.
  2. Lisez les trois en-têtes. S'il en manque un, rejetez.
  3. Vérifiez que le timestamp est un entier à moins de 5 minutes de votre horloge, dans un sens ou dans l'autre. Sinon, rejetez.
  4. Retirez whsec_ de votre secret et décodez le reste en base64. Ces 32 octets sont la clé HMAC, pas la chaîne elle-même.
  5. Calculez le HMAC-SHA256 de {webhook-id}.{webhook-timestamp}.{raw body} et encodez-le en base64 avec l'alphabet standard et le padding.
  6. Découpez webhook-signature sur les espaces. Pour chaque entrée, découpez sur la première virgule ; ignorez tout ce dont la version n'est pas v1.
  7. Comparez chaque valeur v1 à la vôtre en temps constant. Si l'une correspond, la livraison est authentique.
  8. Seulement maintenant, analysez le JSON et agissez en conséquence.

Un vecteur de test

Avant de déployer un gestionnaire, prouvez qu'il calcule la bonne valeur. Ces entrées sont un vrai corps de livraison endpoint.test signé avec un secret inventé. Votre code doit produire exactement la signature de la dernière ligne. Chaque guide par langage inclut un test unitaire qui les utilise.

EntréeValeur
Secretwhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
Corps brutLes octets exacts du script ci-dessous, sans espaces ni saut de ligne
webhook-signature attenduv1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=

Vous pouvez le vérifier sans écrire de code. Cette commande s'exécute dans un terminal sous macOS ou Linux et affiche la partie base64 de la signature :

Terminal
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"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."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64

Deux autres cas méritent chacun un test, car ce sont les deux causes d'échec réelles les plus fréquentes :

ModificationSignatureVotre gestionnaire doit-il l'accepter ?
Le même corps mis en forme avec une indentation de deux espacesv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=Non. Même JSON, octets différents
webhook-timestamp une seconde plus tardv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=Non. Le timestamp est signé
Signé avec un second secret pendant une rotationv1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Oui, si l'en-tête contient aussi une valeur qui correspond à votre secret

Pendant une rotation de secret, l'en-tête porte les deux valeurs. Votre gestionnaire, avec l'un ou l'autre secret, doit accepter ceci :

Deux signatures
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
Le timestamp du vecteur est fixe, donc un vrai vérificateur le rejette comme trop ancien. Les tests doivent passer une heure courante fixe, ce que font les guides par langage. Le vérificateur de signature de la page sur les webhooks vérifie dans le navigateur n'importe quels secret, en-têtes et corps.

Façons de vérifier

ApprochePoints fortsCompromis
verifyWebhook de @ikiform/sdk/webhooksUn seul appel qui vérifie tout et renvoie un événement typé. Fonctionne sur Node, Workers, Deno et BunJavaScript et TypeScript uniquement
Une bibliothèque Standard WebhooksDu code maintenu pour Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# et ElixirUne dépendance de plus. Vérifiez comment chacune attend les en-têtes ; certaines sont sensibles à la casse
Votre propre code HMACAucune dépendance, une vingtaine de lignes, et vous pouvez tester chaque brancheFacile de se tromper subtilement : comparaison de chaînes, mauvais octets de clé ou corps analysé
Un chemin secret ou un jeton dans l'URL à la placeSimpleFuit via les journaux, les proxys et les outils de suivi d'erreurs, ne prouve rien sur le corps et ne peut pas expirer. Ce n'est pas un substitut

Ikiform signe chaque livraison, et la signature est le moyen pris en charge pour l'authentifier. Sa documentation ne publie pas de liste fixe d'adresses IP d'envoi, donc une liste d'IP autorisées n'est pas une vérification fiable.

Vérifier dans votre framework

Pour les stacks JavaScript, le SDK fait le travail. Ce qui change d'un framework à l'autre, c'est l'emplacement de la route et la façon d'obtenir le corps avant que quoi que ce soit ne l'analyse. Choisissez le vôtre :

Un route handler de l'App Router. request.text() est le corps brut ; appeler request.json() d'abord le perdrait. Le guide sur votre propre serveur ajoute la déduplication et after().

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}

Chaque gestionnaire répond 204 en cas de succès et 400 avec la raison de l'échec sinon. Faites le travail lent après avoir répondu ; Ikiform attend 15 secondes avant de compter une tentative comme échouée.

Pourquoi le corps brut compte

La signature couvre des octets, pas un sens. {"a":1} et { "a": 1 } sont le même JSON mais des octets différents, donc un corps analysé puis resérialisé ne correspond presque jamais. Il en va de même pour tout ce qui touche au corps à l'arrivée : un middleware qui analyse le JSON, un proxy qui réencode, ou un framework qui vous donne un objet au lieu d'une chaîne.

Lisez le corps une seule fois, en octets ou en chaîne, vérifiez-le, puis analysez cette même chaîne. Chaque guide par langage montre où se trouve le corps brut dans ses frameworks, et le guide sur votre propre serveur contient un tableau pour les frameworks Node et Python courants.

Comparer en temps constant

Une comparaison de chaînes normale s'arrête au premier caractère différent. En théorie, un attaquant capable de chronométrer des milliers de requêtes apprend combien de caractères initiaux d'une tentative sont justes et construit une signature valide caractère par caractère. Une comparaison en temps constant prend le même temps quelle que soit la part qui correspond. Chaque langage en a une :

LangageComparaison en temps constant
Node.jscrypto.timingSafeEqual(a, b), qui lève une exception si les longueurs diffèrent, donc vérifiez d'abord la longueur
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.secure_compare(a, b), ou ActiveSupport::SecurityUtils.secure_compare dans Rails

Timestamps et rejeux

Une requête valide capturée une fois reste valide pour toujours, à moins que quelque chose ne la fasse expirer. Le timestamp signé joue ce rôle : rejetez toute livraison à plus de 5 minutes de votre horloge, dans un sens ou dans l'autre. Comme Ikiform signe à nouveau chaque tentative avec un timestamp récent, une nouvelle tentative trois jours plus tard passe encore ; seule une ancienne requête copiée échoue.

Dans ces 5 minutes, un rejeu reste possible, tout comme un véritable doublon : Ikiform réessaie quand il ne reçoit pas de 2xx à temps, même si votre gestionnaire a terminé. Stockez webhook-id avec une contrainte d'unicité et ignorez les identifiants déjà traités. Cela règle les deux cas.

Gardez l'horloge du serveur synchronisée. Une horloge qui dérive de plus de 5 minutes rejette chaque livraison avec timestamp_too_old ou timestamp_too_new. Les plateformes managées synchronisent généralement les horloges ; sur votre propre VM, exécutez NTP.

Faire tourner le secret

Faites tourner le secret quand quelqu'un qui le connaissait s'en va, quand il a pu être journalisé ou commité, ou selon un calendrier. Roll signing secret sur la page de l'endpoint en crée un nouveau, et pendant 24 heures chaque livraison est signée avec les deux :

Ce que contient webhook-signature avant, pendant et après une rotation de secretavantv1,<old>rotationnouveausecretaffiché unefois24 heuressuivantesv1,<old>v1,<new>aprèsv1,<new>seulementdéployer le nouveauCe que contient webhook-signature avant, pendant et après une rotation de secretavantv1,<old>rotationnouveau secret affiché une fois24 heures suivantesv1,<old> v1,<new>aprèsv1,<new> seulementdéployer le nouveau
  1. Avant la rotation, chaque livraison porte une seule signature, faite avec l'ancien secret.
  2. Vous choisissez Roll signing secret et copiez le nouveau secret.
  3. Pendant les 24 heures suivantes, chaque livraison porte deux signatures, donc un serveur qui détient l'un ou l'autre secret l'accepte. Déployez le nouveau secret pendant cette fenêtre.
  4. Après 24 heures, les livraisons ne portent plus que la nouvelle signature, et un serveur qui détient encore l'ancien secret les rejette.
Votre gestionnaire n'a besoin d'aucun code particulier pour cela, tant qu'il vérifie chaque entrée v1 au lieu de la seule première.

Si l'ancien secret est toujours déployé après 24 heures, chaque livraison échoue. Ikiform réessaie chacune 8 fois sur environ trois jours, donc une fois le bon secret déployé, utilisez Retry dans le journal de livraison pour tout ce qui a déjà abandonné.

Après la vérification

Une signature valide prouve que la requête vient d'Ikiform et n'a pas été modifiée. Elle ne prouve pas que le contenu est sûr ou nouveau. Trois habitudes complètent le travail :

  • Ignorez les répétitions. Stockez webhook-id, ou l'id du payload, qui a la même valeur, avec une contrainte d'unicité. Une nouvelle tentative d'une livraison déjà traitée devient sans effet.
  • Répondez d'abord, travaillez ensuite. Renvoyez 2xx dès que l'événement est stocké ou mis en file d'attente. Ikiform compte comme un échec tout ce qui dépasse 15 secondes et réessaie, ce qui ramène le problème des doublons.
  • Considérez les champs comme non fiables. data.fields contient ce qu'un visiteur a tapé dans un formulaire public. Échappez-le avant de l'afficher en HTML, dans un message de chat ou dans un e-mail, et ne le passez jamais à un shell ou à une requête non paramétrée.

Tester avant la mise en production

Ikiform ne livre qu'à des URL https: publiques sur le port 443, donc localhost ne peut pas recevoir de webhook. Lancez un tunnel comme cloudflared tunnel --url http://localhost:3000 et ajoutez l'URL qu'il affiche comme second endpoint pendant que vous travaillez. Send test sur la page de l'endpoint envoie une fois un événement endpoint.test, sans nouvelles tentatives, signé exactement comme une vraie livraison. Supprimez l'endpoint de test quand vous avez terminé ; il a son propre secret, donc rien d'autre ne change.

Si une livraison échoue et que vous ne voyez pas pourquoi, copiez ses en-têtes et son corps depuis vos journaux dans le vérificateur de signature. Il exécute la même vérification dans le navigateur et vous indique quelle étape a échoué.

Cinq façons de rater la vérification

ErreurSymptômeSolution
Utiliser toute la chaîne whsec_... comme cléno_matching_signature à chaque livraisonRetirez whsec_, puis décodez en base64. La clé fait 32 octets
Vérifier un corps analyséno_matching_signature, parfois seulement pour certains payloadsVérifiez le corps brut ; analysez ensuite
Ne vérifier que la première signatureDes échecs qui commencent juste après une rotationParcourez chaque entrée v1 séparée par des espaces
Hexadécimal au lieu de base64no_matching_signatureStandard Webhooks utilise le base64 standard avec padding, pas l'hexadécimal ni le base64url
Chercher les en-têtes avec la casse exactemissing_headers derrière certains proxysLes noms d'en-têtes ne sont pas sensibles à la casse. Ikiform les envoie en minuscules ; recherchez-les sans tenir compte de la casse

Il n'y a pas d'intégrations en un clic. Chaque outil est atteint via un webhook signé, directement ou via un outil d'automatisation comme Zapier ou n8n. Un espace de travail peut avoir 3 endpoints, chacun avec son propre secret. Le payload complet, le calendrier des nouvelles tentatives et le journal de livraison se trouvent dans Webhooks.

Choisissez votre cas

Chaque page par langage contient des gestionnaires idiomatiques, une option avec bibliothèque et un test unitaire qui exécute le vecteur de test de cette page.

Vérifiez que tout fonctionne

Faites-le d'abord sur un endpoint de staging, puis à nouveau en production. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.

  1. Passez le vecteur de test dans votre code : il doit produire la signature attendue, accepter l'en-tête à deux signatures et rejeter le corps mis en forme.
  2. Dans le tableau de bord, ouvrez Webhooks, ajoutez l'URL https: publique de votre endpoint et copiez le secret whsec_ dans l'environnement de votre serveur.
  3. Choisissez Send test sur la page de l'endpoint. Le journal de livraison affiche votre 204 et l'événement arrive dans vos journaux.
  4. Modifiez un caractère du secret sur votre serveur et envoyez un autre test. Le journal affiche 400 avec no_matching_signature.
  5. Envoyez le formulaire pour de vrai et vérifiez que la livraison submission.created réussit.

Dépannage

ProblèmeQue faire
400 no_matching_signatureMauvais secret, corps analysé, ou clé non décodée en base64. Passez le vecteur de test par le même chemin de code pour savoir lequel.
400 timestamp_too_old ou timestamp_too_newL'horloge du serveur est décalée, ou vous rejouez à la main une requête enregistrée. Synchronisez l'horloge, et utilisez Retry dans le journal de livraison, qui signe à nouveau.
400 missing_headersUn proxy ou un framework a supprimé les en-têtes webhook-*, ou la requête ne vient pas d'Ikiform. Journalisez chaque nom d'en-tête que voit votre gestionnaire.
Fonctionne pour les événements de test, échoue pour les vraies soumissionsLes vrais payloads contiennent des caractères non ASCII et des textes plus longs. Votre code décode ou réencode le corps avant de le vérifier. Vérifiez les octets exacts.
Chaque livraison échoue juste après la rotation du secretLe gestionnaire ne vérifie que la première signature, ou les 24 heures se sont écoulées avec l'ancien secret déployé. Vérifiez chaque entrée v1 et déployez le nouveau secret.
Le journal de livraison affiche un délai dépassé, mais votre gestionnaire s'est exécutéIkiform attend 15 secondes. Répondez d'abord et faites le travail lent ensuite, et ignorez les valeurs webhook-id répétées.

Questions

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