Webhook の署名を検証する

Webhook の署名を検証する2 分で読めます

Go で Webhook の署名を検証する

Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。

Preet SutharIkiform 創業者

http.MaxBytesReader を挟んで io.ReadAll でボディ全体を読み、base64 デコードした whsec_ のシークレットで id.timestamp.body の HMAC-SHA256 を計算して、hmac.Equal で各 v1 署名と比較します。タイムスタンプが 5 分以内であることを確認し、そのあとで初めて同じバイト列を json.Unmarshal します。Standard Webhooks の Go ライブラリなら、同じ処理を Verify の 1 回の呼び出しで行えます。

検証に必要なものは Go の標準ライブラリにすべてそろっているので、このページでは最後のオプションのライブラリまで、フレームワークも依存パッケージも使いません。ハンドラーは Go 1.22 で追加されたメソッドとパスのパターンを使う素の net/http で、同じ関数は chi、Gin、Echo にもそのまま組み込めます。

Go ならではの違い

net/http はボディを勝手に解析しないので、生のボディにまつわるよくある落とし穴はありません。Go 特有の落とし穴はストリームそのものです。r.Body は 1 回しか読めません。定番の json.NewDecoder(r.Body).Decode(&event) がボディを読み切ってしまうと、検証するものが何も残りません。そこで、大きな送信でメモリを使い果たさないよう上限を付けてまずバイト列をメモリに読み込み、その 1 つのスライスを検証と解析の両方に使います。

Go の Webhook ハンドラーでの処理の順番MaxBytesReader上限 300 KBio.ReadAll1 つの[]byteVerifySignaturev1 ごとにhmac.Equaljson.Unmarshal同じバイト列204400理由はerr.Error()エラーGo の Webhook ハンドラーでの処理の順番MaxBytesReader上限 300 KBio.ReadAll1 つの []byteVerifySignaturev1 ごとに hmac.Equaljson.Unmarshal同じバイト列204400理由はerr.Error()エラー
  1. MaxBytesReader でボディを包み、Ikiform のペイロード上限 256 KB より大きい 300 KB を超えたら読み込みを止めます。
  2. io.ReadAll でボディ全体を 1 つのバイトスライスに読み込みます。
  3. VerifySignature が、ヘッダー、タイムスタンプ、各 v1 署名をそのバイト列に対して確認します。
  4. 成功したら、json.Unmarshal で同じスライスを構造体に解析します。
  5. ハンドラーは 204 を返します。検証エラーのときは、理由を付けて 400 を返します。
r.Body に json.Decoder を使うと 2 番目の手順が抜け、検証するものが残りません。

検証関数

signature.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

var (
	ErrMissingHeaders   = errors.New("missing_headers")
	ErrInvalidTimestamp = errors.New("invalid_timestamp")
	ErrTooOld           = errors.New("timestamp_too_old")
	ErrTooNew           = errors.New("timestamp_too_new")
	ErrNoMatch          = errors.New("no_matching_signature")
)

func VerifySignature(body []byte, header http.Header, key []byte, now time.Time) error {
	id := header.Get("webhook-id")
	timestamp := header.Get("webhook-timestamp")
	signatures := header.Get("webhook-signature")
	if id == "" || timestamp == "" || signatures == "" {
		return ErrMissingHeaders
	}

	seconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return ErrInvalidTimestamp
	}
	age := now.Sub(time.Unix(seconds, 0))
	if age > tolerance {
		return ErrTooOld
	}
	if age < -tolerance {
		return ErrTooNew
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(body)
	expected := mac.Sum(nil)

	for _, entry := range strings.Split(signatures, " ") {
		version, value, found := strings.Cut(entry, ",")
		if !found || version != "v1" {
			continue
		}
		received, err := base64.StdEncoding.DecodeString(value)
		if err == nil && hmac.Equal(received, expected) {
			return nil
		}
	}
	return ErrNoMatch
}

