웹훅 서명 검증

웹훅 서명 검증4분 분량

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

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

Preet SutharIkiform 창업자

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 웹훅routes/web.php, 기본 미들웨어CSRF에서 제외된 라우트POST/webhooks/ikiformCSRF 확인요청에 토큰 없음419 PageExpired 응답POST/webhooks/ikiform컨트롤러$request->getContent()검증StandardWebhooks 라이브러리204CSRF 예외가 있을 때와 없을 때 Laravel 라우트에 도달하는 Ikiform 웹훅routes/web.php, 기본 미들웨어CSRF에서 제외된 라우트POST /webhooks/ikiformCSRF 확인요청에 토큰 없음419 Page Expired 응답POST /webhooks/ikiform컨트롤러$request->getContent()검증Standard Webhooks 라이브러리204
  1. 기본으로 routes/web.php의 라우트로 가는 POST는 Laravel의 CSRF 확인을 거칩니다.
  2. Ikiform의 전달에는 CSRF 토큰이 없으므로 Laravel이 419로 응답하고 컨트롤러는 실행되지 않습니다.
  3. validateCsrfTokens except에 경로를 등록하면 요청이 컨트롤러에 도달합니다.
  4. 컨트롤러가 Standard Webhooks 라이브러리로 원본 본문을 검증하고 204로 응답합니다.
이 라우트에서는 서명이 CSRF를 대신합니다. 다른 모든 라우트는 보호를 유지합니다.

일반 PHP

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은 여러 번 읽을 수 있으므로, 프레임워크나 라이브러리가 이미 읽었더라도 다시 읽을 수 있습니다.
  • 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에 넣지 마세요.

테스트 벡터로 확인하기

이 스크립트는 테스트 벡터를 함수에 통과시킵니다. php check-signature.php로 실행하세요. 실패하면 0이 아닌 코드로 종료하므로 그대로 CI에서 돌리거나 나중에 PHPUnit 케이스로 바꿀 수 있습니다.

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

일반 PHP에서 먼저 응답하기

일반 PHP 스크립트는 보통 끝날 때 응답을 보내므로 webhook.php의 느린 작업이 Ikiform의 2xx를 늦추고, 15초를 넘으면 실패로 집계되어 재시도됩니다. PHP-FPM에서는 fastcgi_finish_request()가 응답을 보내고 스크립트가 계속 실행되게 합니다. 이 버전의 webhook.php 끝부분은 먼저 응답한 뒤 고유 제약 조건과 함께 전달 id를 기록해 재시도를 건너뜁니다.

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는 PHP-FPM에서만 존재하므로 확인을 거칩니다. Apache의 mod_php에서는 응답이 나가기 전에 스크립트가 끝까지 실행됩니다. ON CONFLICT 절은 PostgreSQL과 SQLite 문법입니다. MySQL에서는 INSERT IGNORE를 쓰세요. 반복일 때 역시 행 수 0을 보고합니다.

Laravel

Composer로 Standard Webhooks 라이브러리를 설치하세요.

터미널
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);

bootstrap/app.php에서 경로를 CSRF 검증에서 제외하세요.

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

그리고 php artisan config:cache가 계속 동작하도록 config를 거쳐 시크릿을 읽으세요.

config/services.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이 표시됩니다.

주의할 점

문제원인과 해결
전달 로그에 419Laravel의 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')로 읽으세요

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.