웹훅 서명 검증

웹훅 서명 검증4분 분량

Node.js에서 웹훅 서명 검증하기

Node.js에서 Ikiform 웹훅 서명을 검증하세요. Express와 Fastify의 원본 본문, SDK, standardwebhooks 패키지, 테스트된 node:crypto 버전을 다룹니다.

Preet SutharIkiform 창업자

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 파서를 거치는 웹훅 본문과 라우트의 원본 파서를 거치는 본문 비교전역 express.json()이 먼저웹훅 라우트의 express.raw()원본 바이트Ikiform이 서명한 것JSON.parse미들웨어에서JSON.stringify다른 바이트400no_matching_signature원본 바이트Ikiform이 서명한 것Buffer 그대로손대지 않음검증 후 파싱204전역 JSON 파서를 거치는 웹훅 본문과 라우트의 원본 파서를 거치는 본문 비교전역 express.json()이 먼저웹훅 라우트의 express.raw()원본 바이트Ikiform이 서명한 것JSON.parse미들웨어에서JSON.stringify다른 바이트400no_matching_signature원본 바이트Ikiform이 서명한 것Buffer 그대로손대지 않음검증 후 파싱204
  1. 전역 JSON 파서가 먼저 등록되어 있으면 서명된 바이트가 미들웨어에서 객체로 파싱됩니다.
  2. 그 객체를 다시 직렬화하면 다른 문자열이 되므로 서명이 일치할 수 없고 핸들러는 400으로 응답합니다.
  3. 웹훅 라우트에 원본 파서가 있으면 핸들러가 정확한 바이트를 Buffer로 받습니다.
  4. 그 바이트를 검증하고, 그 뒤에야 파싱하고 204로 응답합니다.
순서가 중요합니다. Express는 등록한 순서대로 미들웨어를 실행하므로 웹훅 라우트가 먼저 와야 합니다.

Express

server.js
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

server.js
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를 던집니다.

server.js
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를 직접 해시하므로 문자열 인코딩 문제가 생기지 않습니다.

verify.ts
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 파일을 직접 실행합니다.

verify.test.ts
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 이상의 옵션 이름을 씁니다.

deliveries.js
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를 먼저 기록하면 중간에 충돌했을 때 재시도도 건너뜁니다. 재시도를 원한다면 작업이 성공한 뒤에만 기록하세요.

작동 확인하기

  1. node --test를 실행하세요. 네 경우가 모두 통과합니다.
  2. 예를 들어 cloudflared tunnel --url http://localhost:3000으로 서버를 공개 https: URL에 노출하고, 대시보드의 Webhooks에 추가하세요.
  3. IKIFORM_WEBHOOK_SECRET을 엔드포인트의 whsec_ 시크릿으로 설정하고 서버를 재시작하세요.
  4. Send test를 고르세요. 전달 로그에 204가 표시됩니다.
  5. 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보다 낮습니다. 웹훅 라우트에서 높이세요

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.