func DecodeSecret(secret string) ([]byte, error) {
	return base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
}
  • header.Get は名前を正規化するので、リクエストでどう表記されていても webhook-id で Webhook-Id が見つかります。
  • hmac.Equal は定数時間で比較し、長さが違えば false を返すので、形式の崩れた署名は単に一致しないものとして扱われます。
  • strings.Cut は最初のカンマでだけ分割します。これは仕様が求めるとおりの動作です。
  • base64.StdEncoding はパディング付きの標準アルファベットです。RawStdEncoding や URLEncoding では、シークレットも署名もデコードに失敗するか、誤ってデコードされます。
  • 鍵はリクエストごとではなく、起動時に DecodeSecret で 1 回だけデコードします。

ハンドラー

main.go
package main

import (
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

type Event struct {
	ID   string `json:"id"`
	Type string `json:"type"`
	Data struct {
		SubmissionID string         `json:"submission_id"`
		Fields       map[string]any `json:"fields"`
	} `json:"data"`
}

func main() {
	key, err := DecodeSecret(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
	if err != nil || len(key) == 0 {
		log.Fatal("IKIFORM_WEBHOOK_SECRET must be the whsec_ secret from the dashboard")
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
		if err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		if err := VerifySignature(body, r.Header, key, time.Now()); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		var event Event
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "invalid_payload", http.StatusBadRequest)
			return
		}
		if event.Type == "submission.created" {
			log.Printf("submission %s %v", event.Data.SubmissionID, event.Data.Fields)
		}
		w.WriteHeader(http.StatusNoContent)
	})

	server := &http.Server{
		Addr:              ":3000",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

ReadHeaderTimeout は遅いクライアントからサーバーを守ります。素の http.ListenAndServe にはタイムアウトがまったくありません。構造体には使うフィールドだけを置きます。json.Unmarshal は残りを無視するので、ペイロードに新しいフィールドが増えても壊れません。nginx などのリバースプロキシの背後に置く場合は、そのボディ上限 client_max_body_size も 256 KB より大きくしてください。そうしないと、大きな送信は Go に届く前にプロキシで拒否されます。400 のボディには no_matching_signature などのエラーの文字列が入り、配信ログには各レスポンスの先頭 4 KB が表示されるので、失敗の理由はそこでわかります。

Gin では c.GetRawData() で、Echo では io.ReadAll(c.Request().Body) でボディを読みます。chi なら上のハンドラーがそのまま動きます。ボディは 1 回しか読めないという同じ理由から、検証の前に c.ShouldBindJSON や c.Bind を呼ばないでください。

テーブル駆動テスト

signature_test.go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
	"testing"
	"time"
)

const (
	secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
	rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
	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."}}`
)

func vectorHeader(signature string) http.Header {
	header := http.Header{}
	header.Set("webhook-id", "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1")
	header.Set("webhook-timestamp", "1792300000")
	header.Set("webhook-signature", signature)
	return header
}

var now = time.Unix(1792300000, 0).Add(30 * time.Second)

func TestVerifySignature(t *testing.T) {
	key, _ := DecodeSecret(secret)
	rolledKey, _ := DecodeSecret(rolled)
	single := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=")
	both := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")

	var pretty bytes.Buffer
	if err := json.Indent(&pretty, []byte(body), "", "  "); err != nil {
		t.Fatal(err)
	}

	cases := []struct {
		name   string
		body   []byte
		header http.Header
		key    []byte
		now    time.Time
		want   error
	}{
		{"vector", []byte(body), single, key, now, nil},
		{"old secret during a roll", []byte(body), both, key, now, nil},
		{"new secret during a roll", []byte(body), both, rolledKey, now, nil},
		{"re-serialized body", pretty.Bytes(), single, key, now, ErrNoMatch},
		{"stale timestamp", []byte(body), single, key, now.Add(10 * time.Minute), ErrTooOld},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := VerifySignature(c.body, c.header, c.key, c.now); !errors.Is(got, c.want) {
				t.Fatalf("got %v, want %v", got, c.want)
			}
		})
	}
}

