# PHP와 Laravel에서 웹훅 서명 검증하기

> 일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.

2026-10-25 게시 · https://ikiform.com/ko/guides/webhook-signature-verification/php

`php://input`에서 원본 본문을 읽고, 세 `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 확인에서 제외하세요.

두 가지 구성입니다. 공유 호스팅을 포함해 PHP 8이 있는 모든 호스트에서 실행되는 PHP 파일 하나, 그리고 Laravel 컨트롤러입니다. 둘 다 같은 세 헤더와 같은 원본 본문을 읽으며, 그것들이 있는 위치와 가로막는 것이 다릅니다.

## PHP에서 다른 점

PHP는 폼 인코딩에서만 `$_POST`를 채웁니다. Ikiform은 JSON을 보내므로 `$_POST`는 비어 있고 본문은 `php://input`에만 있는데, 이것이 바로 필요한 원본 바이트입니다. 헤더는 `$_SERVER`에 `HTTP_WEBHOOK_ID` 등으로 도착합니다. Laravel의 함정은 다릅니다. `routes/web.php`의 모든 라우트는 CSRF 검증을 거치는데 웹훅에는 CSRF 토큰이 없으므로, 코드가 실행되기 전에 Laravel이 `419`로 응답합니다.

다이어그램: CSRF 예외가 있을 때와 없을 때 Laravel 라우트에 도달하는 Ikiform 웹훅

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`가 없으면 hex가 나와 절대 일치하지 않습니다.
- `base64_decode(..., true)`는 엄격합니다. 손상된 시크릿은 조용히 잘못된 키로 디코딩되지 않고 `false`를 반환합니다.
- `hash_equals`는 상수 시간입니다. 계산한 값을 첫 번째로, 요청의 값을 두 번째로 넘기세요.
- PHP에서 `$body`를 문자열에 보간하는 것은 바이너리 안전하므로 제출의 비ASCII 문자가 아무것도 바꾸지 않습니다.
- `$_SERVER['HTTP_WEBHOOK_ID']`는 일부 오래된 구성의 `getallheaders()`와 달리 Apache, PHP-FPM을 쓰는 nginx, 내장 서버에서 동작합니다.
- `IKIFORM_WEBHOOK_SECRET`은 서버 환경이나 웹 루트 밖의 파일에 두고, 절대 `webhook.php`에 넣지 마세요.

### 테스트 벡터로 확인하기

이 스크립트는 [테스트 벡터](https://ikiform.com/ko/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초 안에 여유 있게 응답이 돌아가도록 느린 작업은 job으로 큐에 넣으세요. `insertOrIgnore`로 쓰는 `delivery_id` 열의 고유 인덱스가 job이 같은 것을 두 번 받아도 안전하게 만듭니다.

> `routes/api.php`의 라우트는 예외 없이 CSRF를 건너뛰고 `/api` 접두사가 붙습니다. 앱에 이미 API 라우트가 있다면 웹훅을 그곳에 두고 `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. 시크릿을 한 글자 바꾸고 테스트를 다시 보내세요. 로그에 사유와 함께 `400`이 표시됩니다.

## 주의할 점

| 문제 | 원인과 해결 |
| --- | --- |
| 전달 로그에 `419` | Laravel의 CSRF 확인입니다. `validateCsrfTokens(except: [...])`에 경로를 추가하세요 |
| `$_POST`가 비어 있음 | JSON이면 정상입니다. `file_get_contents('php://input')`을 읽으세요 |
| 일반 PHP에서 서명이 절대 일치하지 않음 | `hash_hmac`을 `true` 없이 호출해 hex가 반환되었습니다. 또는 키가 디코딩한 바이트가 아니라 `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이 웹훅에 419를 반환하는 이유는 무엇인가요?

routes/web.php의 라우트는 CSRF로 보호되며, 웹훅은 CSRF 토큰을 보낼 수 없습니다. bootstrap/app.php에서 validateCsrfTokens(except: [...])로 경로를 제외하거나, routes/api.php에 라우트를 정의하세요.

### 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은 기본으로 hex를 반환합니다. 네 번째 인수로 true를 넘겨 원본 바이트를 받은 뒤 base64_encode하세요. 키가 시크릿 문자열 전체가 아니라 whsec_ 뒤의 base64 디코딩한 부분인지도 확인하세요.

## 가이드 더 보기

- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [Ruby](https://ikiform.com/ko/guides/webhook-signature-verification/ruby.md): Sinatra에서는 OpenSSL과 Minitest 테스트로, Rails에서는 standardwebhooks gem과 request.raw_post로 Ikiform 웹훅 서명을 검증하세요.
- [Python](https://ikiform.com/ko/guides/webhook-signature-verification/python.md): standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.
- [Node.js](https://ikiform.com/ko/guides/webhook-signature-verification/nodejs.md): Node.js에서 Ikiform 웹훅 서명을 검증하세요. Express와 Fastify의 원본 본문, SDK, standardwebhooks 패키지, 테스트된 node:crypto 버전을 다룹니다.
- [Go net/http 사용](https://ikiform.com/ko/guides/webhook-signature-verification/go.md): Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, crypto/hmac으로 확인하고, 테스트하거나, Standard Webhooks 라이브러리를 쓰세요.
