MaruPay 웹훅 서명 검증하기
MaruPay-Signature 헤더가 어떻게 만들어지는지, 원본 본문으로 검증하는 방법, 가장 자주 보는 실수.
MaruPay가 보내는 모든 웹훅에는 서명이 붙습니다. 주문을 반영하기 전에 처리 코드가 반드시 해야 하는 일이 이 서명 확인입니다. 이벤트가 MaruPay에서 왔고, 오는 길에 아무도 바꾸지 않았다는 증거니까요.
헤더에 담긴 것
MaruPay-Signature 헤더는 두 부분입니다. t는 이벤트에 서명한 Unix 시각, v1은 t + "." + 원본 요청 본문 문자열을 엔드포인트 서명 비밀키(whsec_…)로 계산한 HMAC-SHA256 16진수 값입니다.
MaruPay-Signature: t=1790656319,v1=e2c97b…0f5a
검증 순서
- JSON 파서가 건드리기 전에 원본 본문을 읽으세요. 파싱한 JSON을 다시 직렬화하면 공백과 키 순서가 바뀌어 서명이 맞지 않습니다.
- 서명 비밀키로 t + "." + rawBody의 HMAC-SHA256을 계산합니다.
- crypto.timingSafeEqual 같은 상수 시간 비교 함수로 비교합니다.
- 서버 시각과 5분 넘게 차이 나는 타임스탬프는 거절해, 가로챈 요청을 나중에 재사용하지 못하게 합니다.
import crypto from "node:crypto";
// header: "t=<unix>,v1=<hex>" body: the raw request body
export function verify(body: string, header: string, secret: string) {
const [, t, v1] = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header) ?? [];
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
return fresh && crypto.timingSafeEqual(
Buffer.from(v1, "hex"),
Buffer.from(expected, "hex"),
);
}
// Retried deliveries reuse the event ID: credit each one once.
if (await processed.has(event.id)) return res.status(200).end();
서명 다음에 할 일
서명이 맞다는 건 이벤트가 MaruPay에서 왔다는 뜻이지, 처음 받는 이벤트라는 뜻은 아닙니다. 엔드포인트가 2xx로 응답하지 않으면 다시 보내고, 콘솔에서 어떤 이벤트든 재전송할 수 있으니 같은 이벤트가 여러 번 올 수 있습니다. 이벤트 ID를 주문 변경과 한 트랜잭션으로 저장하고, 이미 반영한 ID에는 200을 돌려주세요.
비밀키 교체
콘솔에서 새 서명 비밀키를 만드세요. 24시간 동안 두 비밀키가 모두 이벤트에 서명하니, 새 키를 배포한 뒤 이전 키를 폐기해도 전달이 실패하지 않습니다.