# Webhook署名の検証

> Formspree Docs · 高度な機能 · 2026年2月28日

受信側のコードで 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)

```python
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)

```javascript
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 署名シークレットであることを確認してください。
