Webhook HMAC 서명 검증하기
대시보드에서 웹훅 시크릿을 발급한 고객에게는 Asleep이 발송하는 웹훅 요청에 HMAC-SHA256 서명이 포함됩니다.
본 문서는 수신한 웹훅이 Asleep에서 발송된 것이 맞는지, 그리고 전송 중 변조되지 않았는지 검증하는 방법을 안내합니다.
서명은 시크릿 발급 이후 헤더에 포함됩니다
- 시크릿 미발급 상태: 웹훅 요청에
X-Asleep-Signature,X-Asleep-Timestamp헤더가 포함되지 않습니다.- 시크릿 발급 후: 발급 시점부터 발송되는 모든 웹훅 요청에 서명 헤더가 자동으로 포함됩니다.
본 검증을 적용하려면 먼저 Webhook Secret 관리하기 문서를 참고해 시크릿을 발급해주세요.
수신 헤더
시크릿이 발급된 경우, 웹훅 요청에는 검증에 필요한 두 개의 헤더가 포함됩니다.
헤더 | 설명 |
|---|---|
| 웹훅 발송 시각 (Unix timestamp, 초 단위) |
| HMAC-SHA256 서명 ( |
헤더가 없을 때의 처리시크릿을 발급 했음에도 헤더가 누락되어 도착했다면 발송 과정의 문제일 수 있습니다. 검증 실패로 간주하고 거부하거나, 로깅 후 모니터링하기를 권장합니다.
검증 절차
-
요청에서
X-Asleep-Timestamp,X-Asleep-Signature헤더가 존재하는지 확인합니다. -
다음과 같이 서명 대상 문자열을 구성합니다.
signed_payload = timestamp + "." + raw_body -
보관 중인 시크릿으로
signed_payload의 HMAC-SHA256 값을 계산합니다 (hex 인코딩). -
계산한 값과 헤더의 서명 중 하나가 일치하면 검증 성공입니다. 비교 시에는 timing-safe 비교 함수를 사용해주세요.
반드시 raw body로 검증해주세요요청 body는 수신한 그대로(원본 바이트)를 사용해야 합니다. JSON으로 파싱한 뒤 재직렬화하면 키 순서·공백 차이로 서명이 깨집니다. 검증 전에 body를 미리 읽어두고, 검증 통과 후 파싱하는 순서를 권장합니다.
코드 예제
import hmac
import hashlib
import time
def verify_webhook(
secret: str,
timestamp_header: str | None,
signature_header: str | None,
raw_body: bytes,
) -> bool:
# 1. 시크릿을 발급했다면 헤더가 반드시 포함되어야 함
if not timestamp_header or not signature_header:
return False
# 2. timestamp 신선도 확인 (5분 이내)
if abs(time.time() - int(timestamp_header)) > 300:
return False
# 3. signed_payload 구성
signed_payload = timestamp_header.encode() + b"." + raw_body
# 4. HMAC-SHA256 계산
expected = hmac.new(
key=secret.encode(),
msg=signed_payload,
digestmod=hashlib.sha256,
).hexdigest()
# 5. 헤더의 서명들과 비교
for sig in signature_header.split(","):
version, value = sig.split("=", 1)
if hmac.compare_digest(expected, value):
return True
return Falseimport java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
public final class WebhookVerifier {
private static final long TOLERANCE_SECONDS = 300; // 5분
private WebhookVerifier() {}
public static boolean verifyWebhook(
String secret,
String timestampHeader,
String signatureHeader,
byte[] rawBody) {
// 1. 시크릿을 발급했다면 헤더가 반드시 포함되어야 함
if (timestampHeader == null || timestampHeader.isEmpty()
|| signatureHeader == null || signatureHeader.isEmpty()) {
return false;
}
// 2. timestamp 신선도 확인 (5분 이내)
final long timestamp;
try {
timestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return false;
}
// 3. signed_payload 구성: timestamp + "." + raw_body
byte[] prefix = (timestampHeader + ".").getBytes(StandardCharsets.UTF_8);
byte[] signedPayload = new byte[prefix.length + rawBody.length];
System.arraycopy(prefix, 0, signedPayload, 0, prefix.length);
System.arraycopy(rawBody, 0, signedPayload, prefix.length, rawBody.length);
// 4. HMAC-SHA256 계산
String expected = hmacSha256Hex(secret, signedPayload);
// 5. 헤더의 서명들과 비교
for (String sig : signatureHeader.split(",")) {
int idx = sig.indexOf('=');
if (idx < 0) {
continue; // "version=value" 형식이 아니면 건너뜀
}
String value = sig.substring(idx + 1);
// 상수 시간 비교 (hmac.compare_digest 대응)
if (MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
value.getBytes(StandardCharsets.UTF_8))) {
return true;
}
}
return false;
}
private static String hmacSha256Hex(String secret, byte[] message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(message));
} catch (Exception e) {
throw new IllegalStateException("HMAC 계산 실패", e);
}
}
}const crypto = require("crypto");
function verifyWebhook(secret, timestampHeader, signatureHeader, rawBody) {
if (!timestampHeader || !signatureHeader) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
const signedPayload = Buffer.concat([
Buffer.from(timestampHeader),
Buffer.from("."),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
]);
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return signatureHeader.split(",").some((sig) => {
const [, value] = sig.split("=", 2);
if (!value) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
);
});
}
Replay 공격을 방지해주세요수신한
X-Asleep-Timestamp가 현재 시각 대비 5분 이내인지 확인하기를 권장합니다. timestamp가 너무 오래된 요청은 무시해주세요. 동일한 (timestamp, signature) 조합을 멱등성 키로 활용하면 재처리도 함께 방지할 수 있습니다.
점검용 샘플
아래 값으로 직접 계산한 결과가 expected_signature와 일치하면 검증 로직이 정상 동작하는 것입니다.
| 항목 | 값 |
|---|---|
secret | whsec_test_1234567890abcdef |
X-Asleep-Timestamp | 1730000000 |
raw_body | {"event":"session_complete","session_id":"20260101_abcde"} |
expected_signature | v1=260861a40e2be5a307c85e2d00581c8952eb270867e3ff04e57163c2c693118a |
샘플 값은 실제 발급된 시크릿이 아닙니다. 실제 사용 시에는 대시보드에서 발급한 시크릿으로 교체해주세요.
Updated about 16 hours ago
