본문으로 건너뛰기

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> (단일 페어. 향후 공백 구분 다중 페어 호환 권장)

검증 절차

  1. webhook-id, webhook-timestamp 헤더 값을 추출.
  2. 요청 본문을 raw bytes 그대로 보존 (JSON 파싱 후 재직렬화 금지).
  3. callback secret 을 base64 디코딩해 HMAC 키를 얻는다. 디코딩에 실패하면 설정 오류로 간주하고 중단.
  4. ${id}.${timestamp}.${body} 문자열에 디코딩한 키로 HMAC-SHA256 적용 → base64 인코딩 → expected.
  5. webhook-signature 헤더 값을 공백으로 분리한 각 페어(v1,<base64>)에 대해 expectedconstant-time 비교. 하나라도 매치하면 통과.
  6. 매치 실패 시 본문을 처리하지 않고 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>) 만 들어갑니다.

머천트 측 검증 코드

  • 위 코드 예시는 헤더의 모든 페어를 순회합니다. 향후 롤오버를 위해 다중 시크릿 동시 서명으로 확장되더라도 머천트 코드는 변경 없이 동작합니다.

시크릿 변경 시 권장 절차 (현재 단일 시크릿 운영 기준)

  1. CROSS Pay 에 신 시크릿 발급 요청 → 신 시크릿이 활성화됨.
  2. 가능하다면 신·구 시크릿 모두 머천트 검증 코드에서 받아들이도록 잠시 유지 (위 코드 예시의 페어 순회 로직은 시크릿 배열로 확장 가능).
  3. CROSS Pay 에 구 시크릿 폐기 요청.
  4. 머천트 측 구 시크릿 제거.

다음 단계