Signature HMAC webhook harus diverifikasi menggunakan byte request body yang diterima persis dari jaringan, sebelum body diubah menjadi objek JSON. Jika body lebih dahulu diparse lalu diserialisasi ulang, perubahan spasi, urutan properti, karakter Unicode, atau newline dapat menghasilkan HMAC berbeda meskipun data JSON terlihat sama.
Implementasi yang aman membaca body satu kali sebagai ArrayBuffer, menggabungkannya dengan timestamp sesuai kontrak penyedia webhook, lalu memverifikasi signature melalui Web Crypto API. Setelah signature dan batas waktu lolos, barulah body didekode dan diparse sebagai JSON.
Kontrak signature yang harus dipastikan
Setiap penyedia webhook dapat memakai format berbeda. Contoh dalam artikel ini mengasumsikan kontrak berikut:
- Header signature:
x-webhook-signature: sha256=<hex>. - Header waktu:
x-webhook-timestamp: 1710000000dalam detik Unix. - Algoritma: HMAC-SHA-256.
- Pesan yang ditandatangani:
timestamp + "." + rawBody. - Secret diperlakukan sebagai byte UTF-8 biasa.
Ikuti dokumentasi penyedia secara tepat. Ada penyedia yang menandatangani raw body saja, menggunakan Base64 alih-alih hex, mengirim beberapa signature untuk rotasi secret, atau memberikan secret yang harus didekode dari Base64.
Mengapa JSON tidak boleh diparse lebih dahulu?
Misalnya penyedia mengirim body berikut:
{"event_id":"evt_123", "amount":100}
Setelah JSON.parse() dan JSON.stringify(), hasilnya mungkin menjadi:
{"event_id":"evt_123","amount":100}
Kedua string mewakili nilai JSON yang sama, tetapi byte-nya berbeda karena satu spasi hilang. HMAC bekerja atas byte, bukan makna JSON, sehingga verifikasi akan gagal. Menggunakan request.arrayBuffer() juga lebih presisi daripada membaca teks lalu melakukan encoding ulang.
Implementasi TypeScript/Deno di Supabase Edge Functions
Simpan secret sebagai environment secret, bukan di source code atau tabel yang dapat dibaca aplikasi klien. Endpoint pihak ketiga biasanya tidak membawa JWT Supabase, sehingga pemeriksaan JWT platform untuk fungsi tersebut perlu dinonaktifkan sesuai metode deployment yang digunakan. Dalam kondisi ini, HMAC menjadi mekanisme autentikasi request.
Contoh berikut membaca raw body satu kali, memvalidasi timestamp, memverifikasi HMAC dengan Web Crypto API, kemudian menyimpan event secara idempoten:
import { createClient } from "npm:@supabase/supabase-js";
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
const MAX_AGE_SECONDS = 300;
type WebhookEvent = {
event_id: string;
type?: string;
[key: string]: unknown;
};
function jsonResponse(
status: number,
body: Record<string, unknown>,
extraHeaders: Record<string, string> = {},
): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
...extraHeaders,
},
});
}
function decodeHexSignature(header: string): Uint8Array | null {
const value = header.startsWith("sha256=")
? header.slice("sha256=".length)
: header;
if (!/^[0-9a-fA-F]{64}$/.test(value)) return null;
const pairs = value.match(/.{2}/g);
if (!pairs) return null;
return Uint8Array.from(pairs, (pair) => Number.parseInt(pair, 16));
}
function makeSignedMessage(
timestamp: string,
rawBody: Uint8Array,
): Uint8Array {
const prefix = encoder.encode(`${timestamp}.`);
const message = new Uint8Array(prefix.length + rawBody.length);
message.set(prefix, 0);
message.set(rawBody, prefix.length);
return message;
}
async function verifyHmac(
secret: string,
message: Uint8Array,
suppliedSignature: Uint8Array,
): Promise<boolean> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
return crypto.subtle.verify(
"HMAC",
key,
suppliedSignature,
message,
);
}
function isWebhookEvent(value: unknown): value is WebhookEvent {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const eventId = (value as Record<string, unknown>).event_id;
return typeof eventId === "string" && eventId.length > 0;
}
Deno.serve(async (request) => {
const requestId = crypto.randomUUID();
if (request.method !== "POST") {
return jsonResponse(
405,
{ error: "method_not_allowed", request_id: requestId },
{ allow: "POST" },
);
}
const signatureHeader = request.headers.get("x-webhook-signature");
const timestampHeader = request.headers.get("x-webhook-timestamp");
if (!signatureHeader || !timestampHeader) {
return jsonResponse(400, {
error: "missing_signature_headers",
request_id: requestId,
});
}
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (
!Number.isSafeInteger(timestamp) ||
Math.abs(now - timestamp) > MAX_AGE_SECONDS
) {
return jsonResponse(401, {
error: "expired_or_invalid_timestamp",
request_id: requestId,
});
}
const suppliedSignature = decodeHexSignature(signatureHeader);
if (!suppliedSignature) {
return jsonResponse(400, {
error: "malformed_signature",
request_id: requestId,
});
}
const secret = Deno.env.get("WEBHOOK_SECRET");
const supabaseUrl = Deno.env.get("SUPABASE_URL");
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!secret || !supabaseUrl || !serviceRoleKey) {
console.error({ request_id: requestId, reason: "missing_server_config" });
return jsonResponse(500, {
error: "server_configuration_error",
request_id: requestId,
});
}
// Body hanya boleh dibaca satu kali. Simpan sebagai byte mentah.
const rawBody = new Uint8Array(await request.arrayBuffer());
const signedMessage = makeSignedMessage(timestampHeader, rawBody);
const signatureValid = await verifyHmac(
secret,
signedMessage,
suppliedSignature,
);
if (!signatureValid) {
console.warn({ request_id: requestId, reason: "invalid_signature" });
return jsonResponse(401, {
error: "invalid_signature",
request_id: requestId,
});
}
let event: unknown;
try {
event = JSON.parse(decoder.decode(rawBody));
} catch {
return jsonResponse(400, {
error: "invalid_json",
request_id: requestId,
});
}
if (!isWebhookEvent(event)) {
return jsonResponse(422, {
error: "invalid_event_schema",
request_id: requestId,
});
}
const supabase = createClient(supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { error } = await supabase.from("incoming_webhook_events").insert({
event_id: event.event_id,
provider: "acme",
event_type: typeof event.type === "string" ? event.type : null,
payload: event,
status: "pending",
});
if (error?.code === "23505") {
return jsonResponse(200, {
status: "duplicate",
event_id: event.event_id,
request_id: requestId,
});
}
if (error) {
console.error({
request_id: requestId,
event_id: event.event_id,
reason: "database_insert_failed",
database_code: error.code,
});
return jsonResponse(500, {
error: "temporary_storage_failure",
request_id: requestId,
});
}
return jsonResponse(202, {
status: "accepted",
event_id: event.event_id,
request_id: requestId,
});
});
Perbandingan signature yang aman
Jangan membandingkan signature menggunakan expected === supplied atau loop yang berhenti pada byte pertama yang berbeda. Pola tersebut dapat memperlihatkan perbedaan waktu berdasarkan posisi byte yang salah.
Contoh di atas memakai crypto.subtle.verify(), sehingga aplikasi tidak menerapkan perbandingan string manual. Signature juga didekode menjadi byte sebelum diverifikasi. Validasi panjang dan format hex dilakukan lebih dahulu untuk menolak input yang rusak.
Timestamp dan perlindungan replay
Signature valid tidak selalu berarti request baru. Penyerang yang merekam request dapat mengirim ulang body, timestamp, dan signature yang sama. Karena timestamp ikut ditandatangani, fungsi dapat menolak request yang lebih tua atau terlalu jauh di masa depan.
Pemeriksaan Math.abs(now - timestamp) > 300 memberi toleransi lima menit terhadap keterlambatan jaringan dan perbedaan jam. Sinkronisasi waktu server tetap diperlukan. Sesuaikan jendela waktu dengan kebijakan retry penyedia; jendela yang terlalu lebar memperbesar peluang replay, sedangkan jendela terlalu sempit dapat menolak request sah.
Batas waktu belum mencegah replay di dalam jendela tersebut. Karena itu, timestamp harus dipadukan dengan penyimpanan event_id yang unik.
Membuat penerimaan webhook idempoten
Penyedia webhook umumnya mengirim ulang event ketika tidak menerima respons 2xx, koneksi terputus, atau terjadi timeout. Dua request identik juga dapat tiba secara bersamaan. Pemeriksaan “cari dulu, lalu insert” tidak aman karena kedua request dapat sama-sama melihat bahwa event belum ada.
Gunakan unique constraint di PostgreSQL agar database menjadi penjaga atomik:
create table public.incoming_webhook_events (
id bigint generated always as identity primary key,
event_id text not null unique,
provider text not null,
event_type text,
payload jsonb not null,
status text not null default 'pending'
check (status in ('pending', 'processing', 'completed', 'failed')),
attempts integer not null default 0,
received_at timestamptz not null default now(),
processed_at timestamptz
);
create index incoming_webhook_events_pending_idx
on public.incoming_webhook_events (received_at)
where status = 'pending';
Jika satu tabel menerima event dari beberapa penyedia dan ID hanya unik dalam ruang nama masing-masing, gunakan constraint gabungan:
alter table public.incoming_webhook_events
drop constraint incoming_webhook_events_event_id_key;
alter table public.incoming_webhook_events
add constraint incoming_webhook_events_provider_event_id_key
unique (provider, event_id);
Unique constraint tetap benar ketika request bersaing. Salah satu insert berhasil, sedangkan insert lain menerima kode PostgreSQL 23505 dan dapat dibalas sebagai duplikat yang sudah diterima.
Idempotensi penerimaan belum otomatis membuat seluruh efek samping idempoten. Jika worker memanggil API eksternal, gunakan
event_idsebagai idempotency key bila API tujuan mendukungnya. Worker dapat crash setelah efek samping berhasil tetapi sebelum status database diperbarui.
Pisahkan penerimaan dari proses berat
Handler webhook sebaiknya hanya melakukan pekerjaan yang diperlukan untuk menerima event secara aman:
- Memvalidasi method, header, timestamp, dan signature.
- Memvalidasi struktur minimum seperti
event_id. - Menyimpan event secara durabel dengan unique constraint.
- Mengembalikan respons 2xx secepat mungkin setelah penyimpanan berhasil.
Pengiriman email, pemanggilan API lain, pembuatan laporan, atau transformasi besar sebaiknya dilakukan oleh worker atau mekanisme antrean terpisah. Tabel penerimaan dapat berfungsi sebagai inbox durabel yang dipindai worker, atau transaksi insert dapat diteruskan ke sistem antrean yang tersedia dalam arsitektur aplikasi.
Worker harus mengklaim event secara atomik sebelum memprosesnya. Hindari dua worker membaca baris pending yang sama tanpa locking atau transisi status atomik. Simpan jumlah percobaan dan error ringkas untuk retry, tetapi jangan memasukkan secret atau payload sensitif ke kolom error.
Kontrak respons HTTP
- 202 Accepted: signature valid dan event baru berhasil disimpan untuk diproses.
- 200 OK: event merupakan duplikat yang sudah pernah diterima. Respons 2xx mencegah retry yang tidak perlu.
- 400 Bad Request: header wajib hilang, signature salah format, atau JSON tidak valid.
- 401 Unauthorized: signature tidak valid atau timestamp berada di luar batas.
- 422 Unprocessable Content: JSON valid tetapi field wajib seperti
event_idtidak tersedia. - 500 Internal Server Error: kegagalan sementara saat menyimpan event. Status non-2xx memungkinkan penyedia mencoba kembali.
Jangan mengembalikan detail internal database, nilai signature yang diharapkan, atau secret dalam respons. Periksa dokumentasi penyedia karena sebagian layanan hanya membedakan respons 2xx dan non-2xx.
Service role, penyimpanan payload, dan logging
Batasi risiko service role
Service role dapat melewati Row Level Security dan memiliki kewenangan luas. Kunci tersebut hanya boleh tersedia sebagai secret server-side di Edge Function, tidak pernah dikirim ke browser, aplikasi seluler, atau repository.
Gunakan fungsi khusus untuk webhook dan minimalkan kode yang memiliki akses ke service role. Untuk pembatasan lebih ketat, pertimbangkan endpoint database atau fungsi SQL dengan hak terbatas yang hanya dapat memasukkan kolom tertentu. Jika tetap memakai service role, jangan menerima nama tabel, filter, atau operasi database langsung dari payload.
Jangan membocorkan data lewat log
Log yang berguna tidak harus berisi seluruh body. Catat data operasional yang terstruktur, seperti:
request_idinternal;- nama penyedia dan jenis event;
- hasil verifikasi atau alasan penolakan yang umum;
- kode error database tanpa detail payload;
event_idhanya jika tidak dianggap sensitif.
Jangan mencatat raw body, secret, header signature, token autentikasi, atau data pribadi. Jika korelasi diperlukan tetapi event ID sensitif, log hash satu arah dari ID dengan kebijakan yang konsisten.
Kolom payload juga perlu diperlakukan sebagai data sensitif. Simpan hanya field yang dibutuhkan, tentukan masa retensi, dan batasi akses tabel. Untuk payload besar, tetapkan batas ukuran request agar fungsi tidak mengalokasikan memori tanpa kendali.
Pengujian verifikasi webhook
Pengujian harus menggunakan byte body yang sama untuk menghasilkan dan memverifikasi signature. Saat mengirim file dengan alat seperti curl, gunakan mode biner agar newline dan byte file tidak berubah, misalnya --data-binary @payload.json.
Cakup setidaknya skenario berikut:
- Signature valid: buat HMAC dari timestamp dan byte body yang sama. Harapkan
202dan satu baris berstatuspending. - Body berubah: buat signature valid, lalu tambahkan satu spasi atau newline ke body tanpa menghitung ulang signature. Harapkan
401. - Replay kedaluwarsa: gunakan signature yang secara kriptografis valid tetapi timestamp lebih tua dari batas. Harapkan
401dan tidak ada insert. - Timestamp terlalu jauh di masa depan: harapkan penolakan yang sama untuk mencegah signature memiliki masa berlaku panjang.
- Event duplikat: kirim dua request valid dengan
event_idsama. Request pertama menerima202, request berikutnya menerima200, dan tabel tetap berisi satu event. - Request bersamaan: kirim beberapa request valid secara paralel untuk memastikan unique constraint, bukan pemeriksaan aplikasi, yang menyelesaikan race condition.
- JSON rusak dengan signature valid: tandatangani body non-JSON secara benar. Signature harus lolos, tetapi endpoint mengembalikan
400ketika parsing dilakukan setelah verifikasi. - Database gagal sementara: simulasikan kegagalan insert dan pastikan endpoint mengembalikan non-2xx agar mekanisme retry penyedia tetap bekerja.
Kesalahan yang paling sering ditemukan saat debugging adalah skema pesan yang berbeda dari dokumentasi, timestamp diperlakukan sebagai milidetik padahal dikirim dalam detik, secret Base64 dipakai sebagai teks biasa, format signature hex tertukar dengan Base64, serta newline tersembunyi dalam fixture. Log panjang body dan alasan penolakan secara aman, tetapi jangan log isi body atau secret.
Ringkasan alur yang aman
Alur yang disarankan adalah membaca timestamp dan signature, menolak timestamp di luar jendela, mengambil body sebagai byte mentah, memverifikasi HMAC melalui crypto.subtle.verify(), lalu memparse JSON. Setelah itu, simpan event_id dengan unique constraint sebelum mengembalikan 2xx.
Kombinasi raw body, timestamp anti-replay, verifikasi kriptografis, inbox idempoten, dan worker terpisah mencegah kegagalan yang paling umum: signature palsu, body yang tidak cocok, replay lama, pemrosesan ganda, dan timeout akibat pekerjaan berat di handler webhook.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!