Webhooks
수신한 webhook payload의 서명을 검증하는 방법을 설명합니다.
webhook endpoint는 인터넷에 열려 있는 URL이고, body가 그럴듯하다는 사실은 아무것도 증명하지 않습니다. 서명이 검증되지 않은 payload는 위조, 변조, 재전송 어느 쪽도 배제할 수 없으므로, 모든 payload는 처리하기 전에 서명부터 검증합니다. 검증에 쓰는 endpoint secret은 whsec_ 접두사로 시작하며, endpoint를 등록할 때 콘솔에 한 번만 표시됩니다.
모든 전달에는 Frism-Signature 헤더가 붙습니다. 값은 ,로 구분된 key=value 쌍입니다.
POST /webhooks/frism HTTP/1.1
Host: example.com
Content-Type: application/json
Frism-Event-Id: evt_01J8QH5R7T
Frism-Signature: t=1788257727,v2=d7e16839f487db479fa277fb11c2d6139225ea837c672df8a68e0452fd7ea772,v1=59ba753f14e68fd9aff609e623a55afd67db964cc881d66cb7aa09e3ef65ad58| 부분 | 의미 |
|---|---|
t | 서명이 생성된 시각입니다. unix 초 단위이며, 5분보다 오래된 이벤트는 거부합니다. |
v2 | endpoint secret으로 <t>.<raw body> 문자열을 HMAC-SHA256 서명한 hex digest입니다. 검증 대상은 이 값입니다. |
v1 | raw body만 서명한 legacy digest입니다. 2026-09-30까지만 함께 전송되며, 새 코드에서는 사용하지 않습니다. |
v1은 timestamp를 포함하지 않아 가로챈 payload를 나중에 재전송해도 그대로 통과합니다. 항상 v2만 신뢰하고, v1 전송 중단 일정은 changelog에서 확인합니다.
Frism-Signature 헤더를 ,로 나누어 t와 v2를 파싱합니다.t가 현재 시각 기준 5분 이내인지 확인합니다. 벗어나면 거부합니다.<t>.<raw body>를 만들고, endpoint secret으로 HMAC-SHA256 digest를 계산합니다. JSON.parse 후 재직렬화한 body가 아니라 도착한 바이트 그대로여야 합니다.v2를 timing-safe 방식으로 비교합니다. 일치할 때만 JSON을 파싱하고 처리를 시작합니다.digest는 t와 body를 .로 이어붙인 문자열 위에서 계산되므로, timestamp와 body 중 어느 하나만 바꿔도 서명이 깨집니다. 이 결합이 재전송 공격을 막는 핵심입니다. t 검사에 5분의 여유를 두는 것은 전달 지연과 시계 오차를 흡수하기 위해서이므로, 서버 시계는 NTP로 맞춰 둡니다.
Node.js 내장 node:crypto만으로 충분합니다. 외부 라이브러리는 필요하지 않습니다.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyFrismSignature(
rawBody: string,
header: string,
secret: string,
): boolean {
// 1. 헤더를 key=value 쌍으로 파싱합니다.
const parts = new Map<string, string>();
for (const pair of header.split(",")) {
const index = pair.indexOf("=");
if (index > 0) parts.set(pair.slice(0, index), pair.slice(index + 1));
}
const t = parts.get("t");
const v2 = parts.get("v2");
if (t === undefined || v2 === undefined) return false;
// 2. timestamp가 5분 이내인지 확인합니다.
const timestamp = Number(t);
if (!Number.isInteger(timestamp)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
// 3. "<t>.<raw body>"에 대한 기대 digest를 계산합니다.
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest();
// 4. timing-safe 비교. 길이가 다르면 timingSafeEqual이 던지므로 먼저 거릅니다.
const received = Buffer.from(v2, "hex");
if (received.length !== expected.length) return false;
return timingSafeEqual(expected, received);
}
export async function handleFrismEvent(request: Request): Promise<Response> {
// raw body를 먼저 확보합니다. JSON.parse는 검증에 성공한 뒤에만 합니다.
const rawBody = request.text();
header = request.headers.get() ?? ;
secret = process.env.FRISM_WEBHOOK_SECRET ?? ;
(!verifyFrismSignature(rawBody, header, secret)) {
Response(null, { status: });
}
event = JSON.parse(rawBody) as { id: string; : string };
console.log(`verified ${event.} (${event.id})`);
Response(null, { status: });
}| 실수 | 무엇이 잘못되는가 |
|---|---|
검증 전에 JSON.parse | 파싱 후 재직렬화한 문자열은 공백과 키 순서가 달라져 digest가 절대 일치하지 않습니다. 검증되지 않은 데이터를 만지는 것 자체도 위험합니다. 항상 raw body 문자열로 검증합니다. |
===로 digest 비교 | 일반 문자열 비교는 불일치 위치에 따라 소요 시간이 달라져 timing 정보를 흘립니다. timingSafeEqual을 사용합니다. |
v2 대신 v1 검증 | v1은 timestamp가 없어 오래된 payload의 재전송을 막지 못하고, 2026-09-30 이후에는 전송 자체가 중단됩니다. |
400을 반환하고 payload는 처리하지 않습니다. 검증 실패에 2xx를 반환하면 APIFuse은 전달이 성공한 것으로 기록하고 재시도를 멈추므로, 실패는 반드시 4xx로 알립니다. 로그에는 Frism-Event-Id와 실패 사유만 남기고 body 내용은 신뢰하지 않습니다. 배포 실수로 정상 이벤트를 거부하게 되더라도 APIFuse이 하므로, 검증 코드를 고치면 이벤트는 다시 도착합니다.
openssl로 직접 서명한 요청을 보내면 배포 전에 검증 코드를 확인할 수 있습니다. 아래 body는 를 담은 reservation.confirmed 이벤트입니다. 스크립트의 T나 BODY를 바꿔 가며 핸들러가 불일치 digest와 오래된 timestamp를 모두 거부하는지도 확인해 봅니다.
SECRET="whsec_mJq82LkabQfw" # 콘솔에서 발급받은 endpoint secret
BODY='{"id":"evt_01J8QH5R7T","type":"reservation.confirmed","created_at":"2026-09-01T10:15:27Z","data":{"object":{"id":"rsv_01J8QDGT2K","status":"confirmed","restaurant_id":"res_01J8Q9W3TE","starts_at":"2026-09-02T19:00:00+09:00","party_size":2}}}'
T=$(date +%s)
V2=$(printf "%s.%s" "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/frism \
-H "Content-Type: application/json" \
-H "Frism-Event-Id: evt_01J8QH5R7T" \
-H "Frism-Signature: t=$T,v2=$V2" \
--data "$BODY"