서명 검증
모든 웹훅 전달은 함께 사용되는 두 개의 헤더를 포함합니다.
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000sha256= 뒤의 hex 문자열은 HMAC-SHA256(secret, timestamp + "." + raw_body)
입니다. 점은 리터럴 바이트이며, 타임스탬프는 ASCII로 된 유닉스 초입니다.
검증이 필요한 이유
웹훅 URL은 유출됩니다. 프록시 로그, 스크린샷, 브라우저 히스토리, 파트너
지원 티켓에 나타납니다. 서명 검사가 없으면 URL을 알게 된 누구든 가짜
payment.settled 이벤트를 POST하여 가맹점이 미결제 주문을 이행하도록
속일 수 있습니다. 검증은 요청이 InfraIO에서 왔음을 암호학적으로 증명합니다.
서명된 페이로드 내부에 타임스탬프를 포함하면 리플레이 보호도 제공됩니다: 전달을 캡처한 공격자는 서명이 감지 가능하게 오래되지 않고는 나중에 다시 보낼 수 없습니다.
알고리즘
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)그런 다음 타임스탬프가 최근인지 확인합니다(일반적인 허용 오차: ±5분).
항상 원본 요청 본문 바이트를 전달하세요. 프레임워크는 종종 핸들러
실행 전에 JSON을 파싱합니다. 재문자열화된 버전은 저희가 보낸 것과 다를
수 있고(키 순서, 공백, 숫자 포맷), HMAC이 일치하지 않습니다. Next.js
App Router에서는 JSON.parse 이전에 await req.text()를 사용하세요.
Express에서는 웹훅 경로에만 express.raw({ type: 'application/json' })를
마운트하세요.
구현
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // 원본 텍스트 — 파싱된 JSON이 아님
signature: string; // X-Signature 헤더의 값
timestamp: string; // X-Timestamp 헤더의 값 (유닉스 초)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // 너무 오래되었거나 미래로 너무 멀리 있음
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}리플레이 보호
서명된 페이로드 내부의 타임스탬프는 첫 번째 방어선입니다 — 전달을 캡처한 공격자는 허용 윈도우가 만료된 후 다시 보낼 수 없습니다.
벨트 앤 브레이스(payment.settled와 같은 고가치 이벤트에 권장):
- 고유 제약이 있는 테이블에서
X-Delivery로 중복 제거. 허용 윈도우 내의 리플레이는 no-op이 됩니다 — 핸들러는 작업을 두 번 수행하지 않고 200을 반환합니다. 이것은 합법적인 재시도에 필요한 것과 동일한 멱등성입니다. (X-Delivery는 전달의 모든 재시도에서 안정적입니다; 페이로드에는event_id필드가 없습니다.) - 클록 드리프트가 허용하는 가장 작은 허용 오차 사용. ±5분은 권장 기본값이며 대부분의 NTP 동기화된 플릿이 유지할 수 있는 값과 일치합니다. 더 엄격하게 설정해도 괜찮지만, ±30초 이하에서는 느린 업스트림 NTP가 있는 네트워크에서 합법적인 전달을 거부하기 시작합니다.
시크릿 로테이션
- 대시보드 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- 새 시크릿이 생성되어 정확히 한 번 표시됩니다. 다이얼로그를 닫기 전에 복사하세요.
- env 변수를 업데이트하고 24시간 이내에 검증자를 재배포하세요.
Grace 윈도우 (이중 서명)
로테이션 후 24시간 동안 모든 전달은 두 개의 서명을 포함합니다.
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000이전 시크릿을 실행하는 검증자는 X-Signature-Prev와 일치하고,
새 시크릿을 실행하는 검증자는 X-Signature와 일치합니다. 어느 쪽
헤더가 통과되어도 충분합니다 — 핸들러는 배포를 보류하지 않고 마이그레이션
중에 전달을 수락할 수 있습니다.
Grace 윈도우가 닫힌 후에는 X-Signature만 전송됩니다. 이전 시크릿은
더 이상 수락되지 않으며, 여전히 이것으로 구성된 검증자는 전달 거부를
시작합니다 — 따라서 24시간 예산 내에 롤아웃을 완료하세요.
권장 수신자 패턴
// 로테이션 grace 윈도우 동안 어느 쪽 서명도 수락.
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));엔드포인트의 grace 윈도우가 만료되고 env에서 PREV_SECRET을 제거한 즉시
X-Signature-Prev 분기를 제거할 수 있습니다.
긴급 폐기
시크릿이 공개적으로 유출되어 이전 시크릿을 즉시 무효화해야 하는 경우 — 즉, 24시간 오버랩이 알려진 잘못된 키를 유지하기를 원하지 않는 경우 — 두 번 로테이션하세요. 첫 로테이션은 유출된 시크릿을 prev 슬롯으로 이동시킵니다. 두 번째 로테이션은 그것을 prev 슬롯에서 밀어내(여전히 새 키로 대체) 유출된 값이 더 이상 수락되지 않게 합니다.
와이어링 테스트
대시보드에서 Developers → Webhooks를 열고 검증하려는 엔드포인트에서 Send Test를 클릭하세요. 저희는 합성 envelope에 서명하여 URL로 동기적으로 POST한 다음, HTTP 상태, 지연, 응답의 512바이트 스니펫을 표시합니다. 페이로드 형식:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}테스트 핑은 프로덕션 전달과 동일한 서명 방식을 사용하므로, 이 버튼의 녹색 체크는 검증자가 실제 이벤트도 수락함을 확인합니다. 테스트 핑은 RMQ 재시도 파이프라인을 우회합니다 — 재시도를 행사하려면 관련 API 흐름을 통해 실제 이벤트를 트리거하세요.
일반적인 실패
| 증상 | 가능한 원인 |
|---|---|
| 개발 환경에서 항상 false 반환 | HMAC 이전에 본문이 JSON 파싱됨. 먼저 원본 바이트를 읽으세요. |
| 어제는 작동했지만 오늘은 실패 | 시크릿을 로테이션했지만 이 서버의 env 변수에 여전히 이전 것이 있음. 새 시크릿으로 재배포하세요. |
| 이전 이벤트는 실패하고 새 이벤트는 작동 | 로테이션 전에 전달이 큐잉됨. 서명이 이전 시크릿을 사용하며 검증자는 더 이상 수락하지 않습니다. 재시도가 떨어지기를 기다리거나 대시보드를 통해 재생하세요. |
| 타임스탬프 비교에서 1 차이 | 유닉스 초를 유닉스 초와 비교하는지 확인. JS의 Date.now()는 밀리초입니다 — 1000으로 나누세요. |
| 로컬에서는 작동, 프로덕션에서는 실패 | 프록시(Cloudflare, nginx)가 압축 해제, 재인코딩 또는 후행 개행 제거 중입니다. 핸들러가 보는 바이트를 검사하세요. |
| 테스트 핑이 401 / 서명 불일치 | 검증자가 body만 서명 중(2026년 이전 방식). timestamp + "." + body를 서명하도록 업데이트하세요. |
| 헤더가 완전히 누락 | 엔드포인트가 다른 환경에 등록됨. 테스트 모드 엔드포인트는 environment=test 이벤트만 받습니다. |