GitHub·Stripe·Slack, 웹훅 서명이 플랫폼마다 다르게 계산되는 이유
웹훅 수신 서버를 만들 때 가장 흔한 삽질 중 하나가 "시크릿 키는 맞는데 서명 검증이 계속 실패한다"입니다. 원인을 뜯어보면 대개 다른 플랫폼의 검증 코드를 복사해와서 그대로 쓴 경우입니다. GitHub, Stripe, Slack 모두 "HMAC-SHA256 서명"이라는 같은 이름의 메커니즘을 쓰지만, 실제로 해시하는 대상 문자열이 서로 다르기 때문에 코드를 그대로 옮기면 시크릿이 100% 정확해도 검증은 항상 실패합니다. 이 가이드는 세 플랫폼이 왜, 어떻게 다르게 서명을 구성하는지 원리부터 짚습니다.
1. 웹훅 서명이 필요한 이유: 발신자 확인 + 변조 탐지
웹훅은 외부 서버가 내 엔드포인트로 HTTP POST를 던지는 구조라, URL만 알면 누구나 가짜 요청을 보낼 수 있습니다. 이를 막기 위해 플랫폼들은 요청 본문(과 부가 정보)을 발신자와 수신자만 아는 시크릿 키로 HMAC-SHA256 해시해서 헤더에 실어 보냅니다. 수신 서버는 같은 시크릿으로 같은 문자열을 동일하게 해시해서, 받은 서명값과 일치하는지 비교합니다. 일치하면 "이 요청은 진짜 그 플랫폼이 보냈고 중간에 변조되지 않았다"고 신뢰할 수 있습니다. 여기까지는 세 플랫폼 모두 원리가 같습니다 — 다른 것은 "정확히 무엇을 해시하느냐"입니다.
2. GitHub: 바디를 그대로 해시
GitHub는 가장 단순한 구조를 씁니다. 요청 바디(JSON 페이로드) 원문을 그대로 HMAC-SHA256으로 해시해서 sha256= 접두사를 붙인 값을 X-Hub-Signature-256 헤더에 담아 보냅니다. 타임스탬프 같은 부가 요소는 서명 대상에 포함되지 않습니다.
3. Stripe: 타임스탬프를 붙여 리플레이 공격을 막는다
Stripe는 여기에 한 겹을 더합니다. {현재 유닉스 타임스탬프}.{바디} 형태로 타임스탬프와 바디를 마침표로 이어붙인 문자열을 해시하고, t=타임스탬프,v1=HEX 형식으로 Stripe-Signature 헤더에 담습니다. 타임스탬프를 서명 대상에 포함시키는 이유는 리플레이 공격(replay attack) 방지입니다. 만약 바디만 해시한다면, 공격자가 과거에 가로챈 진짜 요청(서명 포함)을 그대로 다시 보내도 서명이 여전히 유효해서 통과됩니다. 타임스탬프가 서명에 포함되면 수신 서버가 "이 타임스탬프가 너무 오래됐다"고 판단해 오래된 요청을 거부할 수 있습니다.
4. Slack: 버전 접두사까지 포함한 3단 구조
Slack은 한 단계 더 나아가 v0:{타임스탬프}:{바디} 형식으로 버전 접두사·타임스탬프·바디 세 요소를 콜론으로 이어붙여 해시합니다. 결과는 v0=HEX 형식으로 X-Slack-Signature 헤더에, 사용된 타임스탬프는 별도로 X-Slack-Request-Timestamp 헤더에 담겨 함께 전달됩니다. 버전 접두사(v0)를 넣는 이유는 향후 서명 방식이 바뀌더라도(v1, v2 등) 하위 호환을 유지하면서 새 버전을 구분할 수 있게 하기 위함입니다.
5. 세 방식을 나란히 비교하면
| 플랫폼 | 해시 대상 문자열 | 결과 형식 | 헤더 |
|---|---|---|---|
| GitHub | 바디 원문 | sha256=HEX | X-Hub-Signature-256 |
| Stripe | {타임스탬프}.{바디} | t=타임스탬프,v1=HEX | Stripe-Signature |
| Slack | v0:{타임스탬프}:{바디} | v0=HEX | X-Slack-Signature (+타임스탬프 별도 헤더) |
timestamp + '.' + body를 해시)를 GitHub 웹훅에 그대로 쓰면, GitHub는 애초에 타임스탬프를 서명에 포함시키지 않으므로 해시 입력 자체가 다르고, 결과 해시값도 절대 일치하지 않습니다. 이건 시크릿 관리 실수가 아니라 "어떤 문자열을 해시했는가"를 혼동한 구조적 실수입니다.
6. 도구로 세 형식을 직접 비교해보기
Webhook 생성기는 이 차이를 코드 레벨에서 그대로 반영합니다. 템플릿을 GitHub로 선택하면 바디 전체를 그대로 HMAC-SHA256 해시해 sha256=로 출력하고, Stripe를 선택하면 현재 유닉스 타임스탬프를 생성해 {타임스탬프}.{바디} 문자열을 해시한 뒤 t=타임스탬프,v1=HEX로, Slack을 선택하면 v0:{타임스탬프}:{바디} 문자열을 해시해 v0=HEX와 타임스탬프 값을 함께 표시합니다. 세 서명 모두 브라우저의 Web Crypto API(crypto.subtle)로만 계산되고 네트워크로 전송되지 않으므로, 수신 서버 쪽 검증 로직이 어느 플랫폼의 형식을 정확히 재현하는지 안전하게 테스트해볼 수 있습니다.
자주 묻는 질문
Q. GitHub는 왜 타임스탬프를 서명에 포함하지 않나요?
GitHub의 웹훅 이벤트는 대개 델리버리 ID로 중복 처리를 막는 별도 메커니즘을 두고 있어, 서명 자체에 타임스탬프를 강제하지 않는 설계를 택한 것으로 보입니다. 다만 리플레이 방어가 필요하다면 수신 서버 쪽에서 델리버리 ID 중복 체크를 직접 구현해야 합니다.
Q. Stripe·Slack의 타임스탬프 허용 오차는 어느 정도인가요?
플랫폼마다 권장 값이 다르지만, 통상 수신 서버가 현재 시각과 요청 타임스탬프 차이가 일정 범위(예: 5분)를 넘으면 거부하도록 구현하는 것이 일반적인 관행입니다. 정확한 권장값은 각 플랫폼의 최신 공식 문서를 확인하는 것이 안전합니다.
Q. 커스텀 웹훅을 직접 설계한다면 어느 방식을 참고해야 하나요?
리플레이 공격 방어가 필요하다면 Stripe나 Slack처럼 타임스탬프를 서명 대상에 포함시키는 편이 안전합니다. 향후 서명 방식을 바꿀 가능성이 있다면 Slack처럼 버전 접두사를 넣어두면 나중에 하위 호환을 유지하기 쉽습니다.
Q. HMAC 비교 시 문자열을 그냥 ===로 비교해도 되나요?
보안상 권장되지 않습니다. 일반 문자열 비교는 타이밍 공격에 취약할 수 있어, Node.js의 crypto.timingSafeEqual처럼 상수 시간 비교 함수를 쓰는 것이 안전합니다. 대부분의 플랫폼 공식 SDK가 이 비교 로직을 내장하고 있습니다.