Verifikasi tanda tangan webhook5 menit baca
Verifikasi tanda tangan webhook di Node.js
Verifikasi tanda tangan webhook Ikiform di Node.js: body mentah di Express dan Fastify, SDK, paket standardwebhooks, dan versi node:crypto yang sudah diuji.
Dapatkan body request sebagai byte mentah sebelum parser JSON apa pun berjalan, lalu berikan bersama header dan secret whsec_ Anda ke verifyWebhook dari @ikiform/sdk/webhooks. Di Express itu berarti express.raw() di route webhook; di Fastify, parser content-type string yang dibatasi pada route itu. Tanpa SDK, createHmac dan timingSafeEqual dari node:crypto melakukan hal yang sama dalam sekitar 20 baris.
Halaman ini membahas server Node yang berjalan lama: Express dan Fastify, ditambah versi tanpa dependency yang bisa Anda uji dengan unit test. Untuk route handler di Next.js, Astro, dan framework lain, lihat ringkasan; untuk Cloudflare Worker dengan penyimpanan, lihat server Anda sendiri.
Apa yang berbeda di Node
Framework Node sangat membantu soal JSON. Daftarkan express.json() atau pakai default Fastify dan setiap handler mendapat req.body sebagai objek. Saat kode webhook Anda berjalan, byte yang ditandatangani Ikiform sudah hilang, dan JSON.stringify(req.body) menghasilkan byte yang berbeda: spasi lain, escaping lain untuk karakter seperti / atau emoji. Solusinya ada pada di mana Anda mendapatkan body, bukan cara Anda meng-hash-nya.
- Dengan parser JSON global yang didaftarkan lebih dulu, byte yang ditandatangani di-parse menjadi objek di middleware.
- Menserialisasi ulang objek itu menghasilkan string berbeda, jadi tanda tangan tidak bisa cocok dan handler menjawab 400.
- Dengan parser mentah di route webhook, handler menerima byte persisnya sebagai Buffer.
- Handler memverifikasi byte itu, baru mem-parsing-nya sesudahnya, dan menjawab 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" })hanya di route ini memberireq.bodysebagaiBuffer.express.json()global yang didaftarkan sesudahnya tidak pernah melihat request ini, karena route sudah menjawab.limitdiatur di atas batas payload Ikiform 256 KB. Default Express 100 KB akan menolak kiriman besar dengan413.verifyWebhookmenerimareq.headersNode apa adanya; fungsi ini mencari header tanpa memedulikan huruf besar-kecil dan memeriksa timestamp, setiap tanda tanganv1, dan JSON-nya.- Di Express 5, error yang dilempar dari handler async diteruskan ke error middleware, jadi melempar ulang aman.
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 mem-parsing application/json sendiri. Mendaftarkan parser dengan parseAs: "string" di dalam register menggantinya hanya untuk route di plugin itu, berkat enkapsulasi, jadi bagian lain API Anda tetap mendapat objek yang sudah di-parse. request.body lalu menjadi string persis yang dikirim Ikiform.
Dengan paket standardwebhooks
Jika Anda lebih suka memakai library referensi spesifikasinya, jalankan npm install standardwebhooks. Class Webhook-nya menghapus awalan whsec_ sendiri dan menerima body Buffer. Class ini sinkron dan melempar WebhookVerificationError beserta pesannya:
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 hanya memakai Web Crypto, jadi butuh Node 20 atau lebih baru. Paket standardwebhooks membawa SHA-256 sendiri dan juga berjalan di versi lama.Dengan node:crypto dan test
Tanpa dependency, dan dengan nilai kembalian yang menjelaskan mengapa request gagal. Kode meng-hash Buffer mentah secara langsung, jadi tidak ada persoalan encoding string:
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";
}timingSafeEqualmelempar error saat kedua buffer berbeda panjang, jadi pemeriksaan panjang dilakukan lebih dulu. Tanda tangan dengan panjang yang salah bukanlah kecocokan.createHmac(...).update()dua kali meng-hash awalan dan body sebagai satu stream, sama seperti meng-hash keduanya setelah digabung.- Node mengubah nama header yang masuk menjadi huruf kecil, jadi
headers["webhook-id"]langsung berfungsi direq.headers.
Dan test dengan test vector. Jalankan dengan node --test; Node 22.18 dan yang lebih baru menjalankan file TypeScript secara langsung:
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 yang tetap menjaga timestamp 2026 tetap di dalam jendela 5 menit. Kasus yang diformat rapi adalah yang akan menangkap parser yang diam-diam ditambahkan nanti, jadi pertahankan meski kasus lain terasa jelas.
Melewati pengulangan
Pengiriman yang terverifikasi tetap bisa tiba dua kali: jika handler Anda selesai tetapi jawabannya hilang atau butuh lebih dari 15 detik, Ikiform mencoba ulang dengan webhook-id yang sama. Di layanan Node yang sudah punya Redis, satu SET dengan kondisi NX mengingat setiap id. Ini memakai nama opsi node-redis 5 dan yang lebih baru:
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";
}Panggil await firstDelivery(event.id) setelah verifikasi dan sebelum mengerjakan apa pun; jika mengembalikan false, jawab 204 dan berhenti. Masa kedaluwarsa 4 hari melampaui jendela percobaan ulang Ikiform yang sekitar tiga hari, jadi percobaan ulang yang terlambat tetap dikenali. Tanpa Redis, kolom unik di database Anda melakukan tugas yang sama. Mencatat id lebih dulu berarti crash di tengah jalan juga akan melewati percobaan ulang; jika Anda lebih suka mencoba ulang, catat id hanya setelah pekerjaan berhasil.
Pastikan berfungsi
- Jalankan
node --test. Keempat kasus lolos. - Ekspos server di URL
https:publik, misalnya dengancloudflared tunnel --url http://localhost:3000, dan tambahkan di Webhooks di dashboard. - Atur
IKIFORM_WEBHOOK_SECRETke secretwhsec_milik endpoint dan restart server. - Pilih Send test. Log pengiriman menampilkan
204. - Pindahkan
app.use(express.json())ke atas route webhook dan kirim uji lagi. Uji gagal dengan400 no_matching_signature, yang membuktikan test akan menangkap kesalahan itu. Kembalikan ke posisinya.
Hal yang sering menjebak
| Masalah | Penyebab dan solusi |
|---|---|
req.body bernilai undefined atau {} | type parser mentah tidak cocok dengan Content-Type request, jadi body dilewati. Ikiform mengirim application/json |
TypeScript menyatakan Buffer tidak bisa di-assign ke string | verifyWebhook menerima body sebagai string. Berikan req.body.toString("utf8") |
| Berhasil secara lokal, gagal di host serverless | Platform mem-parsing body untuk Anda. Di Pages Router Next.js, atur bodyParser: false di config route dan baca stream-nya sendiri |
RangeError: Input buffers must have the same byte length | timingSafeEqual dengan tanda tangan yang cacat. Periksa panjang sebelum membandingkan |
413 Payload Too Large di log pengiriman | Batas body parser di bawah 256 KB. Naikkan di route webhook |
Pertanyaan
Tambahkan express.raw({ type: "application/json" }) sebagai middleware di route webhook, yang memberi req.body sebagai Buffer. Daftarkan route sebelum express.json() global, atau parser JSON berjalan lebih dulu.
Fungsi itu butuh dua buffer dengan panjang byte yang sama dan melempar RangeError jika tidak. Bandingkan panjang lebih dulu dan anggap ketidakcocokan sebagai tanda tangan yang gagal.
Bisa. Ikiform mengikuti Standard Webhooks, jadi new Webhook(secret).verify(body, headers) berfungsi dengan secret whsec_ dari dashboard. Fungsi itu melempar WebhookVerificationError saat pemeriksaan gagal.
Node 20 dan yang lebih baru, ditambah Cloudflare Workers, Deno, dan Bun. verifyWebhook hanya memakai Web Crypto API.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.