Security
Webhook 메시지 수신용 Public Endpoint를 안정적으로 운영하기 위해 보안적으로 고려해야 하는 위협 요소들을 살펴보고, 위협을 최소화하기 위해 Nodit 팀이 제안하는 Best Practice를 적용해보세요.
보안 위협
Replay 공격
Replay 공격은 공격자가 동일한 요청을 탈취하여 여러 번 재전송함으로써 의도치 않은 중복 처리나 악의적인 행동을 유발하는 보안 위협입니다. 예를 들어, 동일한 Webhook 메시지가 공격자에 의해 중복 전달됨으로서 결제 요청이나 데이터 업데이트 요청이 반복되어 처리될 경우 시스템에 불필요한 부하가 발생하거나 데이터의 일관성이 깨질 수 있습니다. 이를 방지하기 위해서는 수신한 Webhook 메세지에 대해 비즈니스 로직 내에서 멱등하게(Idempotent) 처리하도록 구현하는 것이 좋습니다.
중간자(Man-In-The-Middle, MITM) 공격
중간자 공격은 공격자가 클라이언트와 서버 간의 통신을 가로채어 데이터를 도청하거나 메세지의 payload를 위변조할 수 있는 위협입니다. HTTPS/TLS와 같은 암호화된 통신 프로토콜을 사용하여 통신 채널 자체를 보호하고, 서명 검증 등을 추가로 수행하여 메세지의 무결성과 출처를 인증하는 방식을 통해 위협을 최소화 할 수 있습니다.
Best Practices
Nodit이 제안하는 다음 보안 가이드를 따라 구현하여 Replay 공격, MITM 공격과 같은 보안 위협을 최소화하고, 고객에게 보다 안전한 서비스 환경을 제공할 수 있습니다. 각 제안 항목은 필수 구현사항은 아니며, 보안 강화를 위해 선택적으로 적용할 수 있습니다.
서명을 활용하여 메시지 무결성 검증하기
Nodit 서버는 모든 Webhook 메시지 요청의 x-signature
헤더에 메세지에 대한 서명값을 포함하여 전달합니다. Endpoint 서버는 아래 가이드와 같이 Webhook 생성시점에 확인되는 서명 키(Signing Key)를 활용하여 각 메시지의 서명을 검증함으로서 메시지의 무결성과 출처를 확인할 수 있습니다.
서명이란?
서명이란, 통신 과정에서 위변조로부터 보호하고자 하는 데이터 원본에 대해 HMAC-SHA256과 같은 알고리즘으로 원본 데이터를 해싱한 후, 미리 공유한 비밀키를 사용하여 메시지 인증 코드(MAC)를 생성한 값을 의미합니다. 통신의 송신자와 수신자 간에 사전에 교환한 키가있다면, 다음과 같은 서명 확인 과정을 통해 데이터 무결성 검증 및 송신자 인증을 수행할 수 있습니다.
- 송신자는 데이터 원본과 서명을 함께 수신자에게 전달합니다.
- 수신자는 사전에 확보한 비밀키로 서명을 복호화하여 송신자가 생성한 Hash 결과인 Digest 평문을 얻습니다. 이때, 복호화가 정상적으로 수행되는지 확인함으로서 송신자가 사전에 키를 교환한 Entity임을 인증할 수 있습니다.
- 수신자는 수신한 데이터 원본에 대해 Hash 연산을 수행하여 Digest를 생성합니다.
- Hash 연산의 특성에 따라, 수신자는 (2)에서 얻은 송신자가 생성한 Digest 평문과 (3)에서 생성한 Digest 값이 같은지 비교하여 데이터 원본이 송신 시점으로부터 변경되지 않았음을 확인할 수 있습니다.
Signing Key 조회하기
Signing Key는 Webhook 생성/등록 시점에 생성되며 Webhook ID(subscriptionId
) 기준으로 같은 Webhook 내에서는 동일한 Key가 메시지 서명 생성에 사용됩니다. 생성된 Signing Key는 Webhook 생성 방식에 따라 다음과 같이 확인하실 수 있습니다.
(1) 콘솔에서 Signing Key 확인하기
Nodit 콘솔의 Webhook 메뉴에서 아래와 같이 [Signing Key] 버튼을 클릭하여 각 Webhook에 사용되는 Signing Key를 조회할 수 있습니다.

