Verifikasi tanda tangan webhook

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.

Preet SutharPendiri, Ikiform

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.

Webhook Ikiform yang sampai ke route Laravel dengan dan tanpa pengecualian CSRFroutes/web.php, middleware defaultroute dikecualikan dari CSRFPOST/webhooks/ikiformpemeriksaanCSRFtidak ada tokendi request419 PageExpiredPOST/webhooks/ikiformcontroller$request->getContent()verifikasiStandardWebhooks204Webhook Ikiform yang sampai ke route Laravel dengan dan tanpa pengecualian CSRFroutes/web.php, middleware defaultroute dikecualikan dari CSRFPOST /webhooks/ikiformpemeriksaan CSRFtidak ada token di request419 Page ExpiredPOST /webhooks/ikiformcontroller$request->getContent()verifikasiStandard Webhooks204
  1. Secara default, POST ke route di routes/web.php melewati pemeriksaan CSRF Laravel.
  2. Pengiriman Ikiform tidak punya token CSRF, jadi Laravel menjawab 419 dan controller tidak pernah berjalan.
  3. Dengan path tercantum di except validateCsrfTokens, request sampai ke controller.
  4. Controller memverifikasi body mentah dengan library Standard Webhooks dan menjawab 204.
Tanda tangan menggantikan CSRF untuk route ini. Setiap route lain tetap terlindungi.

PHP biasa

verify.php
<?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';
}
webhook.php
<?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://input bisa 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. Tanpa true Anda mendapat hex, yang tidak akan pernah cocok.
  • base64_decode(..., true) bersifat ketat: secret yang rusak mengembalikan false alih-alih diam-diam di-decode menjadi key yang salah.
  • hash_equals bersifat constant-time. Berikan nilai yang Anda hitung sebagai argumen pertama dan nilai dari request sebagai argumen kedua.
  • Menyisipkan $body ke 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 seperti getallheaders() di beberapa penyiapan lama.
  • Simpan IKIFORM_WEBHOOK_SECRET di environment server atau file di luar web root, jangan pernah di webhook.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:

check-signature.php
<?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:

webhook.php
$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:

Terminal
composer require standard-webhooks/standard-webhooks
app/Http/Controllers/IkiformWebhookController.php
<?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();
    }
}
routes/web.php
<?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:

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:

config/services.php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
  • $request->getContent() adalah body mentah. Jangan verifikasi json_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) menghapus whsec_ dan men-decode key. Library melempar WebhookVerificationException untuk header yang hilang, timestamp lama atau dari masa depan, atau tanda tangan yang tidak cocok.
  • response()->noContent() menjawab 204. Masukkan pekerjaan lambat ke antrean dengan job agar jawaban kembali jauh di bawah 15 detik Ikiform. Unique index di kolom delivery_id, yang ditulis dengan insertOrIgnore, membuat job aman diterima dua kali.
Route di 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

  1. Jalankan php check-signature.php. Setiap baris diawali ok.
  2. Deploy di balik URL https: publik dan tambahkan di Webhooks di dashboard.
  3. Atur IKIFORM_WEBHOOK_SECRET ke secret whsec_ milik endpoint. Di Laravel, jalankan php artisan config:clear jika config di-cache.
  4. Pilih Send test. Log pengiriman menampilkan 204, bukan 419, dan 4 KB pertama dari body error apa pun yang dicetak kode Anda.
  5. Ubah satu karakter secret dan kirim uji lagi. Log menampilkan 400 beserta alasannya.

Hal yang sering menjebak

MasalahPenyebab dan solusi
419 di log pengirimanPemeriksaan CSRF Laravel. Tambahkan path ke validateCsrfTokens(except: [...])
$_POST kosongItu wajar untuk JSON. Baca file_get_contents('php://input')
Tanda tangan tidak pernah cocok di PHP biasahash_hmac dipanggil tanpa true, jadi mengembalikan hex. Atau key-nya seluruh string whsec_, bukan byte hasil decode-nya
Missing required headers dari libraryArray header punya key yang salah atau nilai berupa array. Gunakan key huruf kecil dan $request->header(), yang mengembalikan string
Secret bernilai null di produksiConfig di-cache dan env() dipanggil di luar file config. Baca melalui config('services.ikiform.webhook_secret')

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.