Verifying Webhooks
CROSS Pay 의 webhook 서명은 Standard Webhooks v1 표준을 따릅니다. 본 페이지는 서명 검증 알고리즘과 머천트 측 검증 코드, 그리고 replay 보호 권장 정책을 다룹니다.
사전 준비
검증에는 머천트별 callback secret 이 필요합니다. 발급·보관 절차는 Webhooks Overview — 시크릿 을 참고하세요.
알고리즘
| 항목 | 값 |
|---|---|
| 해시 | HMAC-SHA256 |
| 키 | 머천트별 callback secret (base64 문자열 — 디코딩한 바이트를 키로 사용) |
| 서명 대상 | ${webhook-id}.${webhook-timestamp}.${raw_body} (. 는 리터럴) |
| 인코딩 | base64 |
| 헤더 형식 | v1,<base64> (단일 페어. 향후 공백 구분 다중 페어 호환 권장) |
검증 절차
webhook-id,webhook-timestamp헤더 값을 추출.- 요청 본문을 raw bytes 그대로 보존 (JSON 파싱 후 재직렬화 금지).
- callback secret 을 base64 디코딩해 HMAC 키를 얻는다. 디코딩에 실패하면 설정 오류로 간주하고 중단.
${id}.${timestamp}.${body}문자열에 디코딩한 키로 HMAC-SHA256 적용 → base64 인코딩 →expected.webhook-signature헤더 값을 공백으로 분리한 각 페어(v1,<base64>)에 대해expected와 constant-time 비교. 하나라도 매치하면 통과.- 매치 실패 시 본문을 처리하지 않고 401 반환.
raw body 보존이 필수
JSON 파싱 후 재직렬화하면 공백·키 순서가 달라져 서명이 깨집니다.
Express 는 express.raw({ type: 'application/json' }), Go 는
io.ReadAll(r.Body) 같은 방식으로 원본 바이트를 보존하세요.
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.CROSSPAY_WEBHOOK_SECRET;
// raw body 보존
app.post(
'/webhooks/crosspay',
express.raw({ type: 'application/json' }),
(req, res) => {
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
const sigHeader = req.get('webhook-signature') || '';
const body = req.body; // Buffer
const secretBytes = Buffer.from(SECRET, 'base64'); // secret 은 base64 인코딩됨
const signed = `${id}.${ts}.${body.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', secretBytes)
.update(signed)
.digest();
const ok = sigHeader.split(' ').some((pair) => {
const [v, sig] = pair.split(',');
if (v !== 'v1' || !sig) return false;
const got = Buffer.from(sig, 'base64');
return (
got.length === expected.length &&
crypto.timingSafeEqual(got, expected)
);
});
if (!ok) return res.status(401).send('invalid signature');
// ... 도메인 처리 ...
res.status(200).send('ok');
}
);
Go (net/http)
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"net/http"
"os"
"strings"
)
var secret = os.Getenv("CROSSPAY_WEBHOOK_SECRET") // base64 인코딩됨
func Handle(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("webhook-id")
ts := r.Header.Get("webhook-timestamp")
sigHeader := r.Header.Get("webhook-signature")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
http.Error(w, "server misconfiguration", http.StatusInternalServerError)
return
}
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(id + "." + ts + "." + string(body)))
expected := mac.Sum(nil)
ok := false
for _, pair := range strings.Fields(sigHeader) {
v, sig, found := strings.Cut(pair, ",")
if !found || v != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
continue
}
if subtle.ConstantTimeCompare(got, expected) == 1 {
ok = true
break
}
}
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// ... 도메인 처리 ...
w.WriteHeader(http.StatusOK)
}
curl (디버그)
수신한 페이로드와 헤더로 서명을 재계산해 헤더 값과 비교할 때:
ID="0190a1b2-c3d4-7e5f-89ab-cdef01234567"
TS="1745236800"
SECRET="<base64-encoded callback secret>"
BODY="$(cat received_body.json)"
# secret 은 base64 인코딩되어 있으므로 먼저 디코딩한 뒤,
# 그 raw bytes(openssl 용으로 hex 인코딩)를 HMAC 키로 사용한다.
printf '%s.%s.%s' "$ID" "$TS" "$BODY" | \
openssl dgst -sha256 -mac HMAC \
-macopt hexkey:"$(printf '%s' "$SECRET" | base64 -d | xxd -p -c 256)" \
-binary | base64
# 출력값을 webhook-signature 헤더의 'v1,' 뒤 값과 일치하는지 확인
Replay 보호
webhook-timestamp 는 발송 시점의 Unix epoch seconds 입니다. 머천트는
이 값을 현재 시각과 비교해 너무 오래된 요청을 거부할 수 있습니 다.
| 항목 | 권장값 |
|---|---|
| 허용 오차 | ±300초 (5분) |
| 비교 | abs(now - webhook_timestamp) ≤ 300 |
| 실패 시 | 401 또는 400 응답 후 본문 무시 |
5분은 시계 오차·네트워크 지연·짧은 발송 큐잉을 흡수할 수 있는 실용적 값입니다. 너무 짧게 설정하면 정상 요청이 거부될 수 있고, 너무 길게 설정하면 캡처-재전송 공격에 노출 면적이 커집니다.
시크릿 로테이션
CROSS Pay 는 시크릿별 상태(활성·비활성·폐기)를 관리하여 다운타임 없이 시크릿을 교체할 수 있도록 합니다.
현재 동작
- 각 webhook 은 단일 활성 시크릿으로 서명됩니다.
- 따라서
webhook-signature헤더에는 단일 페어 (v1,<base64>) 만 들어갑니다.
머천트 측 검증 코드
- 위 코드 예시는 헤더의 모든 페어를 순회합니다. 향후 롤오버를 위해 다중 시크릿 동시 서명으로 확장되더라도 머천트 코드는 변경 없이 동작합니다.
시크릿 변경 시 권장 절차 (현재 단일 시크릿 운영 기준)
- CROSS Pay 에 신 시크릿 발급 요청 → 신 시크릿이 활성화됨.
- 가능하다면 신·구 시크릿 모두 머천트 검증 코드에서 받아들이도록 잠시 유지 (위 코드 예시의 페어 순회 로직은 시크릿 배열로 확장 가능).
- CROSS Pay 에 구 시크릿 폐기 요청.
- 머천트 측 구 시크릿 제거.
다음 단계
- Delivery & Retry — 재시도 정책, 멱등성, dedup
- Production Checklist — 프로덕션 전 점검 사항