3分で読了エンジニアリング

Webhook署名の検証: Node・Python・GoのHMAC-SHA256

HMAC-SHA256でWebhook署名を検証する方法。生のボディ、定数時間比較、リプレイ対策の許容時間、シークレットのローテーションを、Node、Python。

Marius Voß
DevRel · edge infra

Webhookの署名を検証するには、送信側が署名したバイト列とまったく同じものに対して、共有するシークレットを使ってHMACを再計算し、自分の値を署名ヘッダーと定数時間で比較します。そのうえで、署名されたタイムスタンプが新しいことを確認します。ElidoのWebhookの場合は、whsec_...のシークレット全体を鍵にしたHMAC-SHA256を、{X-Webhook-Timestamp}.{raw body}に対して計算し、16進数にエンコードしてv1=を付け、X-Elido-Signatureと照合します。

アルゴリズムはこれがすべてです。失敗の原因は、その周りの細部にあります。早すぎるタイミングで動いたボディパーサー、デコードされてしまったシークレット、16進数のダイジェストとbase64のダイジェストの比較などです。この記事では、HMACによるWebhook署名検証を一般論として説明したうえで、Node、Python、Go、n8nのCodeノードで動くElidoのコードを示し、ほとんどのクイックスタートが省くリプレイ対策の許容時間とシークレットのローテーションも扱います。

まだエンドポイントを設定していない場合は、すべてのイベントタイプとペイロードのエンベロープを一覧にしたリンクイベントのWebhookから始めてください。このページは、署名付きリクエストがサーバーに届いた後の話から始まります。

HMACによるWebhook署名の仕組み

Webhookのエンドポイントは公開URLです。見つけた人は誰でも、本物のイベントに見えるJSONボディをPOSTできるため、受信側には送信元の証明が必要です。HMACなら安価にそれができます。送信側と受信側がシークレットを共有し、送信側がHMAC-SHA256(secret, message)を計算して結果をヘッダーに入れ、受信側が同じ計算をして比較します。シークレットがなければ一致する値は誰にも作れず、メッセージを1バイト変えるだけでダイジェスト全体が変わります。

プロバイダーによって異なる細部が3つあり、どれか1つを間違えると検証が壊れます。

  1. メッセージに何を含めるか。 GitHubは生のボディだけに署名します。StripeとElidoは、タイムスタンプ、ドット、ボディに署名します。Standard Webhooksの仕様は、メッセージID、タイムスタンプ、ボディに署名します。
  2. ダイジェストのエンコード方法。 16進数かbase64で、v1=やsha256=のような方式のプレフィックス付きです。
  3. 鍵は何か。 プレフィックスの後のシークレットをbase64デコードするプロバイダーもあります。Elidoはそうしません。鍵は、whsec_の文字列全体をUTF-8のバイト列にしたものです。

タイムスタンプを署名対象のメッセージの内側に入れることは重要です。古くて有効な署名付きボディに新しいタイムスタンプのヘッダーを組み合わせる攻撃を防ぎ、これによって初めてリプレイ対策の許容時間を実効性のあるものにできます。

Webhook署名の検証方法。Elidoはunixタイムスタンプ、ドット、生のボディを連結し、whsec_のシークレットでHMAC-SHA256によりハッシュしてX-Elido-Signatureにv1=の16進値を送る。受信側は生のバイトとタイムスタンプのヘッダーから同じ値を再計算し、定数時間で比較する エンドポイント URL、イベントの選択、受信側が HMAC 検証に使う署名シークレットを備えた Elido の Webhook エンドポイント追加フォーム

Elidoが署名するものと、それを運ぶヘッダー

eventまたはsiemのエンドポイントへの配信は、すべてContent-Type: application/jsonのPOSTで、ボディは{"type", "workspace_id", "data", "timestamp"}の形をしています。チャット型のエンドポイント(Discord、Telegram、Sentry)はURLで認証し、HMACヘッダーを持たないため、以下の内容は最初の2種類にのみ当てはまります。

ヘッダー値どう使うか
X-Elido-Signaturev1= + 小文字の16進数64文字自分で計算した値と比較する
X-Webhook-Signature上と同じ値古い別名。どちらか一方だけ読み、両方は読まない
X-Webhook-TimestampUnix秒。例: 1789000000署名対象のメッセージの一部。経過時間を確認する
X-Elido-Signature-Previousv1= + 16進数。古いシークレットで署名ローテーションの猶予期間中のみ存在する
X-Webhook-Eventイベント名。例: link.created(検証後に)イベントを振り分ける
X-Webhook-Delivery数値の配信ID。リトライをまたいで不変べき等な処理のための重複排除キー

