웹훅 서명 검증4분 분량
Go에서 웹훅 서명 검증하기
Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, 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 호출 하나로 같은 일을 합니다.
Go 표준 라이브러리에는 확인에 필요한 모든 것이 있으므로 이 페이지는 마지막의 선택 라이브러리 전까지 프레임워크도 의존성도 쓰지 않습니다. 핸들러는 Go 1.22에 추가된 메서드와 경로 패턴을 쓰는 일반 net/http이며, 같은 함수를 chi, Gin, Echo에 그대로 넣을 수 있습니다.
Go에서 다른 점
net/http는 본문을 대신 파싱하지 않으므로 흔한 원본 본문 함정이 없습니다. Go 고유의 함정은 스트림 자체입니다. r.Body는 한 번만 읽을 수 있습니다. 관용적인 json.NewDecoder(r.Body).Decode(&event)가 이를 소비하면 검증할 것이 남지 않습니다. 그러니 큰 POST가 메모리를 고갈시키지 않도록 한도를 두고 바이트를 먼저 메모리에 읽은 뒤, 그 슬라이스 하나를 두 작업에 모두 쓰세요.
- MaxBytesReader가 본문을 감싸 Ikiform의 256 KB 페이로드 상한보다 높은 300 KB를 넘으면 읽기를 멈춥니다.
- io.ReadAll이 본문 전체를 바이트 슬라이스 하나로 읽습니다.
- 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이 한 번 디코딩합니다.
핸들러
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에서는 위 핸들러가 그대로 동작합니다. 같은 한 번 읽기 이유로 검증 전에 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는 벡터의 타임스탬프를 범위 안에 둡니다.
반복과 느린 작업
전달이 검증된 뒤에는 두 가지 일이 일어납니다. 먼저, 본 적이 있다면 건너뛰세요. 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
}다음으로, 느린 작업 전에 응답하세요. 고루틴이 뻔한 도구지만 Go 고유의 함정이 하나 있습니다. r.Context()는 핸들러가 반환하는 즉시 취소되므로, 백그라운드에서 그것으로 만든 데이터베이스 호출이나 HTTP 요청은 context canceled로 실패합니다. 고루틴에 자체 컨텍스트를 주세요.
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로 추가하세요.
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 ./...를 실행하세요. 다섯 경우가 모두 통과합니다.- 공개
https:URL 뒤에서 서버를 시작하고 대시보드의 Webhooks에 추가하세요. - 엔드포인트의
whsec_시크릿으로IKIFORM_WEBHOOK_SECRET을 export하세요. 유효한 시크릿이 없으면 서버가 시작을 거부합니다. - Send test를 고르세요. 전달 로그에
204가 표시됩니다. - 지어낸 서명으로 curl 요청을 보내세요.
400 no_matching_signature를 받습니다.
주의할 점
| 문제 | 원인과 해결 |
|---|---|
| 검증할 때 본문이 비어 있음 | 무언가 r.Body를 먼저 읽었습니다. 대개 디코더나 로깅 미들웨어입니다. 한 번 읽고 슬라이스를 넘기세요 |
시작 시 illegal base64 data | 시크릿을 RawStdEncoding으로 디코딩했거나 끝의 = 없이 복사했습니다. StdEncoding과 전체 값을 쓰세요 |
웹훅 경로에서 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이나 ==는 쓰지 마세요.
요청 본문은 스트림이라 한 번만 읽을 수 있습니다. 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.