웹훅 서명 검증4분 분량
Node.js에서 웹훅 서명 검증하기
Node.js에서 Ikiform 웹훅 서명을 검증하세요. Express와 Fastify의 원본 본문, SDK, standardwebhooks 패키지, 테스트된 node:crypto 버전을 다룹니다.
JSON 파서가 실행되기 전에 요청 본문을 원본 바이트로 받은 뒤, 헤더와 whsec_ 시크릿과 함께 @ikiform/sdk/webhooks의 verifyWebhook에 넘기세요. Express에서는 웹훅 라우트에 express.raw()를, Fastify에서는 그 라우트에 한정한 문자열 콘텐츠 타입 파서를 씁니다. SDK 없이도 node:crypto의 createHmac과 timingSafeEqual로 약 20줄이면 같은 일을 합니다.
이 페이지는 오래 실행되는 Node 서버를 다룹니다. Express와 Fastify, 그리고 단위 테스트할 수 있는 의존성 없는 버전입니다. Next.js, Astro 등 프레임워크의 라우트 핸들러는 개요를, 저장소가 있는 Cloudflare Worker는 자체 서버를 참고하세요.
Node에서 다른 점
Node 프레임워크는 JSON을 친절하게 처리합니다. express.json()을 등록하거나 Fastify 기본값을 쓰면 모든 핸들러가 req.body를 객체로 받습니다. 웹훅 코드가 실행될 즈음에는 Ikiform이 서명한 바이트가 사라졌고, JSON.stringify(req.body)는 다른 바이트를 만듭니다. 공백이 다르고, /나 이모지 같은 문자의 이스케이프가 다릅니다. 해결책은 해시하는 방법이 아니라 본문을 얻는 위치에 있습니다.
- 전역 JSON 파서가 먼저 등록되어 있으면 서명된 바이트가 미들웨어에서 객체로 파싱됩니다.
- 그 객체를 다시 직렬화하면 다른 문자열이 되므로 서명이 일치할 수 없고 핸들러는 400으로 응답합니다.
- 웹훅 라우트에 원본 파서가 있으면 핸들러가 정확한 바이트를 Buffer로 받습니다.
- 그 바이트를 검증하고, 그 뒤에야 파싱하고 204로 응답합니다.
Express
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const app = express();
app.post("/webhooks/ikiform", express.raw({ type: "application/json", limit: "300kb" }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body.toString("utf8"),
headers: req.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET,
});
if (event.type === "submission.created") {
console.info("submission", event.data.submission_id, event.data.fields);
}
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
throw error;
}
});
app.use(express.json());
app.listen(3000);- 이 라우트에만 둔
express.raw({ type: "application/json" })는req.body를Buffer로 줍니다. 그 뒤에 등록한 전역express.json()은 라우트가 이미 응답했으므로 이 요청을 보지 못합니다. limit은 Ikiform의 256 KB 페이로드 상한보다 높게 잡았습니다. Express 기본값 100 KB는 큰 제출을413으로 거부합니다.verifyWebhook은 Node의req.headers를 그대로 받습니다. 대소문자 구분 없이 헤더를 조회하고 타임스탬프, 모든v1서명, JSON을 확인합니다.- Express 5에서는 비동기 핸들러에서 던진 오류가 오류 미들웨어로 가므로 다시 던져도 안전합니다.
Fastify
import Fastify from "fastify";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const app = Fastify({ logger: true });
app.register(async (webhooks) => {
webhooks.addContentTypeParser(
"application/json",
{ parseAs: "string", bodyLimit: 300 * 1024 },
(_request, body, done) => done(null, body),
);
webhooks.post("/webhooks/ikiform", async (request, reply) => {
try {
const event = await verifyWebhook({
payload: request.body,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET,
});
request.log.info({ type: event.type, id: event.id }, "ikiform webhook");
return reply.code(204).send();
} catch (error) {
if (error instanceof WebhookVerificationError) return reply.code(400).send(error.reason);
throw error;
}
});
});
await app.listen({ port: 3000, host: "0.0.0.0" });Fastify는 application/json을 직접 파싱합니다. register 안에서 parseAs: "string"으로 파서를 등록하면 캡슐화 덕분에 그 플러그인의 라우트에서만 대체되므로, 나머지 API는 계속 파싱된 객체를 받습니다. 그러면 request.body는 Ikiform이 보낸 정확한 문자열입니다.
standardwebhooks 패키지 사용하기
명세의 레퍼런스 라이브러리를 쓰고 싶다면 npm install standardwebhooks를 실행하세요. Webhook 클래스는 whsec_ 접두사를 직접 떼고 Buffer 본문을 받습니다. 동기식이며 메시지와 함께 WebhookVerificationError를 던집니다.
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";
const webhook = new Webhook(process.env.IKIFORM_WEBHOOK_SECRET);
const app = express();
app.post("/webhooks/ikiform", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = webhook.verify(req.body, req.headers);
console.info(event.type, event.id);
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
throw error;
}
});@ikiform/sdk는 Web Crypto만 쓰므로 Node 20 이상이 필요합니다. standardwebhooks 패키지는 자체 SHA-256을 포함하므로 이전 버전에서도 실행됩니다.node:crypto와 테스트 사용하기
의존성이 없고, 요청이 실패한 이유를 알려 주는 반환값이 있습니다. 원본 Buffer를 직접 해시하므로 문자열 인코딩 문제가 생기지 않습니다.
import { createHmac, timingSafeEqual } from "node:crypto";
export type VerifyFailure =
| "missing_headers"
| "invalid_timestamp"
| "timestamp_too_old"
| "timestamp_too_new"
| "no_matching_signature";
type Headers = Record<string, string | string[] | undefined>;
const TOLERANCE_SECONDS = 5 * 60;
function header(headers: Headers, name: string) {
const value = headers[name];
return Array.isArray(value) ? value.join(" ") : value;
}
export function verifySignature(
rawBody: Buffer | string,
headers: Headers,
secret: string,
now = Date.now(),
): VerifyFailure | null {
const id = header(headers, "webhook-id");
const timestamp = header(headers, "webhook-timestamp");
const signatures = header(headers, "webhook-signature");
if (!id || !timestamp || !signatures) return "missing_headers";
if (!/^\d{1,12}$/.test(timestamp)) return "invalid_timestamp";
const age = Math.floor(now / 1000) - Number(timestamp);
if (age > TOLERANCE_SECONDS) return "timestamp_too_old";
if (age < -TOLERANCE_SECONDS) return "timestamp_too_new";
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();
for (const entry of signatures.split(" ")) {
const [version, value] = entry.split(",", 2);
if (version !== "v1" || !value) continue;
const received = Buffer.from(value, "base64");
if (received.length === expected.length && timingSafeEqual(received, expected)) return null;
}
return "no_matching_signature";
}timingSafeEqual은 두 버퍼의 길이가 다르면 예외를 던지므로 길이 확인이 먼저 옵니다. 길이가 틀린 서명은 불일치입니다.createHmac(...).update()를 두 번 호출하면 접두부와 본문을 하나의 스트림으로 해시하며, 둘을 이어서 해시한 것과 같습니다.- Node는 들어오는 헤더 이름을 소문자로 바꾸므로
headers["webhook-id"]가req.headers에서 바로 동작합니다.
그리고 테스트 벡터를 쓰는 테스트입니다. node --test로 실행하세요. Node 22.18 이상은 TypeScript 파일을 직접 실행합니다.
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifySignature } from "./verify.ts";
const secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=";
const rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=";
const body =
'{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}';
const headers = {
"webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
"webhook-timestamp": "1792300000",
"webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
};
const now = 1792300000 * 1000 + 30_000;
test("accepts the test vector", () => {
assert.equal(verifySignature(Buffer.from(body), headers, secret, now), null);
});
test("accepts either secret during a roll", () => {
const both = { ...headers, "webhook-signature": `${headers["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` };
assert.equal(verifySignature(body, both, secret, now), null);
assert.equal(verifySignature(body, both, rolled, now), null);
});
test("rejects a re-serialized body", () => {
const pretty = JSON.stringify(JSON.parse(body), null, 2);
assert.equal(verifySignature(pretty, headers, secret, now), "no_matching_signature");
});
test("rejects a stale timestamp", () => {
assert.equal(verifySignature(body, headers, secret, now + 10 * 60_000), "timestamp_too_old");
});고정된 now가 2026년 타임스탬프를 5분 범위 안에 둡니다. 보기 좋게 출력한 경우는 나중에 몰래 끼어든 파서를 잡아내는 테스트이니, 다른 경우가 뻔해 보여도 유지하세요.
반복 건너뛰기
검증된 전달도 두 번 도착할 수 있습니다. 핸들러가 끝났는데 응답이 사라지거나 15초보다 오래 걸리면 Ikiform이 같은 webhook-id로 재시도합니다. 이미 Redis가 있는 Node 서비스라면 NX 조건을 붙인 SET 한 번으로 각 id를 기억합니다. 여기서는 node-redis 5 이상의 옵션 이름을 씁니다.
import { createClient } from "redis";
const redis = await createClient({ url: process.env.REDIS_URL }).connect();
export async function firstDelivery(deliveryId) {
const stored = await redis.set(`ikiform:delivery:${deliveryId}`, "1", {
condition: "NX",
expiration: { type: "EX", value: 4 * 24 * 60 * 60 },
});
return stored === "OK";
}검증한 뒤, 어떤 작업도 하기 전에 await firstDelivery(event.id)를 호출하세요. false를 반환하면 204로 응답하고 멈추세요. 4일 만료는 약 사흘인 Ikiform의 재시도 기간보다 길므로 늦은 재시도도 알아봅니다. Redis가 없다면 데이터베이스의 고유 열이 같은 일을 합니다. id를 먼저 기록하면 중간에 충돌했을 때 재시도도 건너뜁니다. 재시도를 원한다면 작업이 성공한 뒤에만 기록하세요.
작동 확인하기
node --test를 실행하세요. 네 경우가 모두 통과합니다.- 예를 들어
cloudflared tunnel --url http://localhost:3000으로 서버를 공개https:URL에 노출하고, 대시보드의 Webhooks에 추가하세요. IKIFORM_WEBHOOK_SECRET을 엔드포인트의whsec_시크릿으로 설정하고 서버를 재시작하세요.- Send test를 고르세요. 전달 로그에
204가 표시됩니다. app.use(express.json())을 웹훅 라우트 위로 옮기고 테스트를 다시 보내세요.400 no_matching_signature로 실패하며, 테스트가 그 실수를 잡아낸다는 증거입니다. 원래대로 되돌리세요.
주의할 점
| 문제 | 원인과 해결 |
|---|---|
req.body가 undefined이거나 {}임 | 원본 파서의 type이 요청의 Content-Type과 맞지 않아 본문을 건너뛰었습니다. Ikiform은 application/json을 보냅니다 |
TypeScript가 Buffer를 string에 할당할 수 없다고 함 | verifyWebhook은 본문을 문자열로 받습니다. req.body.toString("utf8")을 넘기세요 |
| 로컬에서는 되지만 서버리스 호스트에서 실패함 | 플랫폼이 본문을 대신 파싱했습니다. Next.js Pages Router에서는 라우트의 config에 bodyParser: false를 설정하고 스트림을 직접 읽으세요 |
RangeError: Input buffers must have the same byte length | 형식이 잘못된 서명에 대한 timingSafeEqual입니다. 비교하기 전에 길이를 확인하세요 |
전달 로그에 413 Payload Too Large | 본문 파서의 한도가 256 KB보다 낮습니다. 웹훅 라우트에서 높이세요 |
질문
웹훅 라우트에 express.raw({ type: "application/json" })를 미들웨어로 추가하면 req.body를 Buffer로 받습니다. 전역 express.json()보다 먼저 라우트를 등록하세요. 그렇지 않으면 JSON 파서가 먼저 실행됩니다.
바이트 길이가 같은 두 버퍼를 요구하며, 그렇지 않으면 RangeError를 던집니다. 길이를 먼저 비교하고 불일치는 실패한 서명으로 취급하세요.
네. Ikiform은 Standard Webhooks를 따르므로 대시보드의 whsec_ 시크릿으로 new Webhook(secret).verify(body, headers)가 동작합니다. 확인에 실패하면 WebhookVerificationError를 던집니다.
Node 20 이상과 Cloudflare Workers, Deno, Bun입니다. verifyWebhook은 Web Crypto API만 씁니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.