외부 결제 PG사나 알림 서비스 등을 연동하면서 웹훅(Webhook) 수신 엔드포인트를 만들어본 경험이 있을 것이다.
다만 대부분의 개발자들은 URL만 노출되어 있으면 누구나 위조된 HTTP 요청을 보낼 수 있다는 위험성을 간과한 채, 수신된 데이터를 그대로 DB에 반영하곤 한다.
이번에는 웹훅 위변조 공격이 왜 위험한지, 이를 방지하기 위한 HMAC SHA256 서명 검증이 정확히 무엇인지, 그리고 어떻게 실무에 적용하는지 완벽하게 정리해서 소개하겠다.
웹훅은 서버 대 서버 통신 방식으로, 결제 완료나 상태 변경 같은 이벤트가 발생했을 때 지정된 URL로 HTTP POST 요청을 날려주는 메커니즘이다.
문제는 이 수신 URL이 공용 인터넷에 열려있는 엔드포인트라는 점이다. 해커나 악의적인 사용자가 해당 URL을 알아내어 임의로 {"status": "PAID", "amount": 100000} 같은 페이로드를 POST로 보낸다면, 검증 로직이 없을 경우 결제도 하지 않은 주문이 처리될 수 있다.
이를 방지하기 위해 사용되는 표준적인 기술이 바로 **HMAC(Hash-based Message Authentication Code)**이다.
송신자와 수신자가 오직 둘만 알고 있는 '비밀키(Secret Key)'를 공유하고, 송신자는 전달할 페이로드 데이터와 비밀키를 조합하여 HMAC SHA256 해시값을 생성한 뒤 헤더에 담아 보낸다. 수신자는 동일한 페이로드와 비밀키로 해시를 직접 재계산하여 전달받은 서명과 일치하는지 확인하는 방식이다.
웹훅 서명 검증 방식이 기존 단순 인증 방식과 어떻게 다른지 한눈에 비교해 보자.
| 구분 | IP 허용 목록(IP Whitelisting) | 단순 API 토큰 검증 | HMAC SHA256 서명 검증 |
|---|---|---|---|
| 방식 | 발송 서버의 IP 대역만 수신 허용 | 요청 헤더의 고정 토큰값 확인 | 비밀키 + 페이로드 본문 기반 동적 해시 검증 |
| 장점 | 설정이 간편함 | 구현이 단순함 | 데이터 위변조 차단 및 완전한 수신자/송신자 인증 |
| 단점 | 발송측 IP 변경 시 장애 발생, 프록시 우회 가능성 | 토큰 탈취 시 모든 요청 위조 가능 | 서버단에서 서명 계산 비용 소폭 발생 |
| 보안성 | 보통 | 낮음 | 매우 높음 (추천) |
실제 외부 API 서비스에서 보내오는 웹훅 요청을 받아서 검증하는 PHP 실전 예제 코드를 작성해 보자.
수신 헤더에는 X-Signature라는 이름으로 HMAC SHA256 해시 값이 전달된다고 가정한다.
✗ 잘못된 연동 코드 (검증 없이 페이로드 직렬화 및 처리):
<?php
// 위험: 요청 데이터가 진짜 서비스 제공자가 보낸 것인지 검증하지 않음
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['event']) && $data['event'] === 'payment.success') {
// DB 업데이트 실행 (위조된 요청에 취약)
updateOrderStatus($data['order_id'], 'PAID');
}
?>✓ 올바른 연동 코드 (HMAC SHA256 검증 및 타임 공격 방지 적용):
<?php
// 1. 서버에 안전하게 보관 중인 웹훅 비밀키
$secretKey = 'my_super_secret_webhook_key_12345';
// 2. HTTP 헤더에서 서명(Signature) 추출
$headers = getallheaders();
$receivedSignature = $headers['X-Signature'] ?? $headers['x-signature'] ?? '';
// 3. 원본 Raw Body 수신 (json_decode 이전에 읽어야 함)
$rawPayload = file_get_contents('php://input');
if (empty($rawPayload) || empty($receivedSignature)) {
http_response_code(400);
echo json_encode(['error' => 'Bad Request: Missing payload or signature']);
exit;
}
// 4. 수신한 Raw Body와 비밀키로 HMAC SHA256 서명 재계산
$expectedSignature = hash_hmac('sha256', $rawPayload, $secretKey);
// 5. hash_equals() 함수로 시간 공격(Timing Attack) 방지하며 비교
if (!hash_equals($expectedSignature, $receivedSignature)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized: Invalid signature']);
exit;
}
// 6. 서명 검증 성공 후 비즈니스 로직 수행
$data = json_decode($rawPayload, true);
if (($data['event'] ?? '') === 'payment.success') {
// 안전하게 처리 진행
updateOrderStatus($data['order_id'], 'PAID');
}
http_response_code(200);
echo json_encode(['success' => true]);
?>실행 결과 / 출력값 (서명이 유효하지 않거나 위조된 경우):
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error": "Unauthorized: Invalid signature"}
웹훅 서명 검증을 구현할 때 백엔드 개발자들이 흔히 범하는 몇 가지 치명적인 실수가 있다.
송신 측이 생성한 서명은 전송된 '원본 문자열(Raw String)' 기준이다. 수신측에서 json_decode()를 진행한 뒤 다시 json_encode()를 거치면 띄어쓰기, 줄바꿈, 유니코드 이스케이프 등이 변경되어 전혀 다른 해시값이 생성된다.
따라서 반드시 file_get_contents('php://input')으로 읽은 원본 바이트 스트림을 그대로 hash_hmac()에 전달해야 한다.
문자열 검증 시 $expectedSignature === $receivedSignature 와 같이 일반 비교 연산자를 사용하면 '타이밍 공격(Timing Attack)'에 노출될 수 있다. 문자열 비교 연산자는 앞자리부터 일치 여부를 검사하다가 틀린 지점에서 바로 리턴되므로, 응답 시간에 미세한 차이가 생긴다.
반드시 hash_equals() 함수를 사용하여 항상 일정한 시간 동안 비교하도록 처리해야 안전하다.
웹훅 서명 검증은 외부 서비스와 데이터를 주고받는 시스템에서 결코 타협할 수 없는 필수 보안 요소다. 작은 원본 검증 습관이 모여서 서비스 전체의 신뢰성과 데이터 무결성을 만든다는 점을 잊지 말자. 이 글의 실전 예제 코드를 참고해 수신 엔드포인트에 HMAC SHA256 검증 로직을 구현하면, 위변조된 악성 요청으로부터 서비스 데이터를 완벽하게 보호할 수 있을 것이다.