# Python에서 웹훅 서명 검증하기

> standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.

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

무엇이든 파싱하기 전에 요청 본문을 바이트로 읽으세요. Flask에서는 `request.get_data()`, FastAPI에서는 `await request.body()`, Django에서는 `request.body`입니다. 그 바이트와 헤더를 `standardwebhooks` 패키지의 `Webhook(secret).verify()`에 넘기거나, `hmac`으로 HMAC-SHA256을 직접 계산하고 `hmac.compare_digest`로 비교하세요. 실패하면 `400`으로 응답하고, 통과한 뒤에만 JSON을 파싱하세요.

프레임워크 셋, 검증 방법 둘. Flask와 Django는 `standardwebhooks` 패키지를 쓰고, FastAPI는 [테스트 벡터](https://ikiform.com/ko/guides/webhook-signature-verification.md#test-vector) 기반 pytest 모음이 있는 표준 라이브러리 함수를 씁니다. 원하는 대로 섞으세요. 함수와 패키지는 서로 바꿔 쓸 수 있습니다.

## Python에서 다른 점

Python 프레임워크는 원본처럼 보이지만 가끔 그렇지 않은 형태로 본문을 줍니다. FastAPI가 가장 날카롭습니다. Pydantic 모델을 매개변수로 선언하면 FastAPI가 함수 실행 전에 본문을 읽고, 파싱하고, 검증합니다. 모델을 받게 되고, Ikiform이 서명한 바이트는 손에 들어오지 않습니다. 같은 함정이 Flask의 `request.get_json()`과 Django REST framework의 `request.data`에도 있습니다. Starlette와 FastAPI에서 본문은 `await request.body()`가 읽고 캐시하는 스트림이므로, 무언가 `request.stream()`을 순회하기 전에 호출하세요.

다이어그램: Pydantic 본문 매개변수가 있는 FastAPI 핸들러와 원본 요청 본문을 읽는 핸들러의 비교

1. Pydantic 모델을 본문 매개변수로 쓰면 FastAPI가 핸들러 실행 전에 JSON을 파싱하고 검증합니다.
2. 모델을 다시 직렬화하면 다른 바이트가 되므로 서명이 실패합니다.
3. Request를 받아 request.body()를 await하면 정확한 바이트를 얻습니다.
4. 핸들러가 이를 검증하고 json.loads로 파싱한 뒤 204로 응답하고 느린 작업은 BackgroundTasks에 넘깁니다.

모델이 필요하다면 검증한 뒤 Pydantic으로 확인하세요. Event.model_validate_json(body)입니다.

## standardwebhooks를 쓰는 Flask

`pip install standardwebhooks`로 패키지를 설치하세요.

`app.py`:

```python
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@app.post("/webhooks/ikiform")
def ikiform_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError as error:
        return str(error), 400
    if event["type"] == "submission.created":
        app.logger.info("submission %s %s", event["id"], event["data"]["fields"])
    return "", 204
```

- `request.get_data()`는 본문을 바이트로 반환합니다. Flask가 캐시하므로 나중에 `get_json()`을 호출해도 동작합니다. `as_text=True`는 넘기지 마세요. 먼저 디코딩하면 나중에 정확히 되돌려야 하는 단계가 생깁니다.
- `Webhook(secret)`은 전체 `whsec_` 시크릿을 받아 접두사를 직접 뗍니다. 요청마다가 아니라 import 시점에 한 번 만드세요.
- `verify`는 헤더 이름을 소문자로 바꾸므로 프록시가 어떤 대소문자를 썼든 `dict(request.headers)`가 동작합니다. 타임스탬프와 모든 `v1` 서명을 확인하고 파싱된 JSON을 반환합니다.

## 표준 라이브러리를 쓰는 FastAPI

`signatures.py`:

```python
import base64
import binascii
import hashlib
import hmac
import re
import time
from collections.abc import Mapping

TOLERANCE_SECONDS = 5 * 60


class InvalidSignature(Exception):
    pass


def verify_signature(body: bytes, headers: Mapping[str, str], secret: str, now: float | None = None) -> None:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        raise InvalidSignature("missing_headers")
    if not re.fullmatch(r"[0-9]{1,12}", timestamp):
        raise InvalidSignature("invalid_timestamp")

    age = int(time.time() if now is None else now) - int(timestamp)
    if age > TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_old")
    if age < -TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_new")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1":
            continue
        try:
            received = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if hmac.compare_digest(received, expected):
            return
    raise InvalidSignature("no_matching_signature")
```

`main.py`:

```python
import json
import logging
import os

from fastapi import BackgroundTasks, FastAPI, Request, Response

from signatures import InvalidSignature, verify_signature

app = FastAPI()
log = logging.getLogger("webhooks")
SECRET = os.environ["IKIFORM_WEBHOOK_SECRET"]


def handle_submission(delivery_id: str, fields: dict) -> None:
    log.info("submission %s %s", delivery_id, fields)


@app.post("/webhooks/ikiform")
async def ikiform_webhook(request: Request, background: BackgroundTasks) -> Response:
    body = await request.body()
    try:
        verify_signature(body, request.headers, SECRET)
    except InvalidSignature as error:
        return Response(str(error), status_code=400)
    event = json.loads(body)
    if event["type"] == "submission.created":
        background.add_task(handle_submission, event["id"], event["data"]["fields"])
    return Response(status_code=204)
```

- 핸들러는 모델이 아니라 `Request`를 받고 `await request.body()`를 먼저 읽습니다.
- `request.headers`는 대소문자를 구분하지 않으므로 `.get("webhook-id")`가 어떻게 보내졌든 헤더를 찾습니다.
- `hmac.compare_digest`는 디코딩한 바이트를 상수 시간으로 비교합니다. `validate=True`는 형식이 잘못된 서명을 조용히 부분 디코딩된 값이 아니라 불일치로 만듭니다.
- `BackgroundTasks`는 `204`가 전송된 뒤 실행되므로 느린 작업이 Ikiform의 15초 대기에 포함되지 않습니다.
- 타입 힌트에는 Python 3.10 이상이 필요합니다.

## pytest 테스트

`test_signatures.py`:

```python
import json

import pytest

from signatures import InvalidSignature, verify_signature

SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
BODY = b'{"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


def test_accepts_the_vector():
    verify_signature(BODY, HEADERS, SECRET, now=NOW)


def test_accepts_either_secret_during_a_roll():
    both = {**HEADERS, "webhook-signature": HEADERS["webhook-signature"] + " v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak="}
    verify_signature(BODY, both, SECRET, now=NOW)
    verify_signature(BODY, both, ROLLED, now=NOW)


def test_rejects_a_reserialized_body():
    pretty = json.dumps(json.loads(BODY), indent=2).encode()
    with pytest.raises(InvalidSignature, match="no_matching_signature"):
        verify_signature(pretty, HEADERS, SECRET, now=NOW)


def test_rejects_a_stale_timestamp():
    with pytest.raises(InvalidSignature, match="timestamp_too_old"):
        verify_signature(BODY, HEADERS, SECRET, now=NOW + 600)
```

`pytest`를 실행하세요. 고정된 `NOW`가 벡터의 타임스탬프를 5분 범위 안에 둡니다. `json.dumps(indent=2)`는 Python 코드베이스에서 본문이 바뀌는 현실적인 방식이므로 이 경우는 자리를 차지할 가치가 있습니다.

## Django

`webhooks/views.py`:

```python
import os

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@csrf_exempt
@require_POST
def ikiform_webhook(request):
    try:
        event = webhook.verify(request.body, dict(request.headers))
    except WebhookVerificationError as error:
        return HttpResponseBadRequest(str(error))
    if event["type"] == "submission.created":
        print("submission", event["id"])
    return HttpResponse(status=204)
```

`request.body`가 원본 바이트입니다. Ikiform에는 CSRF 토큰이 없어 Django가 `403`으로 POST를 거부하므로 `@csrf_exempt`가 필요합니다. 이 뷰 하나에서는 서명 확인이 CSRF 보호를 대신합니다. `request.POST`를 건드리기 전에 `request.body`를 읽으세요. 이것은 폼 POST에만 해당하지만 스트림을 소비할 수 있습니다.

## 검증 후 타입이 지정된 이벤트

Pydantic은 한 단계 뒤에서 여전히 유용합니다. 바이트를 검증한 뒤 같은 바이트를 모델로 검증하세요. 서명 확인을 포기하지 않고도 페이로드에 타입이 지정된 방식으로 접근하고, 의존하는 필드가 없으면 명확한 오류를 받습니다.

`events.py`:

```python
from pydantic import BaseModel


class Form(BaseModel):
    id: str
    name: str


class SubmissionData(BaseModel):
    submission_id: str
    form: Form
    fields: dict[str, object]
    created_at: str
    truncated: bool


class SubmissionCreated(BaseModel):
    id: str
    type: str
    data: SubmissionData


def parse_submission(body: bytes) -> SubmissionCreated:
    return SubmissionCreated.model_validate_json(body)
```

Pydantic은 기본으로 추가 키를 무시하므로 Ikiform이 페이로드에 새 필드를 추가해도 모델이 깨지지 않습니다. `parse_submission(body)`는 `submission.created`에서만 호출하세요. `endpoint.test` 이벤트는 `data` 형태가 다릅니다.

## 반복 건너뛰기

Ikiform이 15초 안에 응답을 받지 못해 같은 `webhook-id`로 재시도하면 검증된 전달이 두 번 도착할 수 있습니다. Django에서는 고유 열과 `IntegrityError`가 두 번째를 아무 일도 하지 않게 만듭니다.

`webhooks/models.py`:

```python
from django.db import IntegrityError, models, transaction


class Delivery(models.Model):
    delivery_id = models.CharField(max_length=64, unique=True)
    received_at = models.DateTimeField(auto_now_add=True)


def first_delivery(delivery_id: str) -> bool:
    try:
        with transaction.atomic():
            Delivery.objects.create(delivery_id=delivery_id)
    except IntegrityError:
        return False
    return True
```

검증한 뒤 `first_delivery(event["id"])`를 호출하세요. `False`를 반환하면 `204`로 응답하고 멈추세요. `atomic` 블록은 실패한 삽입이 요청의 나머지 트랜잭션을 깨뜨리지 않게 합니다. SQLAlchemy에서는 `insert(...).on_conflict_do_nothing()`이 PostgreSQL과 SQLite에서 같은 일을 합니다.

> `standardwebhooks` 패키지의 `verify`는 해시하기 전에 본문을 UTF-8로 디코딩하며, Ikiform의 JSON에는 문제없습니다. 표준 라이브러리 버전은 바이트를 직접 해시하므로 어떤 디코딩에도 의존하지 않습니다.

## 작동 확인하기

1. `pytest`를 실행하세요. 네 테스트가 모두 통과합니다.
2. 공개 `https:` URL 뒤에서 앱을 시작하고 대시보드의 **Webhooks**에 추가하세요.
3. `IKIFORM_WEBHOOK_SECRET`을 엔드포인트의 `whsec_` 시크릿으로 설정하고 프로세스를 재시작하세요.
4. **Send test**를 고르세요. 전달 로그에 `204`가 표시되고, 애플리케이션 로그에 `dlv_` id가 있는 `endpoint.test` 이벤트가 표시됩니다.
5. `Zoë` 같은 악센트가 있는 이름으로 폼을 한 번 제출하고 온전하게 도착하는지 확인하세요.

## 주의할 점

| 문제 | 원인과 해결 |
| --- | --- |
| 본문 모델이 있는 FastAPI 핸들러가 항상 실패함 | 모델이 본문을 먼저 파싱했습니다. `Request`를 받아 `await request.body()`를 검증한 뒤 `Model.model_validate_json(body)`를 호출하세요 |
| Django의 `403 Forbidden` | CSRF 미들웨어 때문입니다. 웹훅 뷰에만 `@csrf_exempt`를 추가하세요 |
| 표준 라이브러리 버전의 `missing_headers` | 일반 `dict` 헤더가 서버의 대소문자를 유지했습니다. 프레임워크의 대소문자 구분 없는 헤더 객체를 쓰거나 키를 소문자로 바꾸세요 |
| 시작 시 `binascii.Error: Incorrect padding` | 시크릿을 끝의 `=` 없이 복사했습니다. 전체 `whsec_` 값을 다시 복사하세요 |
| 모든 전달에서 `timestamp_too_old` | 서버 시계가 5분 넘게 어긋났습니다. NTP로 동기화하세요. 서명 자체는 괜찮을 수 있습니다 |

## 질문

### FastAPI에서 원본 요청 본문은 어떻게 얻나요?

Request 객체를 매개변수로 받아 await request.body()를 호출하세요. 웹훅 라우트에서는 Pydantic 모델을 본문 매개변수로 선언하지 마세요. 검증하기 전에 JSON을 파싱합니다.

### standardwebhooks Python 패키지가 Ikiform과 동작하나요?

네. pip install standardwebhooks로 설치하고 대시보드의 whsec_ 시크릿으로 Webhook(secret).verify(body, headers)를 호출하세요. 확인에 실패하면 WebhookVerificationError를 발생시킵니다.

### == 대신 hmac.compare_digest를 쓰는 이유는 무엇인가요?

==는 한 글자라도 다르면 바로 반환할 수 있어, 추측이 얼마나 맞았는지에 대한 타이밍 정보가 새어 나갑니다. compare_digest는 항상 같은 시간이 걸립니다.

### Django 웹훅 뷰에 csrf_exempt가 필요한가요?

네. Django의 CSRF 미들웨어는 CSRF 토큰이 없는 POST를 거부하며, Ikiform은 토큰을 보내지 않습니다. 대신 서명 확인이 뷰를 보호합니다.

### HMAC을 계산할 때 bytes와 str에 관한 TypeError가 나는 이유는 무엇인가요?

hmac.new는 키와 메시지 모두 bytes가 필요합니다. bytes를 반환하는 base64.b64decode로 시크릿을 디코딩하고, 메시지는 f"{msg_id}.{timestamp}.".encode() + body로 만드세요. 여기서 body는 요청의 원본 바이트입니다.

## 가이드 더 보기

- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [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 라이브러리를 쓰세요.
- [PHP](https://ikiform.com/ko/guides/webhook-signature-verification/php.md): 일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.
- [Ruby](https://ikiform.com/ko/guides/webhook-signature-verification/ruby.md): Sinatra에서는 OpenSSL과 Minitest 테스트로, Rails에서는 standardwebhooks gem과 request.raw_post로 Ikiform 웹훅 서명을 검증하세요.
