⌘I

Vérifier les signatures de webhook

Updated February 28, 2026

Voici comment vérifier les signatures de webhook Formspree dans votre code de réception.

Le point clé est que Formspree ne signe pas uniquement le corps brut de la requête. Il signe à la fois l’horodatage et le corps brut, dans ce format :

{timestamp}.{raw_body}

La signature est transmise dans l’en-tête Formspree-Signature selon ce format :

t=<unix>,v1=<hex>

Exigences principales

  • Utilisez le corps brut de la requête exactement tel que reçu.
  • Analysez l’en-tête Formspree-Signature pour extraire t et v1.
  • Calculez HMAC_SHA256(secret, f"{t}.{raw_body}") et comparez-le à v1.
  • Optionnel : rejetez les requêtes plus anciennes qu’une fenêtre de tolérance (protection contre les attaques par rejeu).

Exemples de code

Voici quelques extraits de code pour vous aider à démarrer.

Python (Flask)

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

SIGNING_SECRET = "your_signing_secret"  # Set this to the secret shown in Formspree

def verify_formspree_signature(secret, signature_header, raw_body, tolerance=300):
    if not signature_header:
        return False

    try:
        # Header format: "t=<unix>,v1=<hex>"
        parts = dict(item.split("=", 1) for item in signature_header.split(","))
        timestamp = int(parts.get("t", "0"))
        received_sig = parts.get("v1")
    except Exception:
        return False

    if not received_sig:
        return False

    # Optional: reject old requests to prevent replay attacks
    if abs(int(time.time()) - timestamp) > tolerance:
        return False

    # Formspree signs: "{timestamp}.{raw_body}"
    signed_payload = f"{timestamp}.{raw_body}"

    # Compute HMAC-SHA256 with the shared secret
    expected_sig = hmac.new(
        key=secret.encode("utf-8"),
        msg=signed_payload.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).hexdigest()

    # Constant-time compare to avoid timing attacks
    return hmac.compare_digest(expected_sig, received_sig)

@app.post("/webhook")
def webhook():
    # Important: read the raw request body as sent
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("Formspree-Signature", "")

    if not verify_formspree_signature(SIGNING_SECRET, signature, raw_body):
        abort(401)

    return ("ok", 200)

JavaScript (Node.js + Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

const SIGNING_SECRET = "your_signing_secret"; // Set this to the secret shown in Formspree

// Capture the raw body before JSON parsing changes it
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString("utf8");
  }
}));

function verifyFormspreeSignature(secret, signatureHeader, rawBody, tolerance = 300) {
  if (!signatureHeader) return false;

  // Header format: "t=<unix>,v1=<hex>"
  const parts = Object.fromEntries(
    signatureHeader.split(",").map(s => s.split("=", 2))
  );
  const timestamp = parseInt(parts.t || "0", 10);
  const receivedSig = parts.v1;

  if (!receivedSig) return false;

  // Optional: reject old requests to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > tolerance) return false;

  // Formspree signs: "{timestamp}.{raw_body}"
  const signedPayload = `${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256 with the shared secret
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  // Constant-time compare to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig, "utf8"),
    Buffer.from(receivedSig, "utf8")
  );
}

app.post("/webhook", (req, res) => {
  const signature = req.header("Formspree-Signature") || "";

  if (!verifyFormspreeSignature(SIGNING_SECRET, signature, req.rawBody)) {
    return res.status(401).send("invalid signature");
  }

  res.status(200).send("ok");
});

app.listen(3000);

Résolution des problèmes et erreurs courantes

Voici quelques erreurs courantes à éviter :

  • Re-sérialiser le JSON avant la signature : vérifiez à partir des octets bruts du corps de la requête exactement tels que reçus, car l’analyse puis la re-sérialisation du JSON modifient la séquence d’octets et invalident le HMAC.
  • Signer uniquement le corps (préfixe t. manquant) : comme mentionné ci-dessus, Formspree signe l’horodatage et le contenu ensemble ; vous devez donc calculer le HMAC sur t.<raw_body> (en incluant le délimiteur t.) et non sur le corps seul.
  • Comparer à la chaîne d’en-tête complète au lieu de la valeur v1 : extrayez la valeur de signature v1 de l’en-tête et comparez uniquement ce condensé hexadécimal, pas le contenu complet de l’en-tête séparé par des virgules.
  • Utiliser un secret différent de celui configuré pour le webhook : assurez-vous que le secret utilisé pour le HMAC est exactement le secret de signature du webhook dans les paramètres Formspree, et non votre clé API ou un autre secret de projet.