Webhook HMAC 서명 검증하기

대시보드에서 웹훅 시크릿을 발급한 고객에게는 Asleep이 발송하는 웹훅 요청에 HMAC-SHA256 서명이 포함됩니다.
본 문서는 수신한 웹훅이 Asleep에서 발송된 것이 맞는지, 그리고 전송 중 변조되지 않았는지 검증하는 방법을 안내합니다.

📘

서명은 시크릿 발급 이후 헤더에 포함됩니다

  • 시크릿 미발급 상태: 웹훅 요청에 X-Asleep-SignatureX-Asleep-Timestamp 헤더가 포함되지 않습니다.
  • 시크릿 발급 후: 발급 시점부터 발송되는 모든 웹훅 요청에 서명 헤더가 자동으로 포함됩니다.

본 검증을 적용하려면 먼저 Webhook Secret 관리하기 문서를 참고해 시크릿을 발급해주세요.

수신 헤더

시크릿이 발급된 경우, 웹훅 요청에는 검증에 필요한 두 개의 헤더가 포함됩니다.

헤더

설명

X-Asleep-Timestamp

웹훅 발송 시각 (Unix timestamp, 초 단위)

X-Asleep-Signature

HMAC-SHA256 서명 (버전=서명값 형태)
로테이션 중에는 콤마로 구분된 여러 서명이 포함됩니다. (v1=abc123...,v1=def456...)

🚧

헤더가 없을 때의 처리

시크릿을 발급 했음에도 헤더가 누락되어 도착했다면 발송 과정의 문제일 수 있습니다. 검증 실패로 간주하고 거부하거나, 로깅 후 모니터링하기를 권장합니다.

검증 절차

  1. 요청에서 X-Asleep-TimestampX-Asleep-Signature 헤더가 존재하는지 확인합니다.

  2. 다음과 같이 서명 대상 문자열을 구성합니다.

    signed_payload = timestamp + "." + raw_body

  3. 보관 중인 시크릿으로 signed_payload의 HMAC-SHA256 값을 계산합니다 (hex 인코딩).

  4. 계산한 값과 헤더의 서명 중 하나가 일치하면 검증 성공입니다. 비교 시에는 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 False
import 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와 일치하면 검증 로직이 정상 동작하는 것입니다.

항목
secretwhsec_test_1234567890abcdef
X-Asleep-Timestamp1730000000
raw_body{"event":"session_complete","session_id":"20260101_abcde"}
expected_signaturev1=260861a40e2be5a307c85e2d00581c8952eb270867e3ff04e57163c2c693118a
📘

샘플 값은 실제 발급된 시크릿이 아닙니다. 실제 사용 시에는 대시보드에서 발급한 시크릿으로 교체해주세요.