웹훅 서명 검증4분 분량
Python에서 웹훅 서명 검증하기
standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.
무엇이든 파싱하기 전에 요청 본문을 바이트로 읽으세요. 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가 핸들러 실행 전에 JSON을 파싱하고 검증합니다.
- 모델을 다시 직렬화하면 다른 바이트가 되므로 서명이 실패합니다.
- Request를 받아 request.body()를 await하면 정확한 바이트를 얻습니다.
- 핸들러가 이를 검증하고 json.loads로 파싱한 뒤 204로 응답하고 느린 작업은 BackgroundTasks에 넘깁니다.
standardwebhooks를 쓰는 Flask
pip install standardwebhooks로 패키지를 설치하세요.
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 "", 204request.get_data()는 본문을 바이트로 반환합니다. Flask가 캐시하므로 나중에get_json()을 호출해도 동작합니다.as_text=True는 넘기지 마세요. 먼저 디코딩하면 나중에 정확히 되돌려야 하는 단계가 생깁니다.Webhook(secret)은 전체whsec_시크릿을 받아 접두사를 직접 뗍니다. 요청마다가 아니라 import 시점에 한 번 만드세요.verify는 헤더 이름을 소문자로 바꾸므로 프록시가 어떤 대소문자를 썼든dict(request.headers)가 동작합니다. 타임스탬프와 모든v1서명을 확인하고 파싱된 JSON을 반환합니다.
표준 라이브러리를 쓰는 FastAPI
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")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 테스트
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
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은 한 단계 뒤에서 여전히 유용합니다. 바이트를 검증한 뒤 같은 바이트를 모델로 검증하세요. 서명 확인을 포기하지 않고도 페이로드에 타입이 지정된 방식으로 접근하고, 의존하는 필드가 없으면 명확한 오류를 받습니다.
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가 두 번째를 아무 일도 하지 않게 만듭니다.
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에는 문제없습니다. 표준 라이브러리 버전은 바이트를 직접 해시하므로 어떤 디코딩에도 의존하지 않습니다.작동 확인하기
pytest를 실행하세요. 네 테스트가 모두 통과합니다.- 공개
https:URL 뒤에서 앱을 시작하고 대시보드의 Webhooks에 추가하세요. IKIFORM_WEBHOOK_SECRET을 엔드포인트의whsec_시크릿으로 설정하고 프로세스를 재시작하세요.- Send test를 고르세요. 전달 로그에
204가 표시되고, 애플리케이션 로그에dlv_id가 있는endpoint.test이벤트가 표시됩니다. 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로 동기화하세요. 서명 자체는 괜찮을 수 있습니다 |
질문
Request 객체를 매개변수로 받아 await request.body()를 호출하세요. 웹훅 라우트에서는 Pydantic 모델을 본문 매개변수로 선언하지 마세요. 검증하기 전에 JSON을 파싱합니다.
네. pip install standardwebhooks로 설치하고 대시보드의 whsec_ 시크릿으로 Webhook(secret).verify(body, headers)를 호출하세요. 확인에 실패하면 WebhookVerificationError를 발생시킵니다.
==는 한 글자라도 다르면 바로 반환할 수 있어, 추측이 얼마나 맞았는지에 대한 타이밍 정보가 새어 나갑니다. compare_digest는 항상 같은 시간이 걸립니다.
네. Django의 CSRF 미들웨어는 CSRF 토큰이 없는 POST를 거부하며, Ikiform은 토큰을 보내지 않습니다. 대신 서명 확인이 뷰를 보호합니다.
hmac.new는 키와 메시지 모두 bytes가 필요합니다. bytes를 반환하는 base64.b64decode로 시크릿을 디코딩하고, 메시지는 f"{msg_id}.{timestamp}.".encode() + body로 만드세요. 여기서 body는 요청의 원본 바이트입니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.