Webhook の署名を検証する2 分で読めます
Go で Webhook の署名を検証する
Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
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 つのスライスを検証と解析の両方に使います。
- MaxBytesReader でボディを包み、Ikiform のペイロード上限 256 KB より大きい 300 KB を超えたら読み込みを止めます。
- io.ReadAll でボディ全体を 1 つのバイトスライスに読み込みます。
- VerifySignature が、ヘッダー、タイムスタンプ、各 v1 署名をそのバイト列に対して確認します。
- 成功したら、json.Unmarshal で同じスライスを構造体に解析します。
- ハンドラーは 204 を返します。検証エラーのときは、理由を付けて 400 を返します。
検証関数
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 回だけデコードします。
ハンドラー
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 が表示されるので、失敗の理由はそこでわかります。
c.GetRawData() で、Echo では io.ReadAll(c.Request().Body) でボディを読みます。chi なら上のハンドラーがそのまま動きます。ボディは 1 回しか読めないという同じ理由から、検証の前に c.ShouldBindJSON や c.Bind を呼ばないでください。テーブル駆動テスト
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 で初回かどうかがわかります。
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 には専用のコンテキストを渡してください。
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 で追加します。
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 もありますが、リプレイ攻撃を再び許してしまうので本番環境では使わないでください。
動作を確認する
go test ./...を実行します。5 つのケースすべてが通ります。- 公開された
https:の URL でサーバーを起動し、ダッシュボードの Webhooks に追加します。 - エンドポイントの
whsec_のシークレットをIKIFORM_WEBHOOK_SECRETとして export します。有効なシークレットがないと、サーバーは起動しません。 - Send test を選びます。配信ログに
204が表示されます。 - でたらめな署名を付けて 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 を渡してください |
よくある質問
crypto/hmac の hmac.Equal を使います。定数時間で比較し、長さが違えば false を返します。bytes.Equal や == は、1 バイトでも違った時点で返る可能性があるので使わないでください。
リクエストボディはストリームなので、1 回しか読めません。io.ReadAll でバイトスライスに読み込み、そのスライスを検証してから、同じスライスを json.Unmarshal してください。
はい、standard-webhooks リポジトリの libraries/go にあります。NewWebhook(secret) は whsec_ のシークレットを受け取り、Verify(body, r.Header) は確認に失敗するとエラーを返します。
その必要はありません。hmac.Equal は内部で subtle.ConstantTimeCompare を呼び出し、MAC を比較する方法としてドキュメントに記載されています。どちらも定数時間ですが、== と bytes.Equal はそうではありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com