# PHP と Laravel で Webhook 署名を検証する

> Ikiform の Webhook 署名を、素の PHP では hash_hmac と hash_equals で、Laravel では Standard Webhooks ライブラリと CSRF の除外で検証します。

公開日 2026-10-25 · https://ikiform.com/ja/guides/webhook-signature-verification/php

`php://input` から生のボディを読み、3 つの `webhook-*` ヘッダーを取得して、base64 デコードした `whsec_` のシークレットをキーに `base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true))` を計算します。それを `hash_equals` で各 `v1` 署名と比較し、5 分以上ずれたタイムスタンプは拒否します。Laravel では `$request->getContent()` と `standard-webhooks/standard-webhooks` パッケージを使い、ルートを CSRF チェックから除外します。

構成は 2 つです。共有ホスティングを含め PHP 8 のあるどのホストでも動く単一の PHP ファイルと、Laravel のコントローラーです。どちらも同じ 3 つのヘッダーと同じ生のボディを読みます。違うのは、それらがどこにあるかと、何が邪魔をするかです。

## PHP で異なる点

PHP が `$_POST` を埋めるのはフォームのエンコーディングのときだけです。Ikiform は JSON を送るので `$_POST` は空で、ボディは `php://input` にしかありません。これこそが必要な生のバイト列です。ヘッダーは `$_SERVER` に `HTTP_WEBHOOK_ID` などとして届きます。Laravel では落とし穴が違います。`routes/web.php` のすべてのルートは CSRF の検証を通り、Webhook には CSRF トークンがないので、コードが動く前に Laravel が `419` を返します。

図: CSRF の除外がある場合とない場合に、Ikiform の Webhook が Laravel のルートに届くまで

1. デフォルトでは、routes/web.php のルートへの POST は Laravel の CSRF チェックを通ります。
2. Ikiform の配信には CSRF トークンがないので、Laravel は 419 を返し、コントローラーは実行されません。
3. パスを validateCsrfTokens の except に入れると、リクエストはコントローラーに届きます。
4. コントローラーは Standard Webhooks ライブラリで生のボディを検証し、204 を返します。

このルートでは署名が CSRF の代わりになります。ほかのルートは保護されたままです。

## 素の PHP

`verify.php`:

```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
<?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` は何度でも読めるので、フレームワークやライブラリがすでに読んでいても、もう一度読めます。
- `hash_hmac(..., true)` は生のバイト列を返し、それを base64 エンコードします。`true` がないと 16 進数が返り、絶対に一致しません。
- `base64_decode(..., true)` は厳密です。壊れたシークレットは、黙って誤ったキーにデコードされるのではなく `false` を返します。
- `hash_equals` は定数時間で比較します。計算した値を 1 つ目に、リクエストの値を 2 つ目に渡してください。
- PHP では `$body` を文字列に埋め込んでもバイナリセーフなので、送信データに ASCII 以外の文字があっても何も変わりません。
- `$_SERVER['HTTP_WEBHOOK_ID']` は Apache、nginx と PHP-FPM、組み込みサーバーのどれでも動きます。一部の古い環境での `getallheaders()` とは違います。
- `IKIFORM_WEBHOOK_SECRET` はサーバーの環境変数か、Web ルートの外のファイルに置いてください。`webhook.php` には絶対に書かないでください。

### テストベクターで確認する

