Skip to Content
웹훅서명 검증

서명 검증

모든 웹훅 전달은 함께 사용되는 두 개의 헤더를 포함합니다.

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

sha256= 뒤의 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' })를 마운트하세요.

구현

lib/verify-infraio.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와 같은 고가치 이벤트에 권장):

  1. 고유 제약이 있는 테이블에서 X-Delivery로 중복 제거. 허용 윈도우 내의 리플레이는 no-op이 됩니다 — 핸들러는 작업을 두 번 수행하지 않고 200을 반환합니다. 이것은 합법적인 재시도에 필요한 것과 동일한 멱등성입니다. (X-Delivery는 전달의 모든 재시도에서 안정적입니다; 페이로드에는 event_id 필드가 없습니다.)
  2. 클록 드리프트가 허용하는 가장 작은 허용 오차 사용. ±5분은 권장 기본값이며 대부분의 NTP 동기화된 플릿이 유지할 수 있는 값과 일치합니다. 더 엄격하게 설정해도 괜찮지만, ±30초 이하에서는 느린 업스트림 NTP가 있는 네트워크에서 합법적인 전달을 거부하기 시작합니다.

시크릿 로테이션

  1. 대시보드 → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. 새 시크릿이 생성되어 정확히 한 번 표시됩니다. 다이얼로그를 닫기 전에 복사하세요.
  3. 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 이벤트만 받습니다.