シークレットは作成時に受け取ります。whsec_ に続く64桁の16進文字です。作成レスポンスで一度だけ返され、二度と表示されないので、すぐにシークレットストアに入れてください。

ここに、コードを検証できるテストベクターがあります。シークレット whsec_test_only_do_not_use、タイムスタンプ 1789000000、ボディ {"type":"link.created","workspace_id":42} のとき、正しいヘッダー値は次のとおりです。

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

シェルから再現できます。受信側と送信側が食い違うとき、私が最初にやるのがこれです。

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

落とし穴が1つあります。ボディ自身の timestamp フィールドはイベントが起きた時刻ですが、署名されたタイムスタンプは X-Webhook-Timestamp ヘッダーにあるもので、リクエストが送信されたときに設定されます。取り違えないでください。

NodeでWebhook署名を検証する

Expressでは、ほとんどの失敗の解決策は1行です。Webhookのルートにexpress.raw()をマウントして、req.bodyが受信した正確なバイト列のBufferになるようにします。このルートは、グローバルなapp.use(express.json())より前に登録してください。JSONパーサーがストリームを消費してしまうと、rawパーサーが読むものが何も残らないからです。

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;

function matches(expected, got) {
  const a = Buffer.from(expected);
  const b = Buffer.from(got ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post(
  "/webhooks/elido",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.get("X-Webhook-Timestamp") ?? "";
    if (
      !/^\d+$/.test(ts) ||
      Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
    ) {
      return res.status(400).send("stale or missing timestamp");
    }
    const expected =
      "v1=" +
      createHmac("sha256", SECRET)
        .update(`${ts}.`)
        .update(req.body)
        .digest("hex");
    const ok = [
      req.get("X-Elido-Signature"),
      req.get("X-Elido-Signature-Previous"),
    ].some((got) => matches(expected, got));
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue event, then acknowledge fast
    res.sendStatus(200);
  },
);

長さのチェックは飾りではありません。timingSafeEqualは、長さの異なるバッファに対してfalseを返さず、例外を投げます。APIとSDKのクイックスタートにあるTypeScript SDKを使う場合、@elido/sdkのwebhooks.verify()が同じHMACと定数時間比較を行います。ただし{ maxSkewSec: 300 }は明示的に渡してください。このオプションがないと、タイムスタンプの経過時間はまったく確認されません。

PythonとGoでのWebhook HMAC SHA256検証

Pythonは標準ライブラリで足ります。FastAPIではawait request.body()が生のバイト列を返します。Flaskでは、何かがrequest.jsonに触れる前にrequest.get_data()を呼んでください。

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()


def verify(raw: bytes, ts: str, candidates: list) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
        return False
    digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(c and hmac.compare_digest(c, expected) for c in candidates)


@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
    raw = await request.body()
    h = request.headers
    sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
    if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(raw)
    # enqueue event
    return {"ok": True}

Goでは、ボディを一度だけ読み、サイズに上限を設け、crypto/hmacのhmac.Equalを使います。これは定数時間で比較します。メッセージは、パース済みの整数を再フォーマットするのではなく、受信したままのヘッダー文字列から組み立てています。

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"time"
)

const toleranceSec = 300

// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	tsHeader := r.Header.Get("X-Webhook-Timestamp")
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return nil, false
	}
	if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
		return nil, false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(tsHeader + "."))
	mac.Write(body)
	expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))

	for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
		if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
			return body, true
		}
	}
	return nil, false
}

3つの版はいずれも、署名が不正なら401を返し、チェックを通過した後にのみJSONをパースします。Elidoは2xx以外をすべて失敗した試行として数えるため、検証処理が間違っていると、本物の配信がエンドポイントの配信ログに401として積み上がります。デプロイ後に最初に見るべき場所です。

実際のトラフィックで確かめたいですか? Webhooks機能のページからワークスペースにエンドポイントを作成し、ローカルのトンネルに向けてください。配信ログには、各試行で検証処理が返したステータスコードが表示されます。

n8nのCodeノードでWebhook署名を検証する

n8nなら、追加のサービスなしで同じチェックができます。WebhookノードでRaw Bodyオプションをオンにすると、加工されていないリクエストがバイナリデータとして保存されます。その直後にCodeノードを追加してください。n8nのCodeノードでは組み込みのcryptoモジュールが使えるので、そのまま動きます。

const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");

