Verifikasi tanda tangan webhook5 menit baca
Verifikasi tanda tangan webhook di PHP dan Laravel
Verifikasi tanda tangan webhook Ikiform di PHP biasa dengan hash_hmac dan hash_equals, dan di Laravel dengan library Standard Webhooks dan pengecualian CSRF.
Baca body mentah dari php://input, ambil ketiga header webhook-*, dan hitung base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)) dengan secret whsec_ hasil decode base64 sebagai key. Bandingkan dengan setiap tanda tangan v1 memakai hash_equals, dan tolak timestamp yang meleset lebih dari 5 menit. Di Laravel, gunakan $request->getContent(), paket standard-webhooks/standard-webhooks, dan kecualikan route dari pemeriksaan CSRF.
Dua penyiapan: satu file PHP yang berjalan di host mana pun dengan PHP 8, termasuk shared hosting, dan controller Laravel. Keduanya membaca tiga header yang sama dan body mentah yang sama; bedanya ada di mana semua itu berada dan apa yang menghalanginya.
Apa yang berbeda di PHP
PHP hanya mengisi $_POST untuk encoding formulir. Ikiform mengirim JSON, jadi $_POST kosong dan body hanya ada di php://input, yang persis berisi byte mentah yang Anda butuhkan. Header tiba di $_SERVER sebagai HTTP_WEBHOOK_ID dan seterusnya. Di Laravel jebakannya berbeda: setiap route di routes/web.php melewati verifikasi CSRF, dan webhook tidak punya token CSRF, jadi Laravel menjawab 419 sebelum kode Anda berjalan.
- Secara default, POST ke route di routes/web.php melewati pemeriksaan CSRF Laravel.
- Pengiriman Ikiform tidak punya token CSRF, jadi Laravel menjawab 419 dan controller tidak pernah berjalan.
- Dengan path tercantum di except validateCsrfTokens, request sampai ke controller.
- Controller memverifikasi body mentah dengan library Standard Webhooks dan menjawab 204.
PHP biasa
<?php
declare(strict_types=1);
const IKIFORM_TOLERANCE_SECONDS = 300;
function verify_ikiform_signature(string $body, array $headers, string $secret, ?int $now = null): ?string
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
return 'missing_headers';
}
if (preg_match('/^[0-9]{1,12}$/', $timestamp) !== 1) {
return 'invalid_timestamp';
}
$age = ($now ?? time()) - (int) $timestamp;
if ($age > IKIFORM_TOLERANCE_SECONDS) {
return 'timestamp_too_old';
}
if ($age < -IKIFORM_TOLERANCE_SECONDS) {
return 'timestamp_too_new';
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false || $key === '') {
return 'invalid_secret';
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $value] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return null;
}
}
return 'no_matching_signature';
}<?php
declare(strict_types=1);
require __DIR__ . '/verify.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$body = file_get_contents('php://input');
$headers = [
'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
];
$failure = verify_ikiform_signature($body, $headers, getenv('IKIFORM_WEBHOOK_SECRET') ?: '');
if ($failure !== null) {
http_response_code(400);
echo $failure;
exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if ($event['type'] === 'submission.created') {
error_log('Ikiform submission ' . $event['data']['submission_id']);
}
http_response_code(204);php://inputbisa dibaca lebih dari sekali, jadi framework atau library yang sudah membacanya tidak menghalangi Anda membacanya lagi.hash_hmac(..., true)mengembalikan byte mentah, yang kemudian di-encode base64. TanpatrueAnda mendapat hex, yang tidak akan pernah cocok.base64_decode(..., true)bersifat ketat: secret yang rusak mengembalikanfalsealih-alih diam-diam di-decode menjadi key yang salah.hash_equalsbersifat constant-time. Berikan nilai yang Anda hitung sebagai argumen pertama dan nilai dari request sebagai argumen kedua.- Menyisipkan
$bodyke dalam string aman untuk biner di PHP, jadi karakter non-ASCII di kiriman tidak mengubah apa pun. $_SERVER['HTTP_WEBHOOK_ID']berfungsi di Apache, nginx dengan PHP-FPM, dan server bawaan, tidak sepertigetallheaders()di beberapa penyiapan lama.- Simpan
IKIFORM_WEBHOOK_SECRETdi environment server atau file di luar web root, jangan pernah diwebhook.php.
Memeriksanya dengan test vector
Script ini menjalankan test vector melalui function. Jalankan dengan php check-signature.php; script keluar dengan kode non-nol pada kegagalan apa pun, jadi bisa dijalankan di CI apa adanya atau dijadikan kasus PHPUnit nanti:
<?php
declare(strict_types=1);
require __DIR__ . '/verify.php';
$secret = 'whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=';
$rolled = 'whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=';
$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."}}';
$headers = [
'webhook-id' => 'dlv_01k7v2d4n8r2p6s0w3y5b7c9e1',
'webhook-timestamp' => '1792300000',
'webhook-signature' => 'v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=',
];
$now = 1792300000 + 30;
$both = ['webhook-signature' => $headers['webhook-signature'] . ' v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak='] + $headers;
$pretty = json_encode(json_decode($body), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$cases = [
'accepts the vector' => [verify_ikiform_signature($body, $headers, $secret, $now), null],
'accepts the old secret during a roll' => [verify_ikiform_signature($body, $both, $secret, $now), null],
'accepts the new secret during a roll' => [verify_ikiform_signature($body, $both, $rolled, $now), null],
'rejects a re-serialized body' => [verify_ikiform_signature($pretty, $headers, $secret, $now), 'no_matching_signature'],
'rejects a stale timestamp' => [verify_ikiform_signature($body, $headers, $secret, $now + 600), 'timestamp_too_old'],
];
$failed = 0;
foreach ($cases as $name => [$got, $want]) {
$ok = $got === $want;
$failed += $ok ? 0 : 1;
printf("%s %s\n", $ok ? 'ok ' : 'FAIL', $name);
}
exit($failed === 0 ? 0 : 1);Menjawab lebih dulu di PHP biasa
Script PHP biasa umumnya mengirim responsnya saat selesai, jadi pekerjaan lambat di webhook.php menunda 2xx untuk Ikiform, dan apa pun yang lebih dari 15 detik dianggap gagal dan dicoba ulang. Di PHP-FPM, fastcgi_finish_request() mengirim respons dan membiarkan script terus berjalan. Versi bagian akhir webhook.php ini menjawab lebih dulu, lalu mencatat id pengiriman dengan unique constraint agar percobaan ulang dilewati:
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
http_response_code(204);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
$pdo = new PDO(getenv('DATABASE_DSN'), getenv('DATABASE_USER'), getenv('DATABASE_PASSWORD'));
$insert = $pdo->prepare('INSERT INTO webhook_deliveries (delivery_id) VALUES (?) ON CONFLICT (delivery_id) DO NOTHING');
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1 && $event['type'] === 'submission.created') {
error_log('Ikiform submission ' . $event['data']['submission_id']);
}fastcgi_finish_request hanya ada di PHP-FPM, karena itu ada pemeriksaannya; di mod_php Apache script berjalan sampai akhir sebelum jawaban dikirim. Klausa ON CONFLICT adalah sintaks PostgreSQL dan SQLite. Di MySQL, gunakan INSERT IGNORE, yang juga melaporkan jumlah baris 0 untuk pengulangan.
Laravel
Instal library Standard Webhooks dengan Composer:
composer require standard-webhooks/standard-webhooks<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;
class IkiformWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$webhook = new Webhook(config('services.ikiform.webhook_secret'));
try {
$event = $webhook->verify($request->getContent(), [
'webhook-id' => $request->header('webhook-id'),
'webhook-timestamp' => $request->header('webhook-timestamp'),
'webhook-signature' => $request->header('webhook-signature'),
]);
} catch (WebhookVerificationException $exception) {
return response($exception->getMessage(), 400);
}
if ($event['type'] === 'submission.created') {
Log::info('Ikiform submission', ['id' => $event['id'], 'fields' => $event['data']['fields']]);
}
return response()->noContent();
}
}<?php
use App\Http\Controllers\IkiformWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/ikiform', IkiformWebhookController::class);Kecualikan path dari verifikasi CSRF di bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/ikiform',
]);
})Dan baca secret melalui config, agar php artisan config:cache tetap berfungsi:
'ikiform' => [
'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],$request->getContent()adalah body mentah. Jangan verifikasijson_encode($request->all()); datanya sama tetapi byte-nya berbeda.- Library mencari header dengan key huruf kecil yang persis, jadi susun array secara eksplisit seperti contoh. Memberikan
$request->headers->all()tidak berfungsi: nilai-nilainya berupa array. new Webhook($secret)menghapuswhsec_dan men-decode key. Library melemparWebhookVerificationExceptionuntuk header yang hilang, timestamp lama atau dari masa depan, atau tanda tangan yang tidak cocok.response()->noContent()menjawab204. Masukkan pekerjaan lambat ke antrean dengan job agar jawaban kembali jauh di bawah 15 detik Ikiform. Unique index di kolomdelivery_id, yang ditulis denganinsertOrIgnore, membuat job aman diterima dua kali.
routes/api.php melewati CSRF tanpa pengecualian apa pun dan mendapat awalan /api. Jika aplikasi Anda sudah punya route API, taruh webhook di sana dan lewati perubahan bootstrap/app.php.Pastikan berfungsi
- Jalankan
php check-signature.php. Setiap baris diawaliok. - Deploy di balik URL
https:publik dan tambahkan di Webhooks di dashboard. - Atur
IKIFORM_WEBHOOK_SECRETke secretwhsec_milik endpoint. Di Laravel, jalankanphp artisan config:clearjika config di-cache. - Pilih Send test. Log pengiriman menampilkan
204, bukan419, dan 4 KB pertama dari body error apa pun yang dicetak kode Anda. - Ubah satu karakter secret dan kirim uji lagi. Log menampilkan
400beserta alasannya.
Hal yang sering menjebak
| Masalah | Penyebab dan solusi |
|---|---|
419 di log pengiriman | Pemeriksaan CSRF Laravel. Tambahkan path ke validateCsrfTokens(except: [...]) |
$_POST kosong | Itu wajar untuk JSON. Baca file_get_contents('php://input') |
| Tanda tangan tidak pernah cocok di PHP biasa | hash_hmac dipanggil tanpa true, jadi mengembalikan hex. Atau key-nya seluruh string whsec_, bukan byte hasil decode-nya |
Missing required headers dari library | Array header punya key yang salah atau nilai berupa array. Gunakan key huruf kecil dan $request->header(), yang mengembalikan string |
Secret bernilai null di produksi | Config di-cache dan env() dipanggil di luar file config. Baca melalui config('services.ikiform.webhook_secret') |
Pertanyaan
Baca file_get_contents('php://input'). Untuk request JSON, $_POST kosong, dan php://input menyimpan body persis seperti dikirim, yang dicakup oleh tanda tangan.
Route di routes/web.php dilindungi CSRF, dan webhook tidak bisa mengirim token CSRF. Kecualikan path dengan validateCsrfTokens(except: [...]) di bootstrap/app.php, atau definisikan route di routes/api.php.
Ya. Itu perbandingan string constant-time milik PHP. Gunakan alih-alih === agar waktu yang dibutuhkan tidak mengungkap seberapa banyak tanda tangan palsu yang benar.
standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) menerima secret whsec_, dan verify($body, $headers) mengembalikan payload hasil decode atau melempar WebhookVerificationException.
Secara default hash_hmac mengembalikan hex. Berikan true sebagai argumen keempat untuk mendapat byte mentah, lalu base64_encode byte itu. Periksa juga bahwa key-nya adalah bagian hasil decode base64 setelah whsec_, bukan seluruh string secret.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.