
목차
- HMAC이란 무엇인가
- SonarQube에서 HMAC secret을 설정하는 방법
- Spring Boot webhook endpoint에서 HMAC을 검증하는 방법
- Postman으로 HMAC webhook을 테스트하는 방법
작성 배경
보안 리포트를 받았는데, SonarQube 웹훅에서 HMAC을 검증하지 않는 이슈가 하나 잡혔다. 심각도도 High였다.

고치는 것도 중요했지만, 이번 기회에 HMAC이 뭔지, 왜 필요한지, 그리고 실제로 어떻게 붙였는지 한 번 정리해 봤다.
SonarQube webhook은 분석이 끝났을 때 특정 서버로 JSON payload를 보낼 수 있다.
처음에는 HMAC을 왜 이렇게까지 해야 하는지 잘 와닿지 않았다.
그냥 SonarQube webhook을 endpoint에서 받기만 하면 되는 것 아닌가?
근데 바로 그 생각 자체가 HMAC이 필요한 이유였다.
이 payload를 받아 SonarQube 정적 분석 결과를 확정하는 비즈니스 로직 있다고 가정해 보자.
누군가 webhook 요청을 임의로 조작해 이 endpoint를 호출한다면 어떻게 될까. 실제 정적 분석 결과는 실패인데, 성공한 것처럼 꾸며서 보낸다면? 그러면 서버 입장에서는 이 요청이 정말 SonarQube에서 온 건지, 아니면 중간에서 바뀐 건지 구분할 수가 없어진다.

1. HMAC이란?
HMAC은 Hash-based Message Authentication Code의 약자다. 단순한 hash와 달리 secret key를 같이 써서 메시지 인증 코드를 만든다.
처음에는 X-Sonar-Webhook-HMAC-SHA256 자체가 secret key인 줄 알았다. 이름도 묘하게 "HMAC SHA256 key"처럼 보여서, Postman에서 이 값을 헤더에 넣고 아무리 보내도 계속 401 Unauthorized가 떨어졌다. secret을 넣었는데 왜 인증이 안 되는지 한참 헷갈렸다.
SonarQube 공식 문서를 다시 보고 나서야 내가 완전히 반대로 이해하고 있었다는 걸 알게 됐다. SonarQube는 webhook을 만들 때 지정한 secret key로 payload마다 HMAC hex digest를 계산하고, 그 결과를 X-Sonar-Webhook-HMAC-SHA256 HTTP header로 보낸다. 그러니까 헤더에 들어가는 값은 secret key가 아니라, payload와 secret key로 계산한 signature다.

SonarQube webhook HMAC에서 중요한 값은 아래 네 가지다.
- 입력 메시지: HTTP request body의 raw bytes
- secret key: SonarQube webhook 설정에 입력한 공유 secret
- 알고리즘: HMAC-SHA256
- 결과 형식: 64자리 lower-case hex 문자열
- 전달 header: X-Sonar-Webhook-HMAC-SHA256
즉 X-Sonar-Webhook-HMAC-SHA256 header에는 secret key 원문이 오지 않는다.
SonarQube가 HMAC-SHA256(raw body bytes, secret key)를 계산한 결과만 들어온다.
흐름은 다음과 같다.
- SonarQube가 webhook을 보내기 직전
- Message, 즉 raw request body를 준비한다.
- SonarQube에 저장된 Secret Key를 함께 사용한다.
- HMAC-SHA256(Message, Secret Key)를 계산한다.
- 이 결과가 X-Sonar-Webhook-HMAC-SHA256 header에 들어가는 Signature다.
- Server가 webhook을 받은 직후
- request body를 아직 DTO로 파싱 하지 않고 raw body bytes 상태로 둔다.
- Server에 설정된 같은 Secret Key를 사용한다.
- HMAC-SHA256(받은 Message, Secret Key)를 다시 계산한다.
- 이 결과가 Server 쪽 Signature다.
- Server가 비교
- SonarQube가 header로 보낸 Signature
- Server가 직접 계산한 Signature
- 두 값이 같으면 통과, 다르면 401
여기서 hash는 별도 값으로 먼저 만들어져 전달되는 게 아니다. Message + Secret Key를 HMAC-SHA256 알고리즘에 넣어 Signature를 만드는 과정에서 내부적으로 SHA-256 hash 계산이 수행된다. 즉 hash는 SonarQube와 Server가 각각 Signature를 만드는 순간에 같이 들어간다고 보면 된다.
더 쉽게 정리하면 다음과 같다.
Message + Secret Key
|
v
HMAC-SHA256 내부에서 hash 계산
|
v
Signature
HMAC 흐름 도식