const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
  throw new Error("stale delivery");
}
const expected = Buffer.from(
  "v1=" +
    crypto
      .createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(raw)
      .digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
  const got = Buffer.from(h[name] ?? "");
  return (
    got.length === expected.length && crypto.timingSafeEqual(got, expected)
  );
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];

例外が投げられると実行が止まるため、偽造されたイベントで下流の処理が動くことはありません。セルフホストでの落とし穴が1つあります。インスタンスでN8N_BLOCK_ENV_ACCESS_IN_NODE=trueが設定されていると、Codeノードは$envを読めず、シークレットが空になって、すべての配信がチェックに失敗します。リバースプロキシ側についてはセルフホストn8nガイドで、イベントが届いた後に何を作るかはn8n URL短縮ノードの記事で扱っています。

Elidoと、Stripe、GitHub、Standard Webhooksとの違い

他のプロバイダー向けの検証処理を書いたことがあっても、そのままコピーしないでください。2026-10-25に各ベンダーのドキュメントで確認した内容です。

  • Stripeは、t=と1つ以上のv1=の値を1つのStripe-Signatureヘッダーにまとめ、timestamp.bodyを16進数のHMAC-SHA256で署名し、ライブラリの既定の許容時間は5分です(Stripeのドキュメント)。Elidoはタイムスタンプを独立したヘッダーに分けています。
  • GitHubは、生のボディだけに署名し、X-Hub-Signature-256にsha256=と16進数を送り、定数時間の比較を使うよう求めています(GitHubのドキュメント)。確認すべきタイムスタンプはありません。
  • Standard Webhooksは、webhook-id、webhook-timestamp、webhook-signatureを使い、id.timestamp.bodyに署名し、v1,のプレフィックス付きのbase64でエンコードし、許容時間の値は利用者に任せています(仕様)。ElidoのX-Webhook-DeliveryはIDの役割を果たしますが、署名対象のメッセージには含まれません。

Elido独自の方式は、api-coreのsigning.Signにあるとおりです。メッセージはUnixタイムスタンプ、ドット、ボディで、鍵は保存されたままのシークレット文字列、結果はv1=に小文字の16進数を付けたものです。Elidoにlink.clickedイベントはないため、このチャネルでクリック単位の配信は期待しないでください。

リプレイ対策の許容時間とシークレットのローテーション

有効な署名が証明するのは誰が送ったかであって、いつ送ったかではありません。ログの行や設定ミスのプロキシから署名付きの配信を1件キャプチャした人が、1週間後にそれを再送しても、HMACは一致してしまいます。タイムスタンプのチェックがこの隙間を塞ぎますが、それは受信側の仕事です。Elidoの配信ワーカーはタイムスタンプに署名しますが、あなたの側で許容時間を強制することはありません。私は300秒を使っています。受信側のクロックはNTPで同期してください。数分ずれたサーバーは、正当なトラフィックまで拒否し始めます。

リトライが許容時間に引っかかることはありません。各試行には新しいX-Webhook-Timestampと新しい署名が付く一方、X-Webhook-Deliveryは同じままです。この分離によって2つの防御が得られます。タイムスタンプは、キャプチャされたリクエストが使える期間を制限し、配信IDに対する一意インデックスは、正当なリトライが二重に処理されるのを防ぎます。同じパターンをインバウンドAPI側から見た話は、レート制限とべき等性の記事で扱っています。

ローテーションはPOST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret、またはエンドポイントのページにあるRotateボタンで行います。レスポンスには新しいシークレットが一度だけ含まれ、grace_window_days(7)とprevious_expires_atも返されます。その7日間、各配信は2つの署名を運びます。

  • X-Elido-Signature: 新しいシークレットで作成
  • X-Elido-Signature-Previous: 古いシークレットで作成

上のどのコードも、保持している1つのシークレットに対して両方のヘッダーを確認しているのはそのためです。古いシークレットのまま動いている受信側は2つ目のヘッダーに一致し、新しいシークレットをデプロイした後は1つ目に一致します。その間、何も失敗しません。ただし、前の鍵のヘッダーは保証ではなく橋渡しだと考え、新しいシークレットは週の早いうちにデプロイしてください。

Webhookシークレットのローテーションの時系列。ローテーション前はX-Elido-Signatureのみ送信。rotate-secret後の7日間の猶予期間は、新しい鍵のX-Elido-Signatureと古い鍵のX-Elido-Signature-Previousを配信が運び、どちらのシークレットを持つ受信側でも検証できる。猶予期間の後は新しい鍵のみで署名

Webhook署名の検証が失敗する理由

