웹훅 서명 검증

웹훅 서명 검증4분 분량

Python에서 웹훅 서명 검증하기

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

Preet SutharIkiform 창업자

무엇이든 파싱하기 전에 요청 본문을 바이트로 읽으세요. 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는 테스트 벡터 기반 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 핸들러와 원본 요청 본문을 읽는 핸들러의 비교본문 매개변수로 쓴 Pydantic 모델await request.body() 호출요청 바이트파싱되고 검증됨내 코드가 실행되기 전에model.model_dump_json()다른 바이트400요청 바이트바이트보낸 그대로검증 후json.loads204, 작업은백그라운드에서Pydantic 본문 매개변수가 있는 FastAPI 핸들러와 원본 요청 본문을 읽는 핸들러의 비교본문 매개변수로 쓴 Pydantic 모델await request.body() 호출요청 바이트파싱되고 검증됨내 코드가 실행되기 전에model.model_dump_json()다른 바이트400요청 바이트바이트보낸 그대로검증 후 json.loads204, 작업은 백그라운드에서
  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
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
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
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
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
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
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
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 ForbiddenCSRF 미들웨어 때문입니다. 웹훅 뷰에만 @csrf_exempt를 추가하세요
표준 라이브러리 버전의 missing_headers일반 dict 헤더가 서버의 대소문자를 유지했습니다. 프레임워크의 대소문자 구분 없는 헤더 객체를 쓰거나 키를 소문자로 바꾸세요
시작 시 binascii.Error: Incorrect padding시크릿을 끝의 = 없이 복사했습니다. 전체 whsec_ 값을 다시 복사하세요
모든 전달에서 timestamp_too_old서버 시계가 5분 넘게 어긋났습니다. NTP로 동기화하세요. 서명 자체는 괜찮을 수 있습니다

질문

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