서명 검증
ZeroTalk는 웹훅 요청마다 시크릿으로 서명해서 보냅니다. 수신 서버에서 이 서명을 검증하면 요청이 ZeroTalk에서 왔고 본문이 바뀌지 않았음을 확인할 수 있습니다. 요청에는 아래 두 헤더가 들어 있습니다.
X-Webhook-Signature:sha256=<hex>형식의 HMAC-SHA256 서명입니다.X-Webhook-Timestamp: 서명에 사용한 시각(Unix 초)입니다.
주의
서명 값에는 sha256= 접두사가 포함됩니다. 검증할 때 접두사를 붙여 비교하세요.
알고리즘
- raw 요청 본문을 그대로 사용합니다. JSON을 다시 직렬화하면 직렬화 차이로 서명이 어긋나므로 직렬화하지 마세요.
X-Webhook-Timestamp값,., 본문을 차례로 이어 붙인 문자열을 시크릿으로 HMAC-SHA256 합니다. 시크릿은 64자 hex 문자열을 디코딩하지 않고 문자열 그대로 키로 씁니다.sha256=+ hex 결과가X-Webhook-Signature와 같은지 상수 시간(constant-time) 비교로 확인합니다. 헤더 값은 누구나 보낼 수 있으므로 길이가 다르거나 헤더가 없으면 예외 없이 거부하세요. Node.js의crypto.timingSafeEqual은 두 버퍼의 길이가 다르면 예외를 던지므로 길이를 먼저 확인해야 합니다.- (권장)
X-Webhook-Timestamp가 현재 시각 ±5분 이내인지 확인해 replay 공격을 막습니다.
예시
const crypto = require('crypto');
// rawBody: 파싱하기 전의 요청 본문 Buffer (예: express.raw()가 넘겨주는 req.body)
function verify(rawBody, signatureHeader, timestamp, secret) {
if (typeof signatureHeader !== 'string') return false;
const expected = Buffer.from('sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest('hex'));
const received = Buffer.from(signatureHeader);
// timingSafeEqual은 두 버퍼의 바이트 길이가 다르면 예외를 던지므로 먼저 거부합니다.
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, timestamp: str, secret: str) -> bool:
if not isinstance(signature_header, str):
return False
expected = 'sha256=' + hmac.new(
secret.encode(), timestamp.encode() + b'.' + raw_body, hashlib.sha256
).hexdigest()
# 비-ASCII 문자가 든 str은 compare_digest가 예외를 던지므로 bytes로 비교합니다.
return hmac.compare_digest(signature_header.encode(), expected.encode())
func verify(rawBody []byte, signatureHeader, timestamp, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signatureHeader), []byte(expected))
}
서명이 맞지 않을 때
검증이 실패하면 아래 원인을 순서대로 확인하세요.
- 본문을 가공했습니다. 프레임워크가 JSON을 파싱한 객체를 다시 문자열로 만들면 공백이나 키 순서가 달라집니다. 파싱하기 전의 raw 본문으로 서명을 계산하세요.
sha256=접두사를 빠뜨렸습니다. 계산한 hex 앞에sha256=을 붙여서 헤더 값과 비교하세요.- 본문의
timestamp를 썼습니다. 서명에는 본문의timestamp가 아니라X-Webhook-Timestamp헤더 값을 씁니다. 두 값은 서로 다릅니다. - 시크릿이 다른 웹훅의 것이거나 이전 시크릿입니다. 시크릿은 웹훅마다 따로 발급되며, 재생성하면 이전 시크릿으로는 검증할 수 없습니다.
시크릿 회전
시크릿을 재생성하면 기존 시크릿은 즉시 무효가 되고 유예 기간이 없습니다. 재생성 이후에 보내는 요청은 재전달하는 요청까지 모두 새 시크릿으로 서명합니다. 재생성하는 시점에 이미 전송 중이던 요청만 옛 시크릿으로 서명되어 있을 수 있습니다.
대시보드의 키 재생성 또는 PAT API의 POST /outbound-webhooks/{id}/regenerate-secret으로 재생성하세요. 두 경우 모두 새 시크릿은 응답에서 1회만 평문으로 반환되므로, 받는 즉시 수신 서버에 반영하세요. 반영하기 전에 도착한 요청은 서명 검증에 실패합니다. 이때 비-2xx로 응답하면 전달 규칙에 따라 재전달되지만, 이벤트마다 첫 시도의 실패는 자동 비활성화를 판단하는 연속 실패 횟수에 들어갑니다.