웹훅 (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-Signature 와 X-Slack-Request-Timestamp 두 헤더. 서명 대상은 v0:<timestamp>:<body>. 서명은 v0=<hex> prefix 가 붙는다.
공통 패턴: 타임스탬프를 서명 대상에 포함 한다는 점 (Stripe·Slack). 타임스탬프가 없으면 같은 요청을 무한 재전송할 수 있기 때문이다 (replay attack).
검증 — 단계별 흐름
- raw body 보존 — 프레임워크가 본문을 자동 파싱하면 공백· 키 순서가 달라져 서명이 깨진다. Express 의 경우
express.raw()또는req.rawBody캡처. - 서명 헤더 파싱 — Stripe 의 경우
,로 split 해t와v1추출. - 타임스탬프 검증 — 현재 시각과 5 분 이상 차이가 나면 거부 (replay 방어 + 시계 오차 허용).
- 예상 서명 계산 — 서비스가 정한 포맷대로 시크릿과 본문을 합쳐 HMAC-SHA256 계산.
- 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.