このスクリプトは[テストベクター](https://ikiform.com/ja/guides/webhook-signature-verification.md#test-vector)を関数に通します。`php check-signature.php` で実行します。失敗があれば 0 以外で終了するので、そのまま CI で実行することも、あとで PHPUnit のテストケースにすることもできます。

`check-signature.php`:

```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);
```

### 素の PHP で先に応答を返す

素の PHP スクリプトは通常、終了時に応答を送るので、`webhook.php` での時間のかかる処理は Ikiform への `2xx` を遅らせ、15 秒を超えると失敗とみなされてリトライされます。PHP-FPM では、`fastcgi_finish_request()` が応答を送ったうえでスクリプトを続行させます。この版の `webhook.php` の末尾は先に応答を返し、そのあと一意制約付きで配信 ID を記録するので、リトライは飛ばされます。

`webhook.php`:

```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` は PHP-FPM でしか存在しないので、チェックしています。Apache の `mod_php` では、スクリプトが最後まで実行されてから応答が送られます。`ON CONFLICT` 句は PostgreSQL と SQLite の構文です。MySQL では `INSERT IGNORE` を使ってください。こちらも繰り返しの場合は行数 `0` を返します。

## Laravel

Composer で Standard Webhooks ライブラリをインストールします。

`ターミナル`:

```bash
composer require standard-webhooks/standard-webhooks
```

`app/Http/Controllers/IkiformWebhookController.php`:

```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
<?php

use App\Http\Controllers\IkiformWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/ikiform', IkiformWebhookController::class);
```

`bootstrap/app.php` でパスを CSRF の検証から除外します。

`bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'webhooks/ikiform',
    ]);
})
```

そして `php artisan config:cache` が引き続き使えるよう、シークレットは config を通して読みます。

`config/services.php`:

```php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
```

- `$request->getContent()` が生のボディです。`json_encode($request->all())` を検証しないでください。データは同じでもバイト列が違います。
- ライブラリは小文字の正確なキーでヘッダーを探すので、例のように配列を明示的に組み立ててください。`$request->headers->all()` を渡すと動きません。値が配列だからです。
- `new Webhook($secret)` は `whsec_` を取り除いてキーをデコードします。ヘッダーがない、タイムスタンプが古いか未来、一致する署名がない場合は `WebhookVerificationException` を投げます。
- `response()->noContent()` は `204` を返します。Ikiform の 15 秒より十分前に応答が返るよう、時間のかかる処理はジョブでキューに入れてください。`delivery_id` 列に一意インデックスを付け、`insertOrIgnore` で書き込めば、ジョブは 2 回受け取っても安全です。

> `routes/api.php` のルートは除外設定なしで CSRF を通らず、`/api` の接頭辞が付きます。アプリにすでに API ルートがあるなら、代わりに Webhook をそこに置き、`bootstrap/app.php` の変更は省いてください。

## 動作を確認する

1. `php check-signature.php` を実行します。すべての行が `ok` で始まります。
2. 公開された `https:` の URL にデプロイし、ダッシュボードの **Webhooks** に追加します。
3. `IKIFORM_WEBHOOK_SECRET` にエンドポイントの `whsec_` のシークレットを設定します。Laravel では、config がキャッシュされているなら `php artisan config:clear` を実行してください。
4. **Send test** を選びます。配信ログには `419` ではなく `204` が表示され、コードが出力したエラーのボディがあれば最初の 4 KB も表示されます。
5. シークレットを 1 文字変えて、もう一度テストを送ります。ログには理由付きで `400` が表示されます。

## ハマりどころ

| 問題 | 原因と対処 |
| --- | --- |
| 配信ログに `419` が出る | Laravel の CSRF チェックです。パスを `validateCsrfTokens(except: [...])` に追加してください |
| `$_POST` が空 | JSON では想定どおりです。`file_get_contents('php://input')` を読んでください |
| 素の PHP で署名がまったく一致しない | `hash_hmac` を `true` なしで呼んだため、16 進数が返っています。または、キーがデコードしたバイト列ではなく `whsec_` の文字列全体になっています |
| ライブラリから `Missing required headers` | ヘッダーの配列のキーが間違っているか、値が配列になっています。小文字のキーと、文字列を返す `$request->header()` を使ってください |
| 本番でシークレットが `null` になる | config がキャッシュされていて、config ファイルの外で `env()` を呼んでいます。`config('services.ikiform.webhook_secret')` を通して読んでください |

## よくある質問

### PHP で生の POST ボディを取得するには？

file_get_contents('php://input') を読みます。JSON のリクエストでは $_POST は空で、php://input には送られたとおりのボディが入っています。署名の対象はこれです。

### Laravel が Webhook に 419 を返すのはなぜ？

routes/web.php のルートは CSRF で保護されていて、Webhook は CSRF トークンを送れません。bootstrap/app.php の validateCsrfTokens(except: [...]) でパスを除外するか、ルートを routes/api.php に定義してください。

### Webhook 署名の比較に hash_equals は安全ですか？

はい。PHP の定数時間の文字列比較です。=== の代わりに使えば、かかる時間から偽造した署名のどこまでが正しかったかが漏れません。

### PHP で Standard Webhooks を検証する Composer パッケージは？

standard-webhooks/standard-webhooks です。new StandardWebhooks\Webhook($secret) は whsec_ のシークレットを受け取り、verify($body, $headers) はデコードしたペイロードを返すか、WebhookVerificationException を投げます。

### hash_hmac の結果が webhook-signature ヘッダーと一致しないのはなぜ？

hash_hmac はデフォルトで 16 進数を返します。4 番目の引数に true を渡して生のバイト列を得てから base64_encode してください。キーが、シークレットの文字列全体ではなく whsec_ のあとの部分を base64 デコードしたものになっているかも確認してください。

## その他のガイド

- [Webhook の署名を検証する](https://ikiform.com/ja/guides/webhook-signature-verification.md): フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。
- [Ruby](https://ikiform.com/ja/guides/webhook-signature-verification/ruby.md): Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。
- [Python](https://ikiform.com/ja/guides/webhook-signature-verification/python.md): Flask、FastAPI、Django で Ikiform の Webhook 署名を検証する方法。standardwebhooks パッケージか標準ライブラリを使い、pytest のテストも付けます。
- [Node.js](https://ikiform.com/ja/guides/webhook-signature-verification/nodejs.md): Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。
- [Go の net/http](https://ikiform.com/ja/guides/webhook-signature-verification/go.md): Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
