본문으로 건너뛰기
yutils

HMAC 웹훅 검증 — Stripe·GitHub·Slack 의 서명 검증 패턴

웹훅에 왜 HMAC 서명이 필요한지, Stripe·GitHub·Slack 이 어떻게 페이로드를 서명하는지, constant-time 비교·재전송 방어·단계별 검증 구현까지.

약 8분 읽기

웹훅 (webhook) 은 외부 서비스가 우리 서버로 직접 HTTP 요청을 보내 이벤트를 알리는 방식이다. Stripe 의 결제 성공, GitHub 의 push, Slack 의 슬래시 명령 — 모두 웹훅이다. 문제는 그 요청이 정말 그 서비스에서 온 건지 확인하는 일이다. IP 화이트리스팅은 IP 가 바뀌면 깨지고, 토큰을 URL 에 박으면 로그·프록시· 브라우저 캐시 등 어디든 새 나간다. 표준 답은 HMAC 서명이다.

왜 HMAC 인가

HMAC (Hash-based Message Authentication Code) 은 공유된 시크릿과 메시지 본문을 SHA-256 같은 해시 함수로 묶어 짧은 서명을 만든다. 시크릿을 모르면 서명을 만들 수 없고, 본문이 한 글자라도 바뀌면 서명이 완전히 달라진다.

  • 위조 방지 — 공격자가 가짜 페이로드를 보내도 시크릿을 모르면 서명을 못 만든다.
  • 변조 감지 — 중간자가 본문을 한 바이트라도 바꾸면 서명이 깨진다.
  • 경량 — 공개 키 암호화보다 훨씬 빠르다. 매 요청마다 확인해도 비용이 거의 없다.

직접 계산해 보고 싶다면 HMAC 생성기 에 본문과 시크릿을 넣으면 SHA-256 서명이 나온다. 반대로 HMAC 검증기 는 받은 서명이 본인 시크릿 으로 만들 수 있는 값인지 확인한다.

대표 서비스의 서명 형식

Stripe

Stripe-Signature 헤더에 다음 형식으로 전송된다.

t=1614265330,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

서명 대상 문자열은 `t`.`payload` 형태. 즉 타임스탬프 + "." + 원본 JSON 본문. 시크릿은 endpoint 마다 다른 whsec_... 값.

GitHub

X-Hub-Signature-256 헤더에 sha256=<hex> 형식으로 박힌다. 서명 대상은 raw request body. 시크릿은 webhook 설정 시 지정한 값.

Slack

X-Slack-SignatureX-Slack-Request-Timestamp 두 헤더. 서명 대상은 v0:<timestamp>:<body>. 서명은 v0=<hex> prefix 가 붙는다.

공통 패턴: 타임스탬프를 서명 대상에 포함 한다는 점 (Stripe·Slack). 타임스탬프가 없으면 같은 요청을 무한 재전송할 수 있기 때문이다 (replay attack).

검증 — 단계별 흐름

  1. raw body 보존 — 프레임워크가 본문을 자동 파싱하면 공백· 키 순서가 달라져 서명이 깨진다. Express 의 경우 express.raw() 또는 req.rawBody 캡처.
  2. 서명 헤더 파싱 — Stripe 의 경우 , 로 split 해 tv1 추출.
  3. 타임스탬프 검증 — 현재 시각과 5 분 이상 차이가 나면 거부 (replay 방어 + 시계 오차 허용).
  4. 예상 서명 계산 — 서비스가 정한 포맷대로 시크릿과 본문을 합쳐 HMAC-SHA256 계산.
  5. constant-time 비교a === b 같은 일반 비교는 타이밍 공격에 취약. Node 의 crypto.timingSafeEqual, Python 의 hmac.compare_digest 사용.

Node.js 예제 — Stripe 스타일

import {createHmac, timingSafeEqual} from "crypto";

function verifyStripeSignature(body, sigHeader, secret) {
  // 1. 헤더 파싱
  const parts = Object.fromEntries(
    sigHeader.split(",").map((kv) => kv.split("="))
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  // 2. 타임스탬프 5 분 이내
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  // 3. 예상 서명 계산
  const signedPayload = `${timestamp}.${body}`;
  const expected = createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // 4. constant-time 비교
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

body 는 string 그대로 넘긴다 — JSON.parse 후 다시 stringify 하면 공백·키 순서가 바뀌어 서명이 깨진다.

왜 constant-time 비교인가

일반 문자열 비교는 첫 다른 문자에서 즉시 종료된다. 공격자가 한 글자씩 바꿔 가면서 응답 시간을 측정하면 어디까지 일치하는지 알 수 있다 (타이밍 공격). constant-time 함수는 길이만 같으면 항상 같은 시간 안에 모든 바이트를 비교한다.

  • Node: crypto.timingSafeEqual(a, b)
  • Python: hmac.compare_digest(a, b)
  • Go: subtle.ConstantTimeCompare(a, b)
  • Ruby: OpenSSL.fixed_length_secure_compare

흔히 빠지는 함정

1. JSON 파싱 후 서명 검증

프레임워크의 body-parser 가 본문을 객체로 자동 변환하면 raw bytes 가 사라진다. 서명 검증 후 파싱 — 순서가 중요.

2. 타임스탬프 누락

서명 만 검증하고 타임스탬프를 체크하지 않으면 공격자가 합법 요청을 1 년 뒤에 그대로 재전송할 수 있다. Stripe·Slack 모두 타임스탬프 포함을 명시하는 이유.

3. URL path 미포함

본문만 서명하면 같은 본문을 다른 endpoint 로 우회시킬 수 있다 (cross- endpoint replay). 일부 API 는 path/method 도 서명 대상에 포함시킨다.

4. 시크릿 노출

webhook 시크릿이 코드에 박혀 GitHub 에 푸시되면 즉시 노출. 환경 변수 또는 시크릿 매니저로 분리. 이미 노출됐다면 즉시 rotate.

5. 일반 === 비교

앞서 말한 타이밍 공격. 라이브러리가 충분히 빠르므로 항상 constant-time 함수 사용.

SHA-256 vs SHA-1 — 어떤 해시?

SHA-1 은 충돌이 입증된 2017 년 이후 보안 용도엔 권장하지 않는다 (Google SHAttered). GitHub 는 v1 X-Hub-Signature (SHA-1) 와 v2 X-Hub-Signature-256 (SHA-256) 둘 다 보내지만, 새 구현은 반드시 SHA-256 만 검증한다. 직접 계산해 비교하고 싶다면 SHA 해시 에서 두 알고리즘 결과를 나란히 볼 수 있다.

관련 도구로 시뮬레이션하기

  • HMAC 생성기 — 본문 + 시크릿 → HMAC-SHA256 서명. 공급자가 보낼 서명을 미리 계산해 볼 수 있다.
  • HMAC 검증기 — 받은 서명이 본인 시크릿 으로 만든 게 맞는지 constant-time 비교. 로컬에서 끝나므로 시크릿이 외부로 새지 않는다.
  • SHA 해시 — 단방향 해시 자체를 확인하고 싶을 때. HMAC 의 내부 동작 (해시 + 키 padding) 이해에 도움.

요약

  • 웹훅은 출처 확인이 핵심 — 표준은 HMAC-SHA256 서명.
  • 서명 대상에 타임스탬프 포함 + 5 분 이내 검증 = replay 방어.
  • raw body 를 그대로 서명 — 프레임워크의 자동 파싱 주의.
  • 비교는 반드시 constant-time 함수로.
  • 시크릿은 환경 변수/시크릿 매니저로. 노출되면 즉시 rotate.
가이드 목록으로