Verifikasi tanda tangan webhook

Verifikasi tanda tangan webhook5 menit baca

Verifikasi tanda tangan webhook di Go

Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.

Preet SutharPendiri, Ikiform

Baca seluruh body dengan io.ReadAll di balik http.MaxBytesReader, lalu hitung HMAC-SHA256 dari id.timestamp.body dengan secret whsec_ hasil decode base64 dan bandingkan dengan setiap tanda tangan v1 memakai hmac.Equal. Pastikan timestamp dalam 5 menit, dan baru setelah itu json.Unmarshal byte yang sama. Library Go Standard Webhooks melakukan hal yang sama dalam satu panggilan Verify.

Standard library Go punya semua yang dibutuhkan pemeriksaan ini, jadi halaman ini tidak memakai framework atau dependency sampai library opsional di bagian akhir. Handler-nya net/http biasa dengan pola method-dan-path yang ditambahkan di Go 1.22, dan function yang sama bisa dipakai di chi, Gin, atau Echo tanpa perubahan.

Apa yang berbeda di Go

Tidak ada bagian net/http yang mem-parsing body untuk Anda, jadi jebakan body mentah yang biasa tidak ada. Jebakan khas Go adalah stream itu sendiri: r.Body hanya bisa dibaca sekali. json.NewDecoder(r.Body).Decode(&event) yang idiomatis menghabiskannya, dan tidak ada yang tersisa untuk diverifikasi. Jadi baca byte-nya ke memori lebih dulu, dengan batas agar post besar tidak menghabiskan memori, dan gunakan satu slice itu untuk kedua tugas.

Urutan operasi di handler webhook GoMaxBytesReaderbatas 300KBio.ReadAllsatu[]byteVerifySignaturehmac.Equalper v1json.Unmarshalbyte yangsama204400err.Error()sebagaialasanerrorUrutan operasi di handler webhook GoMaxBytesReaderbatas 300 KBio.ReadAllsatu []byteVerifySignaturehmac.Equal per v1json.Unmarshalbyte yang sama204400err.Error()sebagaialasanerror
  1. MaxBytesReader membungkus body agar pembacaan berhenti setelah 300 KB, di atas batas payload Ikiform 256 KB.
  2. io.ReadAll membaca seluruh body ke satu byte slice.
  3. VerifySignature memeriksa header, timestamp, dan setiap tanda tangan v1 terhadap byte itu.
  4. Jika berhasil, json.Unmarshal mem-parsing slice yang sama ke dalam struct.
  5. Handler menjawab 204. Pada error verifikasi apa pun, handler menjawab 400 beserta alasannya.
json.Decoder pada r.Body akan melewati langkah kedua dan tidak menyisakan apa pun untuk diverifikasi.

Function verifikasi

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 menormalkan nama, jadi webhook-id menemukan Webhook-Id bagaimanapun request menuliskannya.
  • hmac.Equal bersifat constant-time dan mengembalikan false untuk panjang yang berbeda, jadi tanda tangan yang cacat cukup dianggap tidak cocok.
  • strings.Cut hanya memisahkan di koma pertama, sesuai yang diminta spesifikasi.
  • base64.StdEncoding adalah alfabet standar dengan padding. RawStdEncoding atau URLEncoding akan menolak atau salah men-decode secret maupun tanda tangan.
  • Key di-decode sekali saat startup oleh DecodeSecret, bukan di setiap request.

Handler-nya

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 melindungi server dari klien yang lambat; http.ListenAndServe polos sama sekali tidak punya timeout. Struct hanya menyimpan field yang Anda pakai. json.Unmarshal mengabaikan sisanya, jadi field payload baru tidak pernah merusaknya. Di balik reverse proxy seperti nginx, pertahankan batas body-nya, client_max_body_size, di atas 256 KB juga, atau proxy menolak kiriman besar sebelum Go melihatnya. Body 400 membawa teks error, seperti no_matching_signature, dan log pengiriman menampilkan 4 KB pertama dari setiap respons, jadi kegagalan menjelaskan dirinya sendiri di sana.

Di Gin, baca body dengan c.GetRawData(); di Echo, io.ReadAll(c.Request().Body); di chi, handler di atas langsung berfungsi. Jangan panggil c.ShouldBindJSON atau c.Bind sebelum verifikasi, karena alasan sekali-baca yang sama.

Table test

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)
			}
		})
	}
}

Jalankan go test ./.... Kasus-kasusnya berasal dari test vector. json.Indent menghasilkan body serialisasi ulang yang mungkin muncul di codebase Go sungguhan, dan now yang tetap menjaga timestamp vector di dalam jendela.

Pengulangan dan pekerjaan lambat

Dua hal terjadi setelah pengiriman terverifikasi. Pertama, lewati jika sudah pernah Anda lihat: Ikiform mencoba ulang dengan webhook-id yang sama saat tidak mendapat 2xx dalam 15 detik, meski handler Anda sudah selesai. Dengan PostgreSQL dan kolom unik, RowsAffected memberi tahu apakah ini yang pertama:

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
}

Kedua, jawab sebelum pekerjaan lambat. Goroutine adalah alat yang jelas, dengan satu jebakan khas Go: r.Context() dibatalkan begitu handler kembali, jadi panggilan database atau request HTTP apa pun yang dibuat dengannya di latar belakang gagal dengan context canceled. Beri goroutine context-nya sendiri:

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 mati bersama prosesnya, jadi deploy di tengah pekerjaan akan menghilangkannya. Untuk apa pun yang tidak boleh hilang, tulis event ke antrean atau tabel di dalam handler, jawab 204, dan proses dari sana.

Dengan library Standard Webhooks

Library Go dari spesifikasi ini ada di modul libraries di repositorinya. Tambahkan dengan 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 menghapus whsec_ dan men-decode secret. Verify menerima body dan r.Header secara langsung dan mengembalikan error yang membungkus ErrNoMatchingSignature, ErrMessageTooOld, dan lainnya, jadi Anda bisa membedakannya dengan errors.Is. Ada juga VerifyIgnoringTimestamp; jangan memakainya di produksi, karena membuka kembali celah replay.

Pastikan berfungsi

  1. Jalankan go test ./.... Kelima kasus lolos.
  2. Jalankan server di balik URL https: publik dan tambahkan di Webhooks di dashboard.
  3. Ekspor IKIFORM_WEBHOOK_SECRET dengan secret whsec_ milik endpoint. Server menolak berjalan tanpa secret yang valid.
  4. Pilih Send test. Log pengiriman menampilkan 204.
  5. Kirim request dengan curl dan tanda tangan karangan. Request itu mendapat 400 no_matching_signature.

Hal yang sering menjebak

MasalahPenyebab dan solusi
Body kosong saat Anda memverifikasiAda yang membaca r.Body lebih dulu, sering kali decoder atau middleware logging. Baca sekali dan teruskan slice-nya
illegal base64 data saat startupSecret di-decode dengan RawStdEncoding, atau disalin tanpa = di akhirnya. Gunakan StdEncoding dan nilai lengkapnya
404 di path webhookPola seperti POST /webhooks/ikiform butuh Go 1.22 atau lebih baru dan baris go 1.22 atau lebih baru di go.mod; pengaturan lama memperlakukan pola itu sebagai path literal
413 di log pengirimanBatas MaxBytesReader di bawah 256 KB. Pertahankan di atas batas payload Ikiform
Test vector gagal dengan timestamp_too_oldTest memakai time.Now(). Berikan now yang tetap dari test, seperti di atas

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.