この問題のデバッグを手伝うとき、ほぼ毎回同じ短いリストに行き着きます。順番に確認してください。

  1. 再シリアライズされたJSON。 JSON.stringify(req.body)やjson.dumps(payload)は、送信側がハッシュしたものとは異なるバイト列を生成します。キーの順序、空白、エスケープされたスラッシュ、Unicodeエスケープなどです。生のボディをハッシュしてください。フレームワークがすでにパースしてしまった場合は、文字列を作り直そうとせず、ミドルウェアの順序を直してください。
  2. 鍵のバイト列の誤り。 Elidoはwhsec_...の文字列全体をHMACの鍵として使います。プレフィックスを取り除く、残りを16進デコードする、base64デコードする(Standard Webhooksのライブラリはこれを行います)と、別の鍵になります。echoでシークレットファイルに書いたときの末尾の改行も同じ結果を招くため、Pythonの例では.strip()を呼んでいます。
  3. エンコーディングの不一致。 v1=に小文字の16進数を付けたものをヘッダーと比較してください。base64のダイジェスト、大文字の16進数、プレフィックスの欠落は、決して一致しません。
  4. タイムスタンプの誤り。 ボディのtimestampフィールドや、パースして再フォーマットした数値ではなく、X-Webhook-Timestampヘッダーの文字列を使ってください。

さらに小さな点が2つあります。==での比較は動きますがタイミングが漏れるため、言語が提供する定数時間の関数を使ってください。また、ボディを展開したり再エンコードしたりするプロキシも検証を壊しますが、プレーンなJSONのPOSTではまれです。

それでも両側が食い違うときは、タイムスタンプ、ボディの長さ、自分のダイジェストの最初の数文字の16進数をログに出し、先ほどのopensslの行を同じ入力で実行してください。opensslと一致するほうが正しい側です。プロバイダーを問わず確認すべき他の制御の中で、署名がどこに位置づけられるかは、URL短縮サービスのセキュリティチェックリストをご覧ください。

基幹記事もお読みください: リンクイベントのWebhook。

関連記事

よくある質問

Webhookの署名はどうやって検証しますか?

送信側が署名したものとまったく同じ内容に対して、共有シークレットを使ってHMACを再計算し、結果を署名ヘッダーと定数時間で比較します。Elidoの場合は、X-Webhook-Timestampの値、ドット、生のボディを対象にしたHMAC-SHA256を、v1=のプレフィックス付きの16進数でエンコードしたものです。一致しない場合やタイムスタンプが古い場合は、リクエストを拒否してください。

Webhookの署名検証がずっと失敗するのはなぜですか?

ほぼ必ず、送信側とは異なるバイト列をハッシュしているためです。先にJSONボディのパーサーが動いてオブジェクトを再シリアライズした、シークレットをデコードした、16進数とbase64を比較した、といった原因です。生のリクエストバイトをハッシュし、シークレットは発行されたとおりの文字列で使い、両方の値を並べてログに出してください。

WebhookのHMACとは何ですか?

HMACは鍵付きハッシュです。送信側が、あなたと共有するシークレットを、メッセージのSHA-256ハッシュに混ぜ込みます。シークレットを持つ者だけが一致する値を作れるため、有効な署名は、リクエストが送信側から来たこと、ボディが途中で改ざんされていないことを証明します。

Webhookのリプレイ攻撃はどう防ぎますか?

タイムスタンプをボディと一緒に署名し、タイムスタンプが数分以上古いリクエストを拒否します。5分が一般的な許容時間です。そのうえで配信IDを保存し、すでに処理したIDはスキップします。Elidoはタイムスタンプに署名しますが、鮮度の確認は受信側に任せています。

HMAC-SHA256のWebhook署名は16進数とbase64のどちらを使うべきですか?

送信側が文書化しているほうです。同じダイジェストでも、2つのエンコーディングは決して一致しません。Elidoはv1=のプレフィックスの後に小文字の16進数を送ります。ShopifyとStandard Webhooksの仕様はbase64を使い、GitHubはsha256=の後に16進数を使います。比較する前に、自分のダイジェストを同じ方法でエンコードしてください。

イベントを落とさずにWebhookのシークレットをローテーションするには?

しばらくの間、両方の鍵で署名する送信側を使います。Elidoのエンドポイントのシークレットをローテーションすると、各配信は新しい鍵のX-Elido-Signatureと、7日間は古い鍵のX-Elido-Signature-Previousの両方を運びます。どちらのヘッダーも受け入れて新しいシークレットをデプロイすれば、古いシークレットは自然に失効します。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

登録不要。無料登録すると月 50 件のリンクを作成できます。

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

続きを読む