2. SonarQube에서 HMAC 설정하기
SonarQube Server 공식 문서 기준으로 webhook은 project level 또는 global level에 설정할 수 있다.
Project level webhook은 Project Settings > Webhooks에서 생성한다. Global webhook은 시스템 관리자 권한으로 Administration > Configuration > Webhooks에서 생성한다.


📌 주의할 점
소나큐브에서는 webhook URL host가 localhost,127.0.0.1, 0.0.0.0 등 loopback, wildcard 또는 SonarQube 서버 자신의 local address로 해석될 경우 Invalid URL: loopback and wildcard addresses are not allowed for webhooks. 예외를 발생시킨다. 따라서 SonarQube 서버에서 접근 가능한 도메인 주소로 구성해야 한다. [출처]
서버에서 설정 값은 다음과 같이 구성하면 된다.
SonarQube는 이 Secret 값을 사용해 webhook payload의 HMAC hex digest를 생성하고, 그 결과를 X-Sonar-Webhook-HMAC-SHA256 header에 담아 보낸다. 운영 환경에서는 이 값을 plain YAML에 고정하기보다 Secret, 환경 변수, 외부 설정 저장소 등을 통해 주입해서 사용해야 한다.
server:
sonarqube:
webhook:
secret-key: sonarqube-webhook-secret
3. Webhook endpoint에서 HMAC 검증하기
현재 HMAC 적용 endpoint는 POST /sonarqube/v1/webhook이다.
Controller는 request body를 DTO로 바로 받지 않고 byte []로 받는다.
@PostMapping("/webhook")
public ResponseEntity<Void> webhookHmac(
@RequestHeader(name = "X-Sonar-Webhook-HMAC-SHA256", required = false) String signature,
@RequestBody byte[] body
) {
log.info("SonarQube HMAC header present={}, length={}",
signature != null,
signature == null ? 0 : signature.length());
// X-Sonar-Webhook-HMAC-SHA256 값은 secret key 원문이 아니라
// HMAC-SHA256(raw body bytes, secret key) 결과인 64자리 lower-case hex 서명값이다.
// Jackson DTO로 변환하면 공백/필드 순서/escaping 등 원문 정보가 사라질 수 있으므로
// raw body bytes로 먼저 서명을 검증한 뒤 SonarQubeWebhookRequest로 파싱한다.
if (!signatureVerifier.isValid(body, signature)) {
throw new InvalidSonarQubeWebhookSignatureException();
}
parseRequest(body);
return ResponseEntity.ok().build();
}
private SonarQubeWebhookRequest parseRequest(byte[] body) {
try {
return objectMapper.readValue(body, SonarQubeWebhookRequest.class);
} catch (IOException e) {
throw new InvalidSonarQubeWebhookPayloadException(e);
}
}
핵심은 HMAC 검증을 JSON DTO 파싱보다 먼저 해야 한다는 점이다.
JSON은 의미상 같은 데이터라도 문자열 표현이 달라질 수 있다. 공백, 줄 바꿈, 필드 순서, escaping 방식이 달라지면 JSON 객체로는 같아 보여도 raw body bytes는 달라진다. SonarQube가 서명한 대상은 JSON의 의미 구조가 아니라 실제 HTTP request body 이므로
서버에서도 반드시 raw body bytes로 먼저 서명을 검증하고, 검증이 끝난 뒤 비즈니스 DTO로 파싱해야 한다.
Verifier 구현 흐름은 다음과 같다.
public boolean isValid(byte[] body, String receivedSignature) {
// X-Sonar-Webhook-HMAC-SHA256 header가 없거나 blank면 실패한다.
if (!StringUtils.hasText(receivedSignature)) {
return false;
}
byte[] receivedDigest = decodeHex(receivedSignature.trim());
if (receivedDigest == null) {
return false;
}
// 서버의 secret key와 raw body bytes로 HMAC-SHA256 digest를 계산한다.
byte[] expectedDigest = hmac(body == null ? new byte[0] : body);
// 계산한 digest와 header digest를 MessageDigest.isEqual로 비교한다.
return MessageDigest.isEqual(expectedDigest, receivedDigest);
}
private byte[] hmac(byte[] body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
return mac.doFinal(body);
} catch (Exception e) {
throw new IllegalStateException("failed to verify SonarQube webhook signature", e);
}
}
private byte[] decodeHex(String value) {
try {
return HexFormat.of().parseHex(value);
} catch (IllegalArgumentException exception) {
return null;
}
}
검증에 실패하면 Controller에서 401 Unauthorized를 반환하도록 했다.
@ExceptionHandler(InvalidSonarQubeWebhookSignatureException.class)
public ResponseEntity<Void> handleInvalidSignature(InvalidSonarQubeWebhookSignatureException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
4. Postman에서 테스트하기
Postman으로 직접 테스트할 때는 request body와 secret으로 HMAC을 계산한 뒤 header에 넣어 보내면 된다.
먼저 environment variable을 만든다.

sonarqube_webhook_secret = sonarqube-webhook-secret
Body는 raw JSON으로 작성한다.
{
"taskId": "task-hmac-1",
"status": "SUCCESS",
"project": {
"key": "project-a",
"name": "Project A"
},
"qualityGate": {
"status": "OK"
},
"properties": {
"sonar.analysis.sonarqube-branch": "main"
}
}
Pre-request Script에는 다음을 넣는다.
const secret = pm.environment.get("sonarqube-webhook-secret");
const rawBody = pm.request.body && pm.request.body.raw ?
pm.request.body.raw : "";
console.log("secret exists:", !!secret);
console.log("rawBody:", rawBody);
if (!secret) {
throw new Error("sonarqube-webhook-secret environment variable is missing");
}
const signature = CryptoJS.HmacSHA256(rawBody,
secret).toString(CryptoJS.enc.Hex);
pm.request.headers.upsert({
key: "X-Sonar-Webhook-HMAC-SHA256",
value: signature
});
검증 시나리오별 응답 결과
| 시나리오 | 상태코드 | 응답 캡쳐 |
| 정상 secret + 정상 body | 200 OK | ![]() ![]() |
| header 누락 또는 잘못된 signature | 401 Unauthorized | ![]() ![]() |
| signature는 맞지만 JSON 형식이 깨진 body | 400 Bad Request | ![]() ![]() |
마무리
HMAC을 어떻게 비유하면 좋을까 고민하다. 가장 근접한 비슷하다고 생각되는 상황으로 풀어봤다.
편지를 봉투에 넣고 둘만 아는 문양의 실링왁스로 봉인하는 것과 비슷하다.
보내는 쪽은 편지 내용(body)을 확인하고, 고유한 문양(secret key)으로 봉인(signature)을 찍어 보낸다.
받는 쪽도 같은 내용과 같은 문양을 기준으로 다시 봉인을 찍어 보고, 서로 같은지 확인한다.
결국 중요한 건 봉인 문양(signature)이 같은지, 그리고 편지 내용(raw body)이 그대로 인지다.
둘이 같을 때만 "이 요청은 믿을 수 있다"라고 판단한다.
HMAC 설정 덕분에 외부로 열려 있던 webhook endpoint를 최소한의 비용으로 안전하게 지킬 수 있었다. 여기서 안전하게 지킨다는 건 누군가 임의로 SonarQube webhook 결과를 조작하지 못하게 막고, 결국 비즈니스 로직의 무결성을 지킬 수 있게 됐다는 뜻이다. 로컬에서 테스트할 때도 예외는 아니다. ^^; (Postman을 쓰면 되긴 하지만 Pre-request Script 설정은 꼭 필요하다.)
한 줄 요약
HMAC은 같은 secret과 같은 raw body로 계산한 서명이 같은지 확인해 무결성과 인증성을 검증하는 해시 기반 메시지 인증 코드다.
'🏰 Back-end' 카테고리의 다른 글
| SonarQube | 네? 소나큐브에서 10,000개 이상 이슈는 안 보인다고요? (2) | 2026.07.04 |
|---|---|
| SonarQube | 같은 소스코드를 분석했는데 분석 시간이 14배 늘어났다. (0) | 2026.06.02 |
| 255개는 되는데 256개부터 터진다고요? Spring @ModelAttribute 컬렉션 바인딩 제한으로 대량 요청이 실패한 이유 (0) | 2026.03.17 |
| Template Method + Reader 추상화로 확장 가능한 Validator 만들기 (0) | 2026.01.22 |
| [SonarQube] SonarQube 업그레이드에 따른 SonarScanner 버전을 확인해보자 (SonarQube 10.5.1) (0) | 2024.10.27 |





