본문 바로가기
← 아티클 목록
실무 가이드Laravel 13웹훅보안Pest

웹훅을 받을 때 서명부터 검증하기: 원본 본문, hash_equals, 그리고 거부 경로 테스트

작성: 라라벨 코리아

발행: 2026년 10월 10일

GitHub 웹훅처럼 HMAC 서명이 붙는 요청을 Laravel에서 안전하게 받는 방법을 정리합니다. 왜 원본 본문으로 계산해야 하는지, 왜 == 대신 hash_equals를 쓰는지 설명하고, 서명 누락·변조·재직렬화 네 가지 거부 경로를 Pest로 고정합니다.

웹훅은 "누구나 보낼 수 있는 POST 요청"이다

GitHub, 결제 대행사, 메시징 서비스가 보내는 웹훅은 결국 공개 URL로 들어오는 POST 요청입니다. 주소를 아는 사람은 누구든 같은 모양의 JSON을 보낼 수 있습니다. 그래서 대부분의 서비스는 공유 비밀키로 본문을 HMAC 서명해 헤더에 넣어 보내고, 받는 쪽이 같은 계산을 해서 비교하도록 합니다. GitHub는 X-Hub-Signature-256 헤더에 sha256=<hex> 형식으로 보냅니다.

이 글은 라라벨 코리아가 laravel/docs 저장소의 push 이벤트를 받아 문서 동기화를 시작하는 실제 엔드포인트의 검증 로직을 떼어 내어 설명합니다. 핵심은 세 가지입니다. 원본 본문으로 계산할 것, 상수 시간 비교를 쓸 것, 거부 경로를 테스트로 남길 것.

적용 환경

Laravel 13, PHP 8.5, Pest 4를 기준으로 합니다. 예제는 GitHub 형식을 따르지만 sha256= 접두어와 헤더 이름만 바꾸면 다른 서비스에도 같은 구조를 쓸 수 있습니다. 비밀키는 .env에 두고 config()로 읽습니다. 예제의 'test-secret'은 테스트 전용 값입니다.

1. 검증 로직을 컨트롤러 밖으로 꺼낸다

컨트롤러 안에 hash_hmac을 직접 쓰면 두 번째 웹훅 엔드포인트를 만들 때 같은 코드가 복사됩니다. 라라벨 코리아에도 문서·패키지·릴리스용 엔드포인트 세 개가 있고, 검증 코드가 세 번 반복돼 있습니다. 아래 클래스를 app/Support/WebhookSignature.php에 두면 한 곳에서 관리할 수 있습니다.

<?php namespace App\Support; class WebhookSignature { private const PREFIX = 'sha256='; /** * 서비스가 보내야 하는 헤더 값을 만든다. 테스트와 재전송 도구에서 사용한다. */ public static function sign(string $rawBody, string $secret): string { return self::PREFIX.hash_hmac('sha256', $rawBody, $secret); } /** * 받은 헤더가 원본 본문과 비밀키로 계산한 값과 일치하는지 확인한다. */ public static function verify(string $rawBody, ?string $header, string $secret): bool { if ($header === null || $header === '' || $secret === '') { return false; } return hash_equals(self::sign($rawBody, $secret), $header); } }

세 가지 결정이 들어 있습니다.

  • 빈 비밀키는 무조건 실패입니다. 설정이 비어 있을 때 hash_hmac('sha256', $body, '')는 정상적으로 값을 돌려주므로, 검사를 빼먹으면 "비밀키 없이도 서명이 맞는" 상태가 됩니다. 라라벨 코리아의 컨트롤러는 한 단계 앞에서 설정이 비어 있으면 503으로 응답합니다.
  • hash_equals는 인자 순서가 있습니다. 첫 번째가 우리가 아는 값, 두 번째가 사용자가 보낸 값입니다. 길이가 다르면 즉시 false를 돌려주되, 같은 길이에서는 모든 바이트를 비교해 비교 시간이 내용에 따라 달라지지 않게 합니다. ==나 ===는 첫 번째로 다른 바이트에서 멈추므로 이론상 한 바이트씩 맞춰 가는 공격에 정보를 줍니다.
  • sign()을 공개한 이유는 테스트에서 "올바른 서명"을 만들 때 검증 코드와 같은 규칙을 쓰기 위해서입니다. 테스트가 자기만의 서명 계산을 따로 가지면 둘 다 틀렸을 때 통과합니다.

2. 본문은 반드시 getContent()로 읽는다

가장 흔한 실수는 이것입니다.

// 잘못된 예: 파싱한 배열을 다시 JSON으로 만들어 서명한다 $expected = 'sha256='.hash_hmac('sha256', json_encode($request->all()), $secret);

서비스는 자기가 보낸 바이트 그대로를 서명합니다. json_encode는 공백, 키 순서, 유니코드 이스케이프, 슬래시 처리가 원본과 다를 수 있어 정상 요청을 거부하게 됩니다. 반대로 어떤 입력에서는 우연히 같아져 "가끔만 실패하는" 버그가 됩니다. $request->getContent()는 파싱 전 원본 문자열을 돌려주므로 이것으로 계산해야 합니다.

