Webhook署名の検証
Updated February 28, 2026
受信側のコードで Formspree の webhook 署名を検証する方法を説明します。
重要な点として、Formspree は生のボディのみに署名するのではなく、タイムスタンプと生のボディの両方に署名します。形式は以下のとおりです:
{timestamp}.{raw_body}
署名は Formspree-Signature ヘッダーで次の形式で送信されます:
t=<unix>,v1=<hex>
主要な要件
- 受信したとおりの生のリクエストボディをそのまま使用してください。
Formspree-Signatureヘッダーを解析してtとv1を抽出してください。HMAC_SHA256(secret, f"{t}.{raw_body}")を計算し、v1と比較してください。- オプション:許容時間ウィンドウより古いリクエストを拒否します(リプレイ攻撃対策)。
コードサンプル
始めるためのコードスニペットをいくつか紹介します。
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);
トラブルシューティングとよくある問題
以下によくあるミスをまとめます:
- 署名前に JSON を再シリアライズしてしまう:JSON を解析して再文字列化するとバイト列が変わり HMAC が壊れるため、受信したとおりの生のリクエストボディのバイト列で検証してください。
- ボディのみに署名してしまう(
t.プレフィックスの省略):上述のとおり、Formspree はタイムスタンプとペイロードをまとめて署名するため、ボディのみではなくt.<raw_body>(t.区切り文字を含む)に対して HMAC を計算する必要があります。 v1の値ではなくヘッダー文字列全体と比較してしまう:署名ヘッダーからv1署名値を抽出し、カンマ区切りのヘッダー全体ではなくその16進数ダイジェストのみを比較してください。- webhook に設定された
signing_secretと異なるシークレットを使用してしまう:HMAC に使用するシークレットが、API キーや他のプロジェクトシークレットではなく、Formspree の webhook 設定にある正確な webhook 署名シークレットであることを確認してください。