웹훅 서명 검증4분 분량
PHP와 Laravel에서 웹훅 서명 검증하기
일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.
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로 응답합니다.
- 기본으로 routes/web.php의 라우트로 가는 POST는 Laravel의 CSRF 확인을 거칩니다.
- Ikiform의 전달에는 CSRF 토큰이 없으므로 Laravel이 419로 응답하고 컨트롤러는 실행되지 않습니다.
- validateCsrfTokens except에 경로를 등록하면 요청이 컨트롤러에 도달합니다.
- 컨트롤러가 Standard Webhooks 라이브러리로 원본 본문을 검증하고 204로 응답합니다.
일반 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';
}<?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 케이스로 바꿀 수 있습니다.
<?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를 기록해 재시도를 건너뜁니다.
$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<?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);bootstrap/app.php에서 경로를 CSRF 검증에서 제외하세요.
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/ikiform',
]);
})그리고 php artisan config:cache가 계속 동작하도록 config를 거쳐 시크릿을 읽으세요.
'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 변경은 건너뛰세요.작동 확인하기
php check-signature.php를 실행하세요. 모든 줄이ok로 시작합니다.- 공개
https:URL 뒤에 배포하고 대시보드의 Webhooks에 추가하세요. IKIFORM_WEBHOOK_SECRET을 엔드포인트의whsec_시크릿으로 설정하세요. Laravel에서 config가 캐시되어 있다면php artisan config:clear를 실행하세요.- Send test를 고르세요. 전달 로그에
419가 아니라204가 표시되고, 코드가 출력한 오류 본문의 처음 4 KB도 보입니다. - 시크릿을 한 글자 바꾸고 테스트를 다시 보내세요. 로그에 사유와 함께
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')로 읽으세요 |
질문
file_get_contents('php://input')을 읽으세요. JSON 요청에서는 $_POST가 비어 있고, php://input에 서명 대상인 본문이 보낸 그대로 담겨 있습니다.
routes/web.php의 라우트는 CSRF로 보호되며, 웹훅은 CSRF 토큰을 보낼 수 없습니다. bootstrap/app.php에서 validateCsrfTokens(except: [...])로 경로를 제외하거나, routes/api.php에 라우트를 정의하세요.
네. PHP의 상수 시간 문자열 비교입니다. 걸린 시간으로 위조 서명이 얼마나 맞았는지 드러나지 않도록 === 대신 쓰세요.
standard-webhooks/standard-webhooks입니다. new StandardWebhooks\Webhook($secret)은 whsec_ 시크릿을 받고, verify($body, $headers)는 디코딩된 페이로드를 반환하거나 WebhookVerificationException을 던집니다.
hash_hmac은 기본으로 hex를 반환합니다. 네 번째 인수로 true를 넘겨 원본 바이트를 받은 뒤 base64_encode하세요. 키가 시크릿 문자열 전체가 아니라 whsec_ 뒤의 base64 디코딩한 부분인지도 확인하세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.