웹훅 서명 검증 실패, 원인을 찾아내는 방법
웹훅(webhook) 서명 검증 실패 오류가 뜻하는 것은 하나뿐입니다. 코드가 계산한 다이제스트가 요청 헤더에 실려 온 다이제스트와 다르다는 것입니다. 메시지가 담고 있는 정보는 그게 전부입니다. 권한과도 무관하고 만료와도 무관하며, 제공업체 SDK의 버그인 경우는 거의 없습니다. 제공업체가 해시한 바이트와 여러분이 해시한 바이트 사이에 무언가 어긋나 있는 것입니다.
결과를 결정하는 입력은 네 가지입니다. 어떤 바이트가 서명됐는지, 어떤 키 바이트를 썼는지, 어떤 해시 알고리즘이 돌았는지, 그리고 어떤 텍스트 인코딩으로 비교했는지입니다. 이 중 하나만 틀려도 실패의 모습은 똑같습니다. 오류 메시지에는 넷 중 무엇이 틀렸는지에 대한 단서가 전혀 없으니, 할 일은 문구를 더 꼼꼼히 읽는 것이 아니라 입력 후보를 좁혀 나가는 것입니다.
출발할 분기를 고르세요:
서명이 일치하지 않습니까? 분기는 세 가지입니다:
├─ 프레임워크가 여러분보다 먼저 JSON을 파싱했습니까? → 3절
├─ 헤더 값에 접두사가 붙어 있거나 base64처럼 보입니까? → 4절
└─ 제공업체 헤더에 타임스탬프가 들어 있습니까? → 2절
각 절은 실제 페이로드로 직접 돌려 볼 수 있는 내용으로 끝납니다. 참고로 한국어 문서와 검색어는 같은 개념을 웹훅과 웹후크 두 표기로 나눠 쓰니, 사내 위키나 로그를 뒤질 때는 두 표기를 모두 넣어 보십시오.
1. 서명 불일치가 실제로 알려 주는 것
검증은 바이트 문자열 두 개를 비교하는 일입니다. 실패했다면 아래 넷 중 하나가 어긋난 것이고, 이 넷은 서로 독립적입니다.
어떤 바이트가 서명됐는가. 제공업체는 특정한 바이트 시퀀스를 해시했습니다. 요청 body만일 수도 있고, body 앞에 타임스탬프가 붙은 형태일 수도 있습니다. 프레임워크가 JSON을 파싱해 객체를 건네줬다면 그 바이트는 이미 사라졌고, 믿을 만하게 복원할 방법도 없습니다. 이 이야기가 3절이며, 다른 원인과는 비교가 안 될 만큼 흔합니다.
어떤 키 바이트를 썼는가. 같은 비밀 키 문자열도 UTF-8 텍스트로 읽을 수 있고 hex로 읽을 수도 있고 base64로 읽을 수도 있으며, 해석마다 서로 다른 키가 나옵니다. 설정 로더가 지우지 않고 남긴 줄바꿈 하나가 붙은 비밀 키도 마찬가지입니다. 이 축에는 실패가 하나 더 숨어 있습니다. 맞는 키를 잘못 읽은 것이 아니라 아예 다른 비밀 키일 수도 있는데, 그 이야기가 6절입니다.
어떤 인코딩으로 비교했는가. SHA-256의 다이제스트는 원시 바이트 32개입니다. hex(16진수)와 base64는 똑같은 바이트를 텍스트로 적어 두는 두 가지 방식이고, 둘의 겉모습은 전혀 닮지 않았습니다. 한쪽을 다른 쪽과 비교하면 밑에 깔린 바이트가 같아도 HMAC 서명 불일치는 절대 사라지지 않습니다.
어떤 해시 알고리즘이 돌았는가. 제공업체 대부분은 SHA-256을 쓰고 문서에도 그렇게 적어 두므로, 이 축은 보통 아무 비용도 들지 않습니다. 알아 둘 만한 예외는 GitHub입니다. 전송마다 X-Hub-Signature(HMAC-SHA1)가 X-Hub-Signature-256(HMAC-SHA256) 옆에 함께 실려 오는데, GitHub 문서는 SHA-1 헤더가 하위 호환 목적으로만 남아 있다고 밝히면서 256 쪽을 권장합니다. 엉뚱한 헤더를 읽으면 바이트를 비교하기 전에 길이에서 먼저 드러납니다. 2절의 그 body를 같은 비밀 키로 SHA-1로 서명하면 sha1=ba2954d180839d8170b08b32cd38483775aaae96, 곧 hex 40자입니다. 같은 body의 SHA-256 다이제스트는 64자입니다.
디버깅하는 동안 이 네 가지를 섞지 마십시오. 한 축을 떼어 내는 가장 빠른 방법은 애플리케이션 밖에서, 여러분이 통제하는 입력으로 다이제스트를 계산해 보는 것입니다. body와 비밀 키를 HMAC 생성기에 붙여 넣고 어떤 값이 나오는지 확인하세요. 이 도구는 전부 브라우저 안에서 돌고 비밀 키는 페이지를 벗어나지 않으므로, 운영 환경의 서명 비밀 키를 붙여 넣어도 됩니다. HMAC은 일반 SHA-256 해시와 똑같은 SHA-256 기본 연산을 돌리고 거기에 비밀 키만 얹은 것이므로, 제공업체의 값을 손으로 재현할 수 있다면 암호 연산 쪽은 정상이고 버그는 요청 처리 쪽에 있습니다.
2. 주요 제공업체 네 곳이 실제로 서명하는 대상
연동을 가장 많이 깨뜨리는 가정은 모든 제공업체가 요청 body만 서명한다는 것입니다. 규모가 가장 큰 네 곳 중 두 곳은 그렇지 않습니다. 각 업체가 실제로 해시하는 대상을 현재 공식 문서와 대조해 정리했습니다:
| 제공업체 | 헤더 | 서명 대상 문자열 | 인코딩 | 값 접두사 | 비밀 키 | 타임스탬프 허용 오차 |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | 엔드포인트 서명 비밀 키(whsec_ 접두사) | 5분(300초) |
| GitHub | X-Hub-Signature-256 | rawBody(접두사 없음) | hex | sha256= | 웹훅 비밀 토큰 | 없음(타임스탬프를 보내지 않음) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | 서명 비밀 키 | 5분 |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | 없음 | 앱 클라이언트 시크릿(별도의 웹훅 비밀 키가 아님) | 없음 |
이 네 곳만 봐도 서로 독립적인 세 축이 다 나옵니다. 서명 대상 문자열은 body 단독이거나 타임스탬프를 이어 붙인 것이고, 구분자마저 다릅니다. Stripe는 .을 쓰고 Slack은 :을 씁니다. 인코딩은 세 곳이 hex, 한 곳이 base64입니다. 비밀 키는 세 곳이 웹훅 전용 자격 증명에서 나오고 Shopify만 앱 클라이언트 시크릿에서 나오는데, 관리 화면에 “webhook”이라고 적힌 필드가 있지만 그게 필요한 값이 아니어서 이 부분을 가장 많이 틀립니다.
차이를 눈으로 확인할 수 있게, 하나의 body를 하나의 비밀 키로 네 가지 방식으로 서명해 봤습니다:
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| 형태 | 값 |
|---|---|
| GitHub 방식 | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| Shopify 방식 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| Stripe 방식 | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| Slack 방식 | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
앞의 두 행은 같이 읽으십시오. 똑같은 32바이트 다이제스트를 두 번 적어 놓은 것입니다. hex로는 64자, base64로는 패딩까지 포함해 44자입니다. 두 문자열을 아무리 들여다봐도 같은 값이라는 힌트는 없고, 그래서 인코딩을 가로질러 비교하면 “비밀 키는 맞는데”로 시작하는 온갖 점검을 다 통과하고도 살아남는 불일치가 생깁니다.
뒤의 두 행은 나머지 절반을 증명합니다. body도 같고 비밀 키도 같고 알고리즘도 같은데, 두 다이제스트 모두 GitHub 것과 닮은 구석이 없습니다. 해시되는 문자열이 이제 타임스탬프로 시작하기 때문입니다. Stripe 웹훅 서명 검증 실패 신고는 대부분 이 행으로 귀결됩니다. 코드가 body만 해시하고 t 값과 점을 앞에 붙이지 않은 것입니다. HMAC 생성기에서 메시지 칸과 출력 형식만 바꿔 가며 네 값을 직접 재현해 볼 수 있습니다.
타임스탬프 열에는 실무적인 결과가 하나 딸려 옵니다. Stripe나 Slack의 다이제스트는 몇 분 동안만 유효하므로, 오늘 잡아 둔 서명을 내일 테스트에서 재생할 수는 없습니다. GitHub과 Shopify의 서명은 영구히 그대로여서 디버깅이 훨씬 쉬운 대신, 재생 공격 방어를 직접 고민해야 합니다.
3. 원본 body 문제
웹훅 서명 검증 실패 신고 대부분이 이 절로 이어집니다.
프레임워크가 이미 바이트를 없앤 상태입니다
웹 프레임워크는 파싱을 대신해 주려고 만들어졌습니다. 서명 검증을 깨뜨리는 것이 바로 그 편의 기능입니다. 핸들러가 실행될 시점에는 원래 바이트가 남아 있지 않습니다.
express.json()은 요청 스트림을 읽어 파싱한 뒤 req.body를 JavaScript 객체로 바꿔 놓습니다. 스트림은 이미 소비됐으므로 다시 읽을 수 없습니다. FastAPI에서 Pydantic 모델이나 dict body 파라미터를 선언하면, 프레임워크는 함수에 진입하기 전에 읽고 파싱합니다. Rails는 컨트롤러 액션보다 먼저 도는 미들웨어를 통해 JSON body로 params를 채웁니다. Spring의 Jackson 컨버터는 body를 DTO 클래스로 변환하며, 기본 설정에서 그 밑의 HttpServletRequest 입력 스트림은 한 번만 읽을 수 있습니다.
여기에 버그는 하나도 없습니다. 모두 설정된 대로 동작하고 있을 뿐입니다. 문제는 서명이 덮는 대상이 바이트인데 객체는 바이트가 아니고, 객체를 다시 바이트로 만드는 일은 제공업체가 수행한 연산과 다른 연산이라는 점입니다.
재직렬화가 통할 때도 있다는 것이 바로 함정입니다
흔한 조언은 재직렬화하면 바이트가 바뀐다는 것입니다. 이 설명은 절반만 맞고, 빠진 절반 때문에 이 실패의 진단이 유난히 어렵습니다. 아무것도 바뀌지 않는 경우도 있습니다.
페이로드 모양별로 JSON.stringify(JSON.parse(body)) === body를 측정한 결과입니다:
| 페이로드 모양 | 왕복 후 바이트 | 변화 |
|---|---|---|
{"id":42,"event":"user.created"} | 동일 | 없음. 그래서 로컬 테스트가 통과합니다 |
{"amount":1.0} | 변경됨 | → {"amount":1} |
{"n":1e3} | 변경됨 | → {"n":1000} |
{"id":12345678901234567890} | 변경됨 | → {"id":12345678901234567000} (정밀도 손실) |
{"name":"caf\u00e9"} | 변경됨 | → {"name":"café"} (6바이트가 2바이트로) |
{"a":1}\n | 변경됨 | 끝의 줄바꿈이 삼켜집니다 |
{ "a" : 1 } | 변경됨 | 내부 공백 문자가 삼켜집니다 |
{"v":-0.0} | 변경됨 | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | 변경됨 | → {"p":0.1} |
첫 번째 행을 보십시오. 정수 하나와 짧은 ASCII 문자열로 된 중첩 없는 객체는 바이트 단위로 그대로 왕복하므로, 파싱한 뒤 다시 문자열로 만드는 방식의 검증 코드는 그런 픽스처로 작성한 테스트를 전부 통과합니다. 그리고 배포하면 1.0 같은 금액, 2^53을 넘는 ID, 악센트가 들어간 고객 이름을 실은 첫 페이로드에서 실패합니다. 실패하는 것은 그런 페이로드뿐입니다.
이것이 “로컬은 되는데 운영에서 간헐적으로 401”이 나오는 원리이며, 항상 실패하는 검증 코드보다 훨씬 고약합니다. 늘 실패하는 코드는 한 시간이면 고쳐집니다. 이벤트의 3%에서만 실패하는 코드는 제공업체 탓으로 돌려지고, 재시도되고, 위로 보고되고, 몇 주 동안 그대로 안고 갑니다. 실패율이 0%도 100%도 아닌 어딘가라면, 이 표를 가장 먼저 확인하십시오.
키 순서는 누구나 가장 먼저 의심하지만 실제로는 가능성이 가장 낮은 원인입니다. JSON.parse는 문자열 키의 삽입 순서를 그대로 유지합니다. 진짜 범인은 숫자와 공백 문자입니다.
프레임워크별 원본 body 확보 방법
Express에서는 라우트 전용 파서를 전역 JSON 파서보다 먼저 등록합니다:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 이 라우트는 app.use(express.json())보다 먼저 등록해야 합니다.
// body-parser가 요청을 파싱 완료로 표시하므로, 뒤에 오는 raw()는 조용히 {}만 돌려줍니다.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // 객체가 아니라 Buffer
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // Buffer를 그대로 해시, toString() 금지
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // 나머지 라우트는 계속 파싱된 JSON을 받습니다
app.listen(3000);
미들웨어 순서를 바꿀 수 없다면, 파싱하는 동안 복사본을 남겨 두십시오:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI입니다. Starlette가 body를 캐시하므로, 파싱된 모델을 함께 받는 핸들러에서도 await request.body()는 원래 바이트를 반환합니다:
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # 받은 그대로의 바이트
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails에서는 request.raw_post가 파싱되지 않은 body를 문자열로 반환합니다:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go에서는 body를 직접 읽어야 하고, 읽고 나면 비워진다는 사실을 기억해야 합니다:
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// r.Body가 아니라 raw에서 Unmarshal하십시오. r.Body에는 바이트가 남아 있지 않습니다.
w.WriteHeader(http.StatusOK)
}
Spring에서는 byte[]로 받으면 Jackson을 아예 건너뜁니다:
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
필터에서 검증해야 하고 컨트롤러 시그니처를 바꿀 수 없을 때의 대안이 ContentCachingRequestWrapper입니다. 여기에도 고유한 함정이 있습니다. getContentAsByteArray()는 뒤쪽에서 누군가 스트림을 읽은 뒤에야 바이트를 반환하므로, chain.doFilter(...) 전에 호출하면 빈 배열을 받습니다.
4. 인코딩 불일치: hex, base64, 그리고 키 자체
직접 계산한 다이제스트와 헤더 값 사이에는 서로 다른 인코딩 결정이 세 개 놓여 있고, 그중 하나만 어긋나도 비교가 깨집니다.
먼저 다이제스트 인코딩입니다. HMAC-SHA256의 출력은 32바이트입니다. 소문자 hex로 적으면 64자이고, 표준 base64로 적으면 = 패딩까지 44자입니다. 2절의 두 행이 그 차이를 그대로 보여 줍니다:
| 인코딩 | 문자 수 | 같은 32바이트를 적은 결과 |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
낯선 헤더는 생김새만 보고도 구분할 수 있습니다. 값이 0-9a-f 문자 64개라면 hex입니다. =로 끝나는 44자이거나 +, /, 대문자가 섞여 있다면 base64입니다. 짐작이 아니라 확인이 필요하다면 base64 값을 Base64 디코더에 넣어 32바이트가 나오는지 보십시오. 32바이트가 나온다면 두 문자열은 같은 다이제스트를 가리키는 것이고, 여러분은 서명이 아니라 텍스트 형식을 비교하고 있었던 것입니다.
다음은 값 접두사입니다. GitHub은 hex 앞에 sha256=을 붙여 보냅니다. Slack은 v0=을 보냅니다. Stripe는 전체를 쉼표로 구분된 key=value 쌍 목록으로 감쌉니다. 이 문자들은 다이제스트의 일부가 아니므로, 헤더에서 접두사를 떼거나 여러분의 값에 접두사를 붙이십시오. 둘 다 하지 않는 것이 올바른 구현에서 HMAC 서명 불일치가 보고되는 가장 흔한 이유이며, Node에서는 7절에서 설명하듯 불일치라고 보고조차 하지 않습니다.
마지막은 키 인코딩입니다. 비밀 키도 바이트이며, 같은 문자열을 UTF-8, hex, base64로 읽으면 서로 다른 세 개의 키가 나옵니다. whsec_... 같은 텍스트 토큰을 주는 제공업체는 UTF-8을 기대하지만, 내부 시스템 중에는 서명 전에 디코딩해야 하는 base64나 hex 비밀 키를 배포하는 곳도 많습니다. 이 실패 형태는 JWT 쪽의 같은 문제와 모양이 완전히 같고, 특정 비밀 키가 base64인지 평문인지 구분하는 방법까지 포함해 JWT invalid signature 오류: 모든 원인과 해결 방법에서 자세히 다룹니다.
5. 타임스탬프, 허용 오차, 재생 공격 시간 창
완벽히 일치하는 다이제스트를 계산했는데도 거부될 수 있습니다. 타임스탬프를 함께 보내는 제공업체는 그 값을 검사하기를 기대하며, 오래된 타임스탬프는 서명이 유효해도 거절해야 하는 요청입니다.
| 제공업체 | 타임스탬프 위치 | 시간 창 |
|---|---|---|
| Stripe | Stripe-Signature 안의 t= | 5분(300초) |
| Slack | X-Slack-Request-Timestamp 헤더 | 5분 |
| GitHub | 보내지 않음 | 해당 없음 |
| Shopify | 보내지 않음 | 해당 없음 |
시간 창은 어느 쪽으로 틀려도 대가가 있습니다. 너무 넉넉하면 가로챈 요청을 허용한 시간만큼 계속 재생할 수 있어서, 타임스탬프를 검사하는 의미가 거의 사라집니다. 너무 빡빡하면 평범한 시계 오차만으로도 정상 전송이 거부되기 시작합니다. 두 제공업체가 모두 5분을 골랐으니, 그대로 따르는 것이 무난한 기본값입니다.
허용 오차를 넓히기 전에 시계를 확인하십시오. 컨테이너 이미지는 NTP를 돌리지 않으며, 스냅샷에서 복원한 VM은 로그에 아무 흔적도 남기지 않은 채 실제 시각보다 몇 분 뒤처져 있을 수 있습니다. 꾸준히 어긋나는 호스트는 처음에는 간헐적이다가 결국 전부 실패하는 양상을 만드는데, 이는 코드 회귀처럼 읽히지만 코드 회귀가 아닙니다.
시계와 관련된 또 다른 버그는 단위 불일치입니다. 표에 있는 모든 제공업체는 epoch 초를 보냅니다. 이 값을 JavaScript의 Date.now() 같은 밀리초 값과 비교하면 차이가 실제 경과 시간의 약 1,000배로 나오므로, 모든 이벤트가 어떤 시간 창에도 들어오지 못합니다. 증상은 다이제스트 자체는 일치하는데 허용 오차 검사가 전송의 100%를 거부하는 형태입니다. 지금 손에 든 값이 어느 단위인지 모르겠다면 자릿수가 단서이며, epoch 초와 밀리초의 차이에서 변환 방법과 그 주변의 시간대 함정을 다룹니다.
서명 대상 문자열을 만들 때는 파싱해서 다시 포맷한 숫자가 아니라 헤더에 실려 온 타임스탬프 문자열을 그대로 쓰십시오. 1700000000을 실수로 파싱해 다시 출력하면 1700000000.0이 될 수 있고, 이는 다른 바이트 시퀀스입니다.
6. 잘못된 비밀 키, 그리고 교체되는 비밀 키
인코딩을 더 파고들기 전에 가장 단순한 원인부터 지워 두십시오. 비밀 키 자체가 맞는 키가 아닐 수 있습니다. Stripe 문서는 엔드포인트마다 고유한 비밀 키를 발급한다고 못 박아 두었고, 같은 URL을 테스트 키와 라이브 키 양쪽에 걸어 두면 비밀 키도 각각 다르다고 적어 두었습니다. 여기서 실수 하나가 세 가지 모습으로 갈라집니다.
테스트 모드와 라이브 모드는 서로 다른 비밀 키를 씁니다. 대시보드가 테스트 모드일 때 복사해 둔 값은 라이브 전송을 전부 실패시킵니다. 엔드포인트도 각자 자기 키를 갖고 있고, 문서 역시 엔드포인트를 여러 개 쓴다면 서명을 검증할 엔드포인트마다 비밀 키를 따로 받아야 한다고 덧붙입니다. 엔드포인트 두 개를 핸들러 하나로 보내면서 환경 변수에는 비밀 키를 하나만 넣어 두면 트래픽의 절반이 실패합니다. 그리고 stripe listen이 출력하는 서명 비밀 키는 CLI가 로컬로 전달할 때 쓰는 것이고, 대시보드에 등록한 어떤 엔드포인트와도 별개이므로 둘을 바꿔 쓸 수 없습니다.
이 셋은 밖에서 보면 인코딩 버그처럼 보이지 않습니다. 다이제스트는 형태가 멀쩡하고, 비교도 제대로 돌고, 환경 변수에 든 값도 실제 Stripe 비밀 키입니다. 다만 이번 전송에 서명한 그 키가 아닙니다.
키 교체는 같은 축이 발밑에서 움직이는 경우입니다. 인코딩 문제와 가장 안 닮았는데도 코드 버그로 가장 많이 오진됩니다. 코드는 하나도 바뀌지 않았고 어제까지 검증이 잘 됐는데, 이제 일부 이벤트가 실패합니다.
겹치는 기간은 의도된 설계입니다. Stripe는 교체 후 최대 24시간 동안 이전 엔드포인트 비밀 키를 유효하게 유지하며, 그 기간에는 Stripe-Signature 헤더가 활성 비밀 키마다 v1 서명을 하나씩 실어 보냅니다. Shopify는 반대 방향입니다. 교체 후 새 비밀 키로 다이제스트를 계산하기 시작할 때까지 최대 한 시간이 걸릴 수 있으므로, 그동안 필요한 것은 이전 키입니다.
코드를 깨뜨리는 쪽은 Stripe의 동작입니다. 헤더가 서명 하나만 담고 있는 것처럼 보이기 때문입니다. ,로 잘라 처음 만난 v1을 쓰는 방식은 v1이 두 개가 되는 순간까지만 잘 동작하고, 그다음부터는 어느 비밀 키가 어느 이벤트에 서명했는지에 따라 대략 절반만 일치합니다. 전부 순회하십시오:
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // 원래 문자열 그대로 보관
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
반복문 말고도 눈여겨볼 것이 두 가지 있습니다. 타임스탬프는 도착한 문자열 그대로 서명 대상 페이로드에 들어가고, body는 템플릿 문자열 보간이 아니라 바이트 단위로 이어 붙입니다. 보간을 쓰면 body를 먼저 UTF-8로 디코딩해 버립니다.
여러분 쪽에서 키를 교체할 때도 형태는 같습니다. 겹치는 기간 동안 이전 키와 새 키를 모두 받아들이고, 그 뒤에 이전 키를 버리십시오. 새로 쓰는 키는 엔트로피가 충분해야 하므로 직접 타이핑하지 말고 생성하십시오. 256비트 난수라면 서명 비밀 키 생성기 같은 도구로 뽑으면 됩니다.
7. 타이밍 정보를 흘리지 않고 서명을 비교하는 방법
다이제스트 두 개를 손에 넣은 다음, 그것을 어떻게 비교할지는 보안 결정입니다. 문자열 비교는 다른 바이트를 만나는 즉시 반환하므로, 걸린 시간이 앞쪽 몇 바이트까지 맞았는지를 드러냅니다. 요청을 많이 던질 수 있는 공격자는 이를 이용해 유효한 서명을 한 바이트씩 복원합니다. 인터넷을 건너서는 느리고 잡음도 크지만, 같은 로컬 네트워크 안에서는 충분히 현실적인 공격입니다.
모든 런타임에는 상수 시간 비교 함수가 들어 있습니다:
| 언어 | 상수 시간 비교 함수 | 길이가 다를 때 |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | 예외를 던집니다 |
| Python | hmac.compare_digest(a, b) | False 반환 |
| Go | hmac.Equal(a, b) | false 반환 |
| PHP | hash_equals($known, $user) | false 반환 |
| Ruby | OpenSSL.secure_compare(a, b) | false 반환 |
혼란스러운 장애 유형 하나가 통째로 이 마지막 열에서 나옵니다. Node만 예외적이고, 실패하는 방식도 얌전하지 않습니다:
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
이 예외가 언제 터지는지 따져 보십시오. hex로 적은 SHA-256 다이제스트는 64자입니다. X-Hub-Signature-256의 값은 sha256=이 7자이므로 71자입니다. 접두사를 떼는 것을 잊으면 두 버퍼의 길이가 달라지고, timingSafeEqual은 false를 반환하는 대신 예외를 던집니다. 잡히지 않은 예외는 핸들러 밖으로 전파되고, Express는 이를 500으로 바꿉니다.
웹훅 401 unauthorized 응답을 찾고 있는데 서버 오류가 돌아오니, 핸들러와 데이터베이스 호출과 이벤트 디스패처를 뒤지게 됩니다. 진짜 버그는 비교문 한 줄 위에 있습니다. 64자 hex 다이제스트와 44자 base64 다이제스트를 비교해도 같은 이유로 예외가 나므로, Node에서는 인코딩 불일치 역시 깔끔한 거절이 아니라 500으로 드러납니다.
해결책은 길이를 직접 검사해 false를 반환하는 것입니다:
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // 호출 전에 방어
return crypto.timingSafeEqual(a, b);
}
길이가 새는 것은 무해합니다. 다이제스트 길이는 알고리즘이 정해 놓은 공개된 값입니다. 새어 나가면 안 되는 것은 앞쪽 몇 바이트가 맞았는지입니다. HMAC 생성기의 검증 탭은 길이 차이를 같은 상수 시간 누산기에 함께 접어 넣고 중간에 빠져나가지 않으므로, 길이가 달라도 예외가 아니라 그냥 false를 반환합니다. 덕분에 일회용 코드를 따로 쓰지 않고도 헤더 값과 직접 계산한 다이제스트를 맞춰 볼 수 있습니다.
8. 전송 계층이 바이트를 바꿔 놓은 경우
서명 대상 문자열, 원본 body, 인코딩, 시계, 키 교체까지 모두 지웠다면, 남은 가능성은 프로세스에 도착한 바이트가 제공업체에서 출발한 바이트와 다르다는 것입니다.
먼저 압축입니다. 제공업체나 프록시가 Content-Encoding: gzip을 붙여 body를 gzip으로 보낼 수 있습니다. 서명이 덮는 대상은 압축되지 않은 페이로드이므로 압축을 푼 다음에 해시해야 합니다. 어떤 프레임워크는 알아서 압축을 풀고 어떤 프레임워크는 압축된 바이트를 그대로 건네주는데, 로그에 바이너리 쓰레기처럼 찍히는 body가 그 신호입니다.
청크 전송도 있습니다. Transfer-Encoding: chunked에서는 Content-Length가 없으므로, 그 헤더를 믿고 읽기 버퍼 크기를 잡는 코드는 body를 잘라 버립니다. 잘린 body의 다이제스트는 형식만 멀쩡한 헛값입니다. 절대 일치하지 않는데 어디도 이상해 보이지 않습니다.
그다음은 프록시와 WAF입니다. body를 읽고 다시 쓰는 계층은 무엇이든 body를 바꿀 수 있습니다. AWS API Gateway는 Lambda에 도달하기 전에 body를 base64로 인코딩할 수 있으므로 해시 전에 디코딩해야 합니다. 애플리케이션 로드 밸런서, 서비스 메시, 웹 애플리케이션 방화벽도 설정에 따라 페이로드를 정규화하거나 다시 인코딩합니다. 핸들러가 보는 바이트 길이와 제공업체가 보낸 Content-Length를 비교해 확인하십시오.
문자 인코딩과 BOM도 바이트를 바꿉니다. 페이로드에는 ASCII 밖의 문자가 들어올 수 있고, GitHub 문서는 페이로드를 UTF-8로 다뤄야 한다고 명시합니다. body를 엉뚱한 문자 집합으로 문자열로 디코딩한 뒤 다시 인코딩하면 멀티바이트 문자가 전부 망가집니다. 편집기나 직렬화 코드가 앞에 붙인 UTF-8 바이트 순서 표시 EF BB BF는 서명되지 않은 3바이트를 더합니다.
마지막은 줄바꿈과 끼어든 공백 문자입니다. 텍스트 모드 파일 경계를 거친 body는 LF가 CRLF로 바뀐 채 도착할 수 있습니다. 서명 대상 문자열의 정확한 형태도 제공업체 명세에서 확인하십시오. 뒤에 문자를 덧붙이는 제공업체가 있는데, Typeform은 끝의 줄바꿈이 해시 대상에 포함된다고 문서에 적어 둔 사례입니다. 제공업체 문서가 추가 문자를 언급한다면 글자 그대로 받아들이십시오.
9. 반복 가능한 디버깅 절차
아래 순서대로 실행하십시오. 각 단계는 버그를 찾아내거나 분기 하나를 지워 냅니다. 중간에 멈추게 되는 것이 원래 목적입니다.
- 미들웨어가 돌기 전에 원본 바이트를 기록하세요. 요청 생애주기에서 닿을 수 있는 가장 이른 지점에서 body를 파일로 쓰거나, 바이트 길이와 SHA-256을 함께 로그로 남기십시오. 길이만으로 해결되는 경우가 의외로 많습니다. 예상보다 1이 크면 끝의 줄바꿈이고, 3이 크면 BOM입니다.
- 다이제스트를 손으로 계산하세요. 그 바이트를 그대로, 그리고 비밀 키를 HMAC 생성기에 붙여 넣고 SHA-256을 고른 뒤 출력 형식을 헤더와 같게 맞추십시오. 문제를 둘로 깔끔하게 갈라 주기 때문에 이 단계의 가치가 가장 큽니다.
- 손으로 계산한 값을 헤더와 비교하세요. 같다면 바이트와 비밀 키가 모두 맞다는 뜻이고 버그는 코드 경로 안에 있으니, 비교하는 코드를 읽으십시오. 같지 않다면 입력 중 하나가 틀린 것이니 다음 단계로 넘어가십시오.
- 서명 대상 문자열을 2절 표와 맞춰 보세요. 이 제공업체는 타임스탬프를 앞에 붙입니까? 구분자는 무엇입니까? 도구에서 접두사를 붙여 다시 계산하십시오.
- 다이제스트 인코딩을 바꿔 보세요. hex와 base64로 각각 다시 계산해 둘 다 헤더와 비교하십시오. 끝에
=이 붙은 44자 헤더 값은 코드가 무엇을 가정했든 base64입니다. - 키 인코딩을 바꿔 보세요. 비밀 키를 텍스트로, hex로, base64로 차례로 시도하십시오. 보통 셋 중 하나가 일치하고, 그것이 제공업체가 기대하는 형식입니다.
- 시계와 키 교체 상태를 확인하세요. 서버 시각을 신뢰할 수 있는 기준과 비교하고, epoch 초를 다루고 있는지 확인하고, 제공업체 대시보드에서 지난 24시간 안에 키 교체가 있었는지 확인하십시오.
이 절차를 훨씬 빠르게 만드는 습관이 두 가지 있습니다. 하나는 다음 전송을 기다리지 말고 실패한 페이로드 하나를 잡아 두고 오프라인에서 작업하는 것입니다. 다른 하나는 잡아 둔 body를 고정된 서명과 함께 엔드포인트로 다시 보내, 시도마다 입력이 달라지지 않게 하는 것입니다. cURL 명령어 생성기는 정확한 헤더와 파일에서 읽은 body로 요청을 조립해 주므로 실행할 때마다 바이트가 그대로 유지됩니다. 원할 때 실패를 재현할 수 있게 되면, 간헐적이던 웹훅 서명 검증 실패 신고는 앉은 자리에서 원인을 짚을 수 있는 버그로 바뀝니다.
그래도 지원 티켓을 넣어야 한다면, 해시한 body의 바이트 길이, 헤더 값 원문, 사용한 서명 대상 문자열 구성 방식, 다이제스트 인코딩을 함께 적으십시오. 비밀 키 자체는 절대 넣지 마십시오.
FAQ
웹훅 서명이 로컬에서는 되는데 운영에서는 실패하는 이유는 무엇입니까?
테스트 페이로드는 JSON 왕복을 거쳐도 그대로 살아남을 가능성이 높아서, 다시 직렬화해도 아무 문제가 없습니다. 실제 페이로드에는 실수, 큰 정수, Unicode 이스케이프, 여분의 공백 문자가 들어 있고 그런 값들은 바이트를 바꿉니다. 다시 직렬화한 사본이 아니라 원본 body를 서명 대상으로 쓰십시오. 어떤 모양이 깨지는지는 3절의 표에 정리해 두었습니다.
서명을 비교할 때 sha256= 접두사를 포함해야 합니까?
떼어 내거나, 여러분의 값에도 붙여서 두 문자열이 정확히 같아지게 만드십시오. 직접 계산한 hex 다이제스트는 64자이고 접두사가 붙은 헤더 값은 71자입니다. 길이가 다르면 false를 반환하는 비교 함수도 있지만, Node의 timingSafeEqual은 false를 반환하는 대신 예외를 던집니다.
프레임워크가 JSON을 파싱한 뒤에도 서명을 검증할 수 있습니까?
믿을 만하게는 안 됩니다. 재직렬화가 원래 바이트를 재현하는 것은 실수도, 2^53을 넘는 정수도, Unicode 이스케이프도, 여분의 공백 문자도 없는 페이로드뿐입니다. 그중 하나만 나타나는 순간 다이제스트가 달라지므로, 테스트에서는 검증이 통과하고 운영 이벤트의 일부에서 실패합니다.
같은 페이로드인데 Stripe와 GitHub의 서명이 다른 이유는 무엇입니까?
해시하는 문자열이 다르기 때문입니다. GitHub은 원본 body만 서명합니다. Stripe는 타임스탬프, 문자 ., 그다음 body를 서명하므로 같은 페이로드가 서로 다른 시각에 전송되면 서로 다른 다이제스트가 나옵니다. Slack은 v0:과 자체 타임스탬프를 앞에 붙입니다. 알고리즘은 같고 입력이 다릅니다.
타임스탬프 허용 오차는 얼마로 잡아야 합니까?
Stripe와 Slack이 쓰는 값이 5분이고, 그대로 따르는 것이 합리적인 기본값입니다. 더 짧게 잡으면 서버 시계가 조금만 어긋나도 정상 전송이 거부됩니다. 더 길게 잡으면 가로챈 요청을 재생할 수 있는 기간이 넓어집니다. 허용 오차를 느슨하게 하기 전에 NTP로 시계를 맞추십시오.
길이가 다를 때 timingSafeEqual은 false를 반환합니까?
아닙니다. Node는 RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length를 던집니다. 잡지 않으면 이것이 401이 아니라 500이 되고, 비교문 한 줄 위가 아니라 핸들러를 디버깅하게 만듭니다. 길이를 먼저 비교해 직접 false를 반환하십시오.
제공업체가 비밀 키를 교체했는데 일부 웹훅이 계속 실패하는 이유는 무엇입니까?
키 교체 기간은 겹칩니다. Stripe는 이전 비밀 키를 최대 24시간 유효하게 유지하면서 활성 비밀 키마다 v1 서명을 하나씩 보내므로, 첫 번째 v1만 읽는 코드는 이벤트의 대략 절반에서 실패합니다. Shopify는 새 비밀 키를 쓰기 시작할 때까지 최대 한 시간이 걸릴 수 있습니다.
마무리
검증은 바이트 비교입니다. 그래서 웹훅 서명 검증 실패는 암호 기술의 문제가 아니라 어떤 바이트를 서명했는지에 대한 이견으로 귀결됩니다. 디버깅하는 동안 아래 축들을 따로 두십시오:
- 어떤 바이트가 서명됐는가. 파서가 손대기 전에 원본 body를 확보하십시오. 재직렬화한 객체는 절대 해시하지 마십시오. 테스트를 통과할 만큼은 자주 일치하고, 제대로 동작할 만큼은 자주 일치하지 않습니다.
- 어떤 키 바이트를 썼는가. 하나의 비밀 키를 텍스트, hex, base64로 읽으면 서로 다른 세 개의 키가 나옵니다.
- 어떤 인코딩으로 비교했는가. hex는 64자, base64는 44자이며, 둘은 같은 32바이트를 가리킵니다.
- 그 밖의 모든 것. 타임스탬프 접두사, 값 접두사, 허용 오차 시간 창, 키 교체 겹침, 전송 계층을 대략 이 가능성 순서로 보십시오.
- 어떻게 비교했는가. 길이를 먼저 방어하고, 그다음 런타임에 들어 있는 상수 시간 함수를 쓰십시오.
비교 기준으로 믿을 수 있는 값이 필요할 때는 애플리케이션 밖에서 계산하십시오. body와 비밀 키를 HMAC 생성기에 붙여 넣으면 어느 쪽이 틀렸는지 알 수 있습니다.