# Go에서 웹훅 서명 검증하기

> Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, crypto/hmac으로 확인하고, 테스트하거나, Standard Webhooks 라이브러리를 쓰세요.

2026-10-25 게시 · https://ikiform.com/ko/guides/webhook-signature-verification/go

`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 웹훅 핸들러의 작업 순서

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`:

```text
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`:

```text
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`:

```text
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 ./...`를 실행하세요. 경우들은 [테스트 벡터](https://ikiform.com/ko/guides/webhook-signature-verification.md#test-vector)에서 옵니다. `json.Indent`는 실제 Go 코드베이스가 만들 법한 다시 직렬화된 본문을 주고, 고정된 `now`는 벡터의 타임스탬프를 범위 안에 둡니다.

## 반복과 느린 작업

전달이 검증된 뒤에는 두 가지 일이 일어납니다. 먼저, 본 적이 있다면 건너뛰세요. Ikiform은 15초 안에 `2xx`를 받지 못하면 핸들러가 끝났더라도 같은 `webhook-id`로 재시도합니다. PostgreSQL과 고유 열이 있다면 `RowsAffected`가 처음인지 알려 줍니다.

`deliveries.go`:

```text
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`:

```text
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`:

```text
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`과 전체 값을 쓰세요 |
| 웹훅 경로에서 `404` | `POST /webhooks/ikiform` 같은 패턴에는 Go 1.22 이상과 `go.mod`의 `go 1.22` 이상 줄이 필요합니다. 이전 설정은 패턴을 문자 그대로의 경로로 취급합니다 |
| 전달 로그에 `413` | `MaxBytesReader` 한도가 256 KB보다 낮습니다. Ikiform의 페이로드 상한보다 높게 유지하세요 |
| 테스트 벡터가 `timestamp_too_old`로 실패함 | 테스트가 `time.Now()`를 썼습니다. 위처럼 테스트에서 고정된 `now`를 넘기세요 |

## 질문

### Go에서 HMAC 서명을 안전하게 비교하려면 어떻게 하나요?

crypto/hmac의 hmac.Equal을 쓰세요. 상수 시간으로 비교하고 길이가 다르면 false를 반환합니다. 한 바이트라도 다르면 바로 반환할 수 있는 bytes.Equal이나 ==는 쓰지 마세요.

### JSON을 디코딩한 뒤 r.Body가 비어 있는 이유는 무엇인가요?

요청 본문은 스트림이라 한 번만 읽을 수 있습니다. io.ReadAll로 바이트 슬라이스에 읽고, 슬라이스를 검증한 뒤, 같은 슬라이스를 json.Unmarshal하세요.

### Go용 Standard Webhooks 라이브러리가 있나요?

네, standard-webhooks 저장소의 libraries/go에 있습니다. NewWebhook(secret)은 whsec_ 시크릿을 받고, Verify(body, r.Header)는 확인에 실패하면 오류를 반환합니다.

### hmac.Equal 대신 crypto/subtle을 써야 하나요?

그럴 필요는 없습니다. hmac.Equal이 subtle.ConstantTimeCompare를 대신 호출하며 MAC을 비교하는 문서화된 방법입니다. 둘 다 상수 시간이지만 ==와 bytes.Equal은 그렇지 않습니다.

## 가이드 더 보기

- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [Node.js](https://ikiform.com/ko/guides/webhook-signature-verification/nodejs.md): Node.js에서 Ikiform 웹훅 서명을 검증하세요. Express와 Fastify의 원본 본문, SDK, standardwebhooks 패키지, 테스트된 node:crypto 버전을 다룹니다.
- [Python](https://ikiform.com/ko/guides/webhook-signature-verification/python.md): standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.
- [PHP](https://ikiform.com/ko/guides/webhook-signature-verification/php.md): 일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.
- [Ruby](https://ikiform.com/ko/guides/webhook-signature-verification/ruby.md): Sinatra에서는 OpenSSL과 Minitest 테스트로, Rails에서는 standardwebhooks gem과 request.raw_post로 Ikiform 웹훅 서명을 검증하세요.
