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

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

2026-10-25 게시 · https://ikiform.com/ko/guides/webhook-signature-verification/nodejs

JSON 파서가 실행되기 전에 요청 본문을 원본 바이트로 받은 뒤, 헤더와 `whsec_` 시크릿과 함께 `@ikiform/sdk/webhooks`의 `verifyWebhook`에 넘기세요. Express에서는 웹훅 라우트에 `express.raw()`를, Fastify에서는 그 라우트에 한정한 문자열 콘텐츠 타입 파서를 씁니다. SDK 없이도 `node:crypto`의 `createHmac`과 `timingSafeEqual`로 약 20줄이면 같은 일을 합니다.

이 페이지는 오래 실행되는 Node 서버를 다룹니다. Express와 Fastify, 그리고 단위 테스트할 수 있는 의존성 없는 버전입니다. Next.js, Astro 등 프레임워크의 라우트 핸들러는 [개요](https://ikiform.com/ko/guides/webhook-signature-verification.md#implementation)를, 저장소가 있는 Cloudflare Worker는 [자체 서버](https://ikiform.com/blog/form-submissions-to-your-own-server.md)를 참고하세요.

## Node에서 다른 점

Node 프레임워크는 JSON을 친절하게 처리합니다. `express.json()`을 등록하거나 Fastify 기본값을 쓰면 모든 핸들러가 `req.body`를 객체로 받습니다. 웹훅 코드가 실행될 즈음에는 Ikiform이 서명한 바이트가 사라졌고, `JSON.stringify(req.body)`는 다른 바이트를 만듭니다. 공백이 다르고, `/`나 이모지 같은 문자의 이스케이프가 다릅니다. 해결책은 해시하는 방법이 아니라 본문을 얻는 위치에 있습니다.

다이어그램: 전역 JSON 파서를 거치는 웹훅 본문과 라우트의 원본 파서를 거치는 본문 비교

1. 전역 JSON 파서가 먼저 등록되어 있으면 서명된 바이트가 미들웨어에서 객체로 파싱됩니다.
2. 그 객체를 다시 직렬화하면 다른 문자열이 되므로 서명이 일치할 수 없고 핸들러는 400으로 응답합니다.
3. 웹훅 라우트에 원본 파서가 있으면 핸들러가 정확한 바이트를 Buffer로 받습니다.
4. 그 바이트를 검증하고, 그 뒤에야 파싱하고 204로 응답합니다.

순서가 중요합니다. Express는 등록한 순서대로 미들웨어를 실행하므로 웹훅 라우트가 먼저 와야 합니다.

## Express

`server.js`:

```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`:

```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`:

```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`:

```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`에서 바로 동작합니다.

그리고 [테스트 벡터](https://ikiform.com/ko/guides/webhook-signature-verification.md#test-vector)를 쓰는 테스트입니다. `node --test`로 실행하세요. Node 22.18 이상은 TypeScript 파일을 직접 실행합니다.

`verify.test.ts`:

```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`:

```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보다 낮습니다. 웹훅 라우트에서 높이세요 |

## 질문

### Express에서 웹훅용 원본 본문은 어떻게 얻나요?

웹훅 라우트에 express.raw({ type: "application/json" })를 미들웨어로 추가하면 req.body를 Buffer로 받습니다. 전역 express.json()보다 먼저 라우트를 등록하세요. 그렇지 않으면 JSON 파서가 먼저 실행됩니다.

### crypto.timingSafeEqual이 왜 예외를 던지나요?

바이트 길이가 같은 두 버퍼를 요구하며, 그렇지 않으면 RangeError를 던집니다. 길이를 먼저 비교하고 불일치는 실패한 서명으로 취급하세요.

### Ikiform과 standardwebhooks npm 패키지를 함께 쓸 수 있나요?

네. Ikiform은 Standard Webhooks를 따르므로 대시보드의 whsec_ 시크릿으로 new Webhook(secret).verify(body, headers)가 동작합니다. 확인에 실패하면 WebhookVerificationError를 던집니다.

### @ikiform/sdk는 웹훅에 어떤 Node 버전을 지원하나요?

Node 20 이상과 Cloudflare Workers, Deno, Bun입니다. verifyWebhook은 Web Crypto API만 씁니다.

## 가이드 더 보기

- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [Python](https://ikiform.com/ko/guides/webhook-signature-verification/python.md): standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.
- [Go net/http 사용](https://ikiform.com/ko/guides/webhook-signature-verification/go.md): Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, crypto/hmac으로 확인하고, 테스트하거나, Standard Webhooks 라이브러리를 쓰세요.
- [Ruby](https://ikiform.com/ko/guides/webhook-signature-verification/ruby.md): Sinatra에서는 OpenSSL과 Minitest 테스트로, Rails에서는 standardwebhooks gem과 request.raw_post로 Ikiform 웹훅 서명을 검증하세요.
- [PHP](https://ikiform.com/ko/guides/webhook-signature-verification/php.md): 일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.