(2) Webhook 생성 API 응답에서 확인하기
Webhook 생성 API 요청시 정상적으로 생성된 Webhook에 대해 응답에 signingKey
필드가 포함됩니다. API상으로는 최초 Webhook 생성 시점에만 확인할 수 있으며, 이후 정보 조회 API 등을 사용해서는 조회할 수 없으므로 필요시 생성 시점에 저장해야합니다.
Nodit Webhook 메시지 서명 검증하기
Nodit Webhook 메세지를 HTTP Request를 통해 수신하는 시점에, x-signature
헤더로 전달된 서명 값을 추출한 뒤 위의 확인 과정을 통해 서명의 유효성을 확인 할 수 있습니다. 아래는 서명 유효성 검증을 구현하기 위한 언어별 예제 코드입니다.
const crypto = require('crypto');
/**
* Validate the signature using the request body, header signature, and signing key.
*
* @param {Object} body - The request body (JSON object)
* @param {string} signature - The signature string from the header
* @param {string} signingKey - The message signing key used for signing
* @returns {boolean} Returns true if the signature is valid, otherwise false
*/
function isValidSignature(body, signature, signingKey) {
const hmac = crypto.createHmac('sha256', signingKey);
hmac.update(JSON.stringify(body), 'utf8');
return signature === hmac.digest('hex');
}
// --- Sample Data and Execution Example ---
// Example request body
const sampleBody = {
"subscriptionId": "1",
"sequenceNumber": "1",
"description": "Signature Test",
"protocol": "APTOS",
"network": "TESTNET",
"subscriptionType": "WEBHOOK",
"notification": {
"webhookUrl": "YOUR_WEBHOOK_ENDPOINT"
},
"eventType": "EVENT",
"event": {
"eventType": "0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent",
"eventAccountAddress": "0x0",
"messages": [
{
"guid": {
"creation_number": "0",
"account_address": "0x0"
},
"sequence_number": "0",
"type": "0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent",
"data": {
"price": "44289819"
},
"event_index": 2,
"version": "6627015772"
}
]
},
"createdAt": "2025-02-18T05:52:53.559342615Z"
};
const signature = "da5eedb3f1fa386e095dc4f66a8f21155d22964633e0e6f844c331296ef1abaa";
// Example signing key (should be securely managed in production)
const signingKey = '7b8664b96de828e3b3bacf538c51e0ddcfa4fa6c686e738d8c0aeff5c8545ae7';
// Print the computed hash for debugging purposes
const hmac = crypto.createHmac('sha256', signingKey);
hmac.update(JSON.stringify(sampleBody), 'utf8');
console.log(hmac.digest('hex'));
// Execute the validation logic
if (isValidSignature(sampleBody, signature, signingKey)) {
console.log('Signature is valid.');
} else {
console.log('Signature is invalid.');
}
import json
import hmac
import hashlib
def is_valid_signature(body, signature, signing_key):
"""
Validate the signature using the request body, header signature, and signing key.
:param body: The request body (JSON object)
:param signature: The signature string from the header
:param signing_key: The message signing key used for signing
:return: Returns True if the signature is valid, otherwise False
"""
# Generate JSON string without unnecessary whitespace
message = json.dumps(body, separators=(',', ':')).encode('utf-8')
computed_signature = hmac.new(signing_key.encode('utf-8'), message, hashlib.sha256).hexdigest()
return signature == computed_signature
if __name__ == '__main__':
sample_body = {
"subscriptionId": "1",
"sequenceNumber": "1",
"description": "Signature Test",
"protocol": "APTOS",
"network": "TESTNET",
"subscriptionType": "WEBHOOK",
"notification": {
"webhookUrl": "YOUR_WEBHOOK_ENDPOINT"
},
"eventType": "EVENT",
"event": {
"eventType": "0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent",
"eventAccountAddress": "0x0",
"messages": [
{
"guid": {
"creation_number": "0",
"account_address": "0x0"
},
"sequence_number": "0",
"type": "0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent",
"data": {
"price": "44289819"
},
"event_index": 2,
"version": "6627015772"
}
]
},
"createdAt": "2025-02-18T05:52:53.559342615Z"
}
signature = "da5eedb3f1fa386e095dc4f66a8f21155d22964633e0e6f844c331296ef1abaa"
signing_key = "7b8664b96de828e3b3bacf538c51e0ddcfa4fa6c686e738d8c0aeff5c8545ae7"
print("Sample Body:", json.dumps(sample_body, indent=2))
print("Signing Key:", signing_key)
if is_valid_signature(sample_body, signature, signing_key):
print("Signature is valid.")
else:
print("Signature is invalid.")
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
public class SignatureValidation {
/**
* Validate the signature using the JSON body, header signature, and signing key.
*
* @param body JSON string (should be generated similar to JSON.stringify in Node.js)
* @param signature The signature string from the header
* @param signingKey The signing key used for signing
* @return Returns true if the signature is valid, otherwise false
* @throws Exception
*/
public static boolean isValidSignature(String body, String signature, String signingKey) throws Exception {
Mac sha256HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256HMAC.init(keySpec);
byte[] hashBytes = sha256HMAC.doFinal(body.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
String computedSignature = sb.toString();
return computedSignature.equals(signature);
}
public static void main(String[] args) {
try {
String sampleBody = "{\"subscriptionId\":\"1\",\"sequenceNumber\":\"1\",\"description\":\"Signature Test\",\"protocol\":\"APTOS\",\"network\":\"TESTNET\",\"subscriptionType\":\"WEBHOOK\",\"notification\":{\"webhookUrl\":\"YOUR_WEBHOOK_ENDPOINT\"},\"eventType\":\"EVENT\",\"event\":{\"eventType\":\"0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent\",\"eventAccountAddress\":\"0x0\",\"messages\":[{\"guid\":{\"creation_number\":\"0\",\"account_address\":\"0x0\"},\"sequence_number\":\"0\",\"type\":\"0x13a9f1a109368730f2e355d831ba8fbf5942fb82321863d55de54cb4ebe5d18f::just::RandomIndexEvent\",\"data\":{\"price\":\"44289819\"},\"event_index\":2,\"version\":\"6627015772\"}]}," +
"\"createdAt\":\"2025-02-18T05:52:53.559342615Z\"}";
String signature = "da5eedb3f1fa386e095dc4f66a8f21155d22964633e0e6f844c331296ef1abaa";
String signingKey = "7b8664b96de828e3b3bacf538c51e0ddcfa4fa6c686e738d8c0aeff5c8545ae7";
System.out.println("Sample Body: " + sampleBody);
System.out.println("Signing Key: " + signingKey);
if (isValidSignature(sampleBody, signature, signingKey)) {
System.out.println("Signature is valid.");
} else {
System.out.println("Signature is invalid.");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
각 언어별 코드에 대해 하나의 파일로 생성한 뒤, 터미널 등을 활용하여 다음과 같은 명령어로 예제 코드를 실행해 볼 수 있습니다.
//bash
node signatureValidation.js
python signatureValidation.py
//compile
javac SignatureValidation.java
//execute
java SignatureValidation
Signing Key 안전하게 관리하기
Signing Key의 안전한 보관은 서명 검증을 통한 Webhook 메시지의 무결성 및 인증 확인을 위한 전제 조건으로, Signing Key는 항상 안전하게 저장 및 관리되어야 합니다. 다음은 Signing Key를 안전하게 관리하기 위한 몇 가지 권장 사항입니다.
- Signing Key는 소스 코드 내부 또는 설정 파일 등 에 평문으로 하드코딩하지 않습니다.
Signing Key는 코드 내에 직접 포함시키지 않고 안전한 외부 저장소를 이용하여 저장하며, 런타임에 메모리 주입받도록 구성합니다. - Key 관리 시스템등을 활용하여 보다 안전하게 저장/조회할 수 있습니다.
AWS Secrets Manager, Azure Key Vault 등과 같은 전용 비밀 관리 서비스를 사용하여 Signing Key를 안전하게 저장하고 접근 제어를 강화할 수 있습니다. - 접근 제어 및 권한 관리
Signing Key에 접근할 수 있는 사용자와 애플리케이션을 최소한으로 제한하여 불필요한 노출을 방지합니다. - 로그 감사 및 모니터링
Signing Key 대한 접근 기록과 사용 내역을 모니터링하고, 서명 검증 실패 등 의심스러운 활동이 감지되면 즉시 대응할 수 있도록 로그를 기록합니다.
이러한 보안 조치를 따르면 Signing Key의 유출 위험을 줄이고 웹훅 메시지의 무결성과 인증 기능을 강화할 수 있습니다.
메시지 멱등성을 보장한 구현
Webhook으로부터 수신한 내용을 비즈니스 로직에 연동하는 경우, 같은 Webhook 메세지가 중복으로 전달되는 경우에도 멱등성을 보장하도록 서비스를 구현해야 합니다. 예를 들어, 자산 전송 이벤트 Webhook 수신 시 사용자 알림을 발생하는 비즈니스 로직을 구현한다면, 동일한 이벤트가 두 번 수신되는 경우에 최초 메시지 수신 시점에 서비스 변경 사항을 반영하고, 두번째 메시지가 인입되는 경우 해당 메시지를 무시하도록 구현되어야 합니다. 메시지의 subscriptionId
와 sequenceNumber
를 조합하여 유일 구분자로 사용할 수 있습니다.
sequenceNumber가 메세지에 포함되어 있지 않습니다. 어떻게 확인할 수 있나요?
현재
sequenceNumber
필드는 일부 블록체인 네트워크에서만 지원되고 있습니다. 빠른 시일내로 더 많은 네트워크에 대해 sequenceNumber 지원을 확대할 예정이오니, Change Log로 공지되는 최신 업데이트를 참고해주세요.
Reliability
메시지 전달의 신뢰성을 보장하기 위해, Endpoint 다운타임이나 처리 지연 상황에서도 모든 메시지가 누락 없이 전달되어야 합니다. 이를 위해 다음 구현 전략을 고려할 수 있습니다.
Best Practices
네트워크 구간에서 유실된 메시지 추적
최신 Webhook 메세지에 포함된 Sequence Number를 Redis와 같은 인메모리 캐시나 데이터베이스에 저장하여, 이전에 수신된 메시지와 비교함으로써 누락되거나 중복된 메시지를 식별할 수 있습니다. 누락된 Sequence Number가 식별되는 경우 Get Webhook History API를 호출하여 누락된 메세지를 조회할 수 있습니다.
Endpoint 다운 이후 자동 복구
Endpoint가 일시적으로 다운되는 경우 복구 시점 또는 인스턴스가 Load되는 시점에 Get Webhook History API를 활용하여 FAILED 상태인 메시지 목록을 자동으로 조회하도록 구현할 수 있습니다. 전달 실패한 메세지들을 일괄 처리함으로서 서버 유지보수 또는 장애 상황에서도 데이터의 완전성을 유지할 수 있습니다.
Get Webhook History API는 현재 Aptos 네트워크만 지원합니다.
더 많은 네트워크에서 Webhook 이력 조회 API를 사용할 수 있도록 업데이트가 지원 될 예정입니다. Change Log로 공지되는 최신 업데이트를 참고해주세요.