go test ./... を実行します。テストケースはテストベクターから取っています。json.Indent は実際の Go のコードベースで起こりうる再シリアライズされたボディを作り、固定した now でベクターのタイムスタンプを許容範囲内に保ちます。

重複と時間のかかる処理

配信を検証したあとにやることは 2 つあります。まず、処理済みの配信はスキップします。Ikiform は 15 秒以内に 2xx が返らないと、ハンドラーの処理が終わっていても同じ webhook-id でリトライします。PostgreSQL とユニーク列を使えば、RowsAffected で初回かどうかがわかります。

deliveries.go
func firstDelivery(ctx context.Context, db *sql.DB, deliveryID string) (bool, error) {
	result, err := db.ExecContext(ctx,
		"INSERT INTO webhook_deliveries (delivery_id) VALUES ($1) ON CONFLICT (delivery_id) DO NOTHING",
		deliveryID,
	)
	if err != nil {
		return false, err
	}
	rows, err := result.RowsAffected()
	return rows == 1, err
}

次に、時間のかかる処理の前に応答を返します。goroutine を使うのが自然ですが、Go 特有の落とし穴が 1 つあります。r.Context() はハンドラーが戻った時点でキャンセルされるので、それを使ってバックグラウンドで行うデータベースの呼び出しや HTTP リクエストは context canceled で失敗します。goroutine には専用のコンテキストを渡してください。

main.go
go func(event Event) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	if err := forward(ctx, event); err != nil {
		log.Printf("forward %s: %v", event.ID, err)
	}
}(event)
w.WriteHeader(http.StatusNoContent)

goroutine はプロセスと一緒に終わるので、処理の途中でデプロイすると失われます。失ってはいけない処理は、ハンドラーの中でイベントをキューかテーブルに書き込み、204 を返してから、そこから処理してください。

Standard Webhooks ライブラリを使う

仕様の Go ライブラリは、リポジトリの libraries モジュールにあります。go get github.com/standard-webhooks/standard-webhooks/libraries で追加します。

main.go
package main

import (
	"io"
	"log"
	"net/http"
	"os"

	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func main() {
	webhook, err := standardwebhooks.NewWebhook(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
	if err != nil {
		log.Fatal(err)
	}

	http.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
		if err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		if err := webhook.Verify(body, r.Header); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})

	log.Fatal(http.ListenAndServe(":3000", nil))
}

NewWebhook は whsec_ を取り除いてシークレットをデコードします。Verify はボディと r.Header をそのまま受け取り、ErrNoMatchingSignature や ErrMessageTooOld などをラップしたエラーを返すので、errors.Is で区別できます。VerifyIgnoringTimestamp もありますが、リプレイ攻撃を再び許してしまうので本番環境では使わないでください。

動作を確認する

  1. go test ./... を実行します。5 つのケースすべてが通ります。
  2. 公開された https: の URL でサーバーを起動し、ダッシュボードの Webhooks に追加します。
  3. エンドポイントの whsec_ のシークレットを IKIFORM_WEBHOOK_SECRET として export します。有効なシークレットがないと、サーバーは起動しません。
  4. Send test を選びます。配信ログに 204 が表示されます。
  5. でたらめな署名を付けて curl でリクエストを送ります。400 no_matching_signature が返ります。

ハマりどころ

問題原因と対処
検証時にボディが空になっているデコーダーやログ用のミドルウェアなど、何かが先に r.Body を読んでいます。1 回だけ読んで、そのスライスを渡してください
起動時に illegal base64 data が出るシークレットを RawStdEncoding でデコードしたか、末尾の = を付けずにコピーしています。StdEncoding と完全な値を使ってください
Webhook のパスで 404 になるPOST /webhooks/ikiform のようなパターンには、Go 1.22 以降と、go.mod の go 1.22 以降の行が必要です。古い設定では、パターンが文字どおりのパスとして扱われます
配信ログに 413 が出るMaxBytesReader の上限が 256 KB を下回っています。Ikiform のペイロード上限より大きくしてください
テストベクターが timestamp_too_old で失敗するテストで time.Now() を使っています。上の例のように、テストで固定した now を渡してください

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com