같은 이유로 서명 검증은 미들웨어나 컨트롤러의 첫 줄에서 끝내야 합니다. $request->all(), validate(), 모델 조회가 그 앞에 있으면 서명이 틀린 요청에도 DB를 건드리게 됩니다.

3. 거부 경로 네 가지를 테스트로 고정한다

아래 테스트는 라우트를 테스트 안에서 직접 등록하므로 저장소에 이미 있는 엔드포인트와 무관하게 실행됩니다. tests/Feature/WebhookSignatureTest.php에 넣습니다.

<?php use App\Support\WebhookSignature; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; test('a webhook endpoint accepts a correctly signed body and rejects everything else', function () { $secret = 'test-secret'; Route::post('/example/webhook', function (Request $request) use ($secret) { $valid = WebhookSignature::verify( $request->getContent(), $request->header('X-Hub-Signature-256'), $secret, ); return $valid ? response('OK', 200) : response('Invalid signature', 401); }); $payload = '{"ref":"refs/heads/13.x","repository":{"full_name":"laravel/docs"}}'; $deliver = fn (string $body, ?string $signature) => $this->call( 'POST', '/example/webhook', [], [], [], array_filter([ 'HTTP_X-Hub-Signature-256' => $signature, 'CONTENT_TYPE' => 'application/json', ]), $body, ); $deliver($payload, WebhookSignature::sign($payload, $secret))->assertOk(); $deliver($payload, null)->assertStatus(401); $deliver('{"ref":"refs/heads/13.x","repository":{"full_name":"evil/docs"}}', WebhookSignature::sign($payload, $secret)) ->assertStatus(401); $deliver('{"ref": "refs/heads/13.x", "repository": {"full_name": "laravel/docs"}}', WebhookSignature::sign($payload, $secret)) ->assertStatus(401); $deliver($payload, WebhookSignature::sign($payload, 'another-secret'))->assertStatus(401); expect(WebhookSignature::verify($payload, WebhookSignature::sign($payload, ''), ''))->toBeFalse(); });

다섯 번의 호출이 각각 다른 실패를 대표합니다.

호출무엇이 다른가기대 응답
정상 본문 + 정상 서명기준200
서명 헤더 없음헤더 누락401
본문의 저장소 이름이 바뀜본문 변조401
내용은 같지만 공백만 추가된 JSON재직렬화401
다른 비밀키로 만든 서명키 불일치401

네 번째 경우가 중요합니다. 두 본문을 json_decode하면 같은 배열이지만 서명은 다릅니다. 이 테스트가 통과한다는 것은 검증이 원본 바이트를 쓰고 있다는 뜻입니다. 2절의 잘못된 코드로 바꾸면 이 행이 실패하거나, 더 나쁘게는 첫 행이 실패합니다.

php artisan test --compact tests/Feature/WebhookSignatureTest.php

4. 서명이 맞아도 끝이 아니다

서명은 "비밀키를 아는 쪽이 이 바이트를 보냈다"만 보장합니다. 다음은 별도로 결정해야 합니다.

  • 재전송. 같은 요청을 가로채 다시 보내면 서명은 여전히 유효합니다. GitHub는 X-GitHub-Delivery 헤더에 전달 ID를 넣어 주므로, 처리한 ID를 저장해 두 번째 전달은 200으로 받되 아무 일도 하지 않게 만들 수 있습니다. 큐 작업이 중복 실행돼도 결과가 한 번만 남게 하는 방법은 큐 멱등성 가이드에서 다룹니다.
  • 이벤트 종류와 출처. 라라벨 코리아의 엔드포인트는 서명 검증 뒤에 X-GitHub-Event가 push인지, 저장소가 laravel/docs인지, 브랜치가 감시 대상인지 차례로 확인하고 해당하지 않으면 200으로 조용히 끝냅니다. 4xx로 응답하면 서비스가 재시도를 반복할 수 있어서입니다.
  • 응답 속도. 대부분의 서비스는 몇 초 안에 응답이 없으면 실패로 기록합니다. 실제 작업은 큐에 넘기고 즉시 200을 돌려주세요.
  • 웹훅 경로의 CSRF. 외부 서비스는 CSRF 토큰을 보낼 수 없으므로 해당 경로를 CSRF 검사에서 제외해야 합니다. 서명 검증이 그 자리를 대신합니다.

운영 체크리스트

  • 비밀키는 설정에서 읽고, 비어 있으면 요청을 받지 않습니다.
  • 검증은 본문 파싱·DB 조회보다 먼저 실행됩니다.
  • getContent()로 계산하고 hash_equals(기대값, 받은값) 순서를 지킵니다.
  • 서명 누락·변조·재직렬화·키 불일치 네 경우가 테스트에 있습니다.
  • 전달 ID로 중복 전달을 걸러내는지 결정했습니다.

출처와 검증 범위

GitHub 웹훅 검증 문서의 헤더 이름과 형식, PHP hash_equals 문서의 인자 순서, Laravel 13 HTTP 테스트의 요청 작성 방법을 대조했습니다. 본문의 클래스와 테스트는 이 사이트의 회귀 테스트에서 그대로 실행됩니다. AI 작성·원문 대조이며, 특정 서비스의 재시도 정책이나 운영 환경의 타이밍 공격 저항성을 측정한 것은 아닙니다.