웹훅 서명 검증

웹훅 서명 검증4분 분량

Go에서 웹훅 서명 검증하기

Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, 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 호출 하나로 같은 일을 합니다.

Go 표준 라이브러리에는 확인에 필요한 모든 것이 있으므로 이 페이지는 마지막의 선택 라이브러리 전까지 프레임워크도 의존성도 쓰지 않습니다. 핸들러는 Go 1.22에 추가된 메서드와 경로 패턴을 쓰는 일반 net/http이며, 같은 함수를 chi, Gin, Echo에 그대로 넣을 수 있습니다.

Go에서 다른 점

net/http는 본문을 대신 파싱하지 않으므로 흔한 원본 본문 함정이 없습니다. Go 고유의 함정은 스트림 자체입니다. r.Body는 한 번만 읽을 수 있습니다. 관용적인 json.NewDecoder(r.Body).Decode(&event)가 이를 소비하면 검증할 것이 남지 않습니다. 그러니 큰 POST가 메모리를 고갈시키지 않도록 한도를 두고 바이트를 먼저 메모리에 읽은 뒤, 그 슬라이스 하나를 두 작업에 모두 쓰세요.

Go 웹훅 핸들러의 작업 순서MaxBytesReader300 KB 상한io.ReadAll[]byte 하나VerifySignaturev1마다hmac.Equaljson.Unmarshal같은 바이트204400사유로err.Error()사용오류Go 웹훅 핸들러의 작업 순서MaxBytesReader300 KB 상한io.ReadAll[]byte 하나VerifySignaturev1마다 hmac.Equaljson.Unmarshal같은 바이트204400사유로err.Error()사용오류
  1. MaxBytesReader가 본문을 감싸 Ikiform의 256 KB 페이로드 상한보다 높은 300 KB를 넘으면 읽기를 멈춥니다.
  2. io.ReadAll이 본문 전체를 바이트 슬라이스 하나로 읽습니다.
  3. VerifySignature가 그 바이트를 기준으로 헤더, 타임스탬프, 각 v1 서명을 확인합니다.
  4. 성공하면 json.Unmarshal이 같은 슬라이스를 구조체로 파싱합니다.
  5. 핸들러가 204로 응답합니다. 검증 오류가 나면 사유와 함께 400으로 응답합니다.
r.Body에 json.Decoder를 쓰면 두 번째 단계를 건너뛰어 검증할 것이 남지 않습니다.

검증 함수

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이 한 번 디코딩합니다.

핸들러

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에서는 위 핸들러가 그대로 동작합니다. 같은 한 번 읽기 이유로 검증 전에 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는 벡터의 타임스탬프를 범위 안에 둡니다.

반복과 느린 작업

전달이 검증된 뒤에는 두 가지 일이 일어납니다. 먼저, 본 적이 있다면 건너뛰세요. 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
}

다음으로, 느린 작업 전에 응답하세요. 고루틴이 뻔한 도구지만 Go 고유의 함정이 하나 있습니다. r.Context()는 핸들러가 반환하는 즉시 취소되므로, 백그라운드에서 그것으로 만든 데이터베이스 호출이나 HTTP 요청은 context canceled로 실패합니다. 고루틴에 자체 컨텍스트를 주세요.

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)

고루틴은 프로세스와 함께 죽으므로 작업 중간에 배포하면 작업을 잃습니다. 잃으면 안 되는 것은 핸들러 안에서 이벤트를 큐나 테이블에 쓰고, 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 ./...를 실행하세요. 다섯 경우가 모두 통과합니다.
  2. 공개 https: URL 뒤에서 서버를 시작하고 대시보드의 Webhooks에 추가하세요.
  3. 엔드포인트의 whsec_ 시크릿으로 IKIFORM_WEBHOOK_SECRET을 export하세요. 유효한 시크릿이 없으면 서버가 시작을 거부합니다.
  4. Send test를 고르세요. 전달 로그에 204가 표시됩니다.
  5. 지어낸 서명으로 curl 요청을 보내세요. 400 no_matching_signature를 받습니다.

주의할 점

문제원인과 해결
검증할 때 본문이 비어 있음무언가 r.Body를 먼저 읽었습니다. 대개 디코더나 로깅 미들웨어입니다. 한 번 읽고 슬라이스를 넘기세요
시작 시 illegal base64 data시크릿을 RawStdEncoding으로 디코딩했거나 끝의 = 없이 복사했습니다. StdEncoding과 전체 값을 쓰세요
웹훅 경로에서 404POST /webhooks/ikiform 같은 패턴에는 Go 1.22 이상과 go.mod의 go 1.22 이상 줄이 필요합니다. 이전 설정은 패턴을 문자 그대로의 경로로 취급합니다
전달 로그에 413MaxBytesReader 한도가 256 KB보다 낮습니다. Ikiform의 페이로드 상한보다 높게 유지하세요
테스트 벡터가 timestamp_too_old로 실패함테스트가 time.Now()를 썼습니다. 위처럼 테스트에서 고정된 now를 넘기세요

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.