가이드

가이드9분 분량

폼 웹훅 서명 검증하기

폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.

Preet SutharIkiform 창업자

서명을 직접 다시 계산해 요청에 담긴 서명과 비교하세요. Ikiform은 Standard Webhooks를 따릅니다. 서명은 webhook-id, webhook-timestamp, 원본 본문을 점으로 이은 것의 base64 HMAC-SHA256이며, whsec_ 시크릿의 base64 디코딩한 부분을 키로 쓰고, v1,<signature> 형태로 전송됩니다. 상수 시간 비교에서 v1 서명 하나가 일치하고 타임스탬프가 5분 이내일 때만 요청을 받으세요.

웹훅 엔드포인트는 누구의 POST 요청이든 받는 공개 URL입니다. 서명 확인이 없으면 URL을 알게 된 누구나 CRM, 데이터베이스, 팀 채팅에 가짜 제출을 보낼 수 있습니다. 확인은 코드 몇 줄이며, 이 가이드는 그 몇 줄을 정확히 맞게 쓰는 방법을 다룹니다.

서명이 동작하는 방식

Ikiform과 서버는 엔드포인트를 추가할 때 한 번 표시되는 시크릿 하나를 공유합니다. Ikiform은 매 전달 시도 전에 그 시크릿으로 요청을 해시하고 결과를 헤더에 넣습니다. 서버는 받은 것에 대해 같은 계산을 반복합니다. 시크릿을 가진 쪽만 일치하는 값을 만들 수 있으며, 본문, id, 타임스탬프가 조금이라도 바뀌면 일치하지 않습니다.

Ikiform이 웹훅 전달에 서명하는 방식과 서버가 이를 확인하는 방식Ikiform, 매 시도마다내 핸들러JSON 본문submission.createdid.timestamp.body서명 대상 콘텐츠HMAC-SHA256각 활성 시크릿으로webhook-signature헤더v1,<base64>원본 본문 + 헤더파싱하기 전에같은 HMAC내 시크릿 사본으로상수 시간 비교v1 하나라도 일치,타임스탬프 최신2xx그다음 파싱하고 처리400처리하지 않음불일치Ikiform이 웹훅 전달에 서명하는 방식과 서버가 이를 확인하는 방식Ikiform, 매 시도마다내 핸들러JSON 본문submission.createdid.timestamp.body서명 대상 콘텐츠HMAC-SHA256각 활성 시크릿으로webhook-signature헤더v1,<base64>원본 본문 + 헤더파싱하기 전에같은 HMAC내 시크릿 사본으로상수 시간 비교v1 하나라도 일치, 타임스탬프 최신2xx그다음 파싱하고 처리400처리하지 않음불일치
  1. Ikiform이 이벤트를 JSON으로 직렬화합니다.
  2. 전달 id, 시도의 Unix 타임스탬프, 본문을 점으로 잇습니다.
  3. 엔드포인트의 각 활성 시크릿으로 그 문자열의 HMAC-SHA256을 계산합니다.
  4. 각 결과를 base64로 인코딩하고 v1 접두사를 붙여 webhook-signature 헤더로 보냅니다.
  5. 핸들러는 무엇이든 파싱하기 전에 원본 본문과 세 헤더를 읽습니다.
  6. 자신이 가진 시크릿 사본으로 같은 HMAC을 계산합니다.
  7. 모든 v1 서명과 상수 시간으로 비교하고 타임스탬프가 5분 이내인지 확인합니다.
  8. 일치하면 2xx로 응답하고 이벤트를 처리합니다. 그렇지 않으면 400으로 응답하고 아무것도 하지 않습니다.
요청과 함께 비밀 정보가 전송되지는 않습니다. 서명은 보낸 쪽이 시크릿을 알았다는 것과 바이트가 도중에 바뀌지 않았다는 것을 증명합니다.

방식을 정확히

Ikiform은 여러 웹훅 제공자가 공유하는 공개 명세인 Standard Webhooks를 씁니다. 모든 전달에는 세 헤더가 담깁니다.

헤더값참고
webhook-iddlv_...전달 id입니다. 재시도마다 같으므로 멱등성 키로도 쓰입니다
webhook-timestamp1792300000 같은 Unix 초이 시도가 서명된 시각입니다. 재시도마다 새로 바뀝니다
webhook-signaturev1,에 base64를 붙인 값공백으로 구분된 하나 이상의 값입니다. 시크릿 교체 중에는 두 개입니다
서명 대상
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature

전달을 검증하려면 이 순서로 하세요.

  1. 원본 본문을 바이트나 정확한 문자열로 읽으세요. 아직 파싱하지 마세요.
  2. 세 헤더를 읽으세요. 하나라도 없으면 거부하세요.
  3. 타임스탬프가 정수이고 내 시계와 앞뒤 5분 이내인지 확인하세요. 아니면 거부하세요.
  4. 시크릿에서 whsec_를 떼고 나머지를 base64 디코딩하세요. 문자열 자체가 아니라 그 32바이트가 HMAC 키입니다.
  5. {webhook-id}.{webhook-timestamp}.{raw body}의 HMAC-SHA256을 계산하고 표준 알파벳과 패딩으로 base64 인코딩하세요.
  6. webhook-signature를 공백으로 나누세요. 각 항목을 첫 번째 쉼표에서 나누고, 버전이 v1이 아닌 것은 건너뛰세요.
  7. 각 v1 값을 내 값과 상수 시간으로 비교하세요. 하나라도 일치하면 진짜 전달입니다.
  8. 그제서야 JSON을 파싱하고 처리하세요.

테스트 벡터

핸들러를 배포하기 전에 올바른 값을 계산하는지 증명하세요. 이 입력은 지어낸 시크릿으로 서명한 실제 endpoint.test 전달 본문입니다. 코드는 마지막 행의 서명을 정확히 만들어야 합니다. 각 언어 가이드에 이를 쓰는 단위 테스트가 있습니다.

입력값
시크릿whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
원본 본문아래 스크립트에 있는 정확한 바이트이며, 공백이나 줄바꿈이 없습니다
예상 webhook-signaturev1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=

코드를 쓰지 않고도 확인할 수 있습니다. macOS나 Linux 터미널에서 실행하면 서명의 base64 부분이 출력됩니다.

터미널
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
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."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64

가장 흔한 실제 실패 두 가지이므로 두 경우를 더 테스트할 가치가 있습니다.

변경서명핸들러가 받아야 하나요?
같은 본문을 두 칸 들여쓰기로 보기 좋게 출력한 것v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=아니요. JSON은 같지만 바이트가 다릅니다
1초 뒤의 webhook-timestampv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=아니요. 타임스탬프도 서명됩니다
교체 중 두 번째 시크릿으로 서명된 것v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=예, 헤더에 내 시크릿과 일치하는 값도 있다면

시크릿 교체 중에는 헤더에 두 값이 담깁니다. 둘 중 어느 시크릿을 가진 핸들러든 이를 받아야 합니다.

서명 두 개
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
벡터의 타임스탬프는 고정되어 있으므로 실제 검증기는 너무 오래되었다고 거부합니다. 테스트는 고정된 현재 시각을 넘겨야 하며, 언어 가이드가 그렇게 합니다. 웹훅 페이지의 서명 검증기는 브라우저에서 어떤 시크릿, 헤더, 본문이든 확인합니다.

검증 방법

방식잘하는 것단점
@ikiform/sdk/webhooks의 verifyWebhook모든 것을 확인하고 타입이 지정된 이벤트를 반환하는 호출 하나입니다. Node, Workers, Deno, Bun에서 실행됩니다JavaScript와 TypeScript만 지원
Standard Webhooks 라이브러리Python, Go, PHP, Ruby, JavaScript, Rust, Java, C#, Elixir용으로 유지 관리되는 코드의존성이 하나 늘어납니다. 각 라이브러리가 헤더를 어떻게 넘겨받는지 확인하세요. 일부는 대소문자를 구분합니다
직접 작성한 HMAC 코드의존성 없이 약 20줄이며, 모든 분기를 테스트할 수 있습니다미묘하게 틀리기 쉽습니다. 문자열 비교, 잘못된 키 바이트, 파싱된 본문 같은 문제입니다
대신 비밀 경로나 쿼리 토큰간단함로그, 프록시, 오류 추적기를 통해 새어 나가고, 본문에 대해 아무것도 증명하지 못하며, 만료시킬 수도 없습니다. 대체 수단이 아닙니다

Ikiform은 모든 전달에 서명하며, 서명이 전달을 인증하는 공식 방법입니다. 문서에 고정된 발신 IP 주소 목록을 공개하지 않으므로 IP 허용 목록은 믿을 만한 확인이 아닙니다.

프레임워크에서 검증하기

JavaScript 스택에서는 SDK가 일을 합니다. 프레임워크마다 다른 것은 라우트가 어디에 있는지, 그리고 무엇이든 파싱하기 전에 본문을 어떻게 얻는지입니다. 스택을 고르세요.

App Router 라우트 핸들러입니다. request.text()가 원본 본문이며, request.json()을 먼저 호출하면 이를 잃습니다. 자체 서버 가이드가 중복 제거와 after()를 더합니다.

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}

모든 핸들러는 성공하면 204로, 그렇지 않으면 실패 사유와 함께 400으로 응답합니다. 느린 작업은 응답한 뒤에 하세요. Ikiform은 15초를 기다린 뒤 시도를 실패로 집계합니다.

원본 본문이 중요한 이유

서명은 의미가 아니라 바이트를 대상으로 합니다. {"a":1}과 { "a": 1 }은 같은 JSON이지만 바이트가 다르므로, 파싱했다가 다시 직렬화한 본문은 거의 일치하지 않습니다. 들어오는 길에 본문을 건드리는 모든 것도 마찬가지입니다. JSON을 파싱하는 미들웨어, 다시 인코딩하는 프록시, 문자열 대신 객체를 넘겨주는 프레임워크가 그렇습니다.

본문을 바이트나 문자열로 한 번 읽어 검증한 뒤, 그 같은 문자열을 파싱하세요. 각 언어 가이드가 해당 프레임워크에서 원본 본문이 어디 있는지 보여 주고, 자체 서버 가이드에 흔한 Node와 Python 프레임워크용 표가 있습니다.

상수 시간으로 비교하기

일반 문자열 비교는 처음 다른 문자에서 멈춥니다. 이론상 수천 개의 요청 시간을 잴 수 있는 공격자는 추측의 앞부분이 몇 글자나 맞는지 알아내 한 글자씩 유효한 서명을 만들 수 있습니다. 상수 시간 비교는 얼마나 일치하든 같은 시간이 걸립니다. 모든 언어에 있습니다.

언어상수 시간 비교
Node.jscrypto.timingSafeEqual(a, b)이며, 길이가 다르면 예외를 던지므로 길이를 먼저 확인하세요
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.secure_compare(a, b), Rails에서는 ActiveSupport::SecurityUtils.secure_compare

타임스탬프와 재전송

한 번 가로챈 유효한 요청은 무언가가 만료시키지 않는 한 영원히 유효합니다. 서명된 타임스탬프가 바로 그것입니다. 내 시계와 앞뒤로 5분 넘게 차이 나는 전달은 거부하세요. Ikiform은 매 시도마다 새 타임스탬프로 다시 서명하므로 사흘 뒤의 재시도도 통과하고, 복사한 오래된 요청만 실패합니다.

그 5분 안에서는 재전송이 여전히 가능하고, 진짜 중복도 생길 수 있습니다. Ikiform은 핸들러가 끝났더라도 제시간에 2xx를 받지 못하면 재시도합니다. 고유 제약 조건과 함께 webhook-id를 저장하고 이미 처리한 id는 건너뛰세요. 그러면 둘 다 막힙니다.

서버 시계를 동기화된 상태로 유지하세요. 5분 넘게 어긋난 시계는 모든 전달을 timestamp_too_old나 timestamp_too_new로 거부합니다. 관리형 플랫폼은 대개 시계를 동기화하고, 자체 VM에서는 NTP를 실행하세요.

시크릿 교체하기

시크릿을 알던 사람이 떠날 때, 로그에 남거나 커밋되었을 수 있을 때, 또는 정기적으로 교체하세요. 엔드포인트 페이지의 Roll signing secret(서명 시크릿 교체)이 새 시크릿을 만들고, 24시간 동안 모든 전달이 둘 다로 서명됩니다.

시크릿 교체 전, 중, 후에 webhook-signature에 담기는 것교체 전v1,<old>교체새 시크릿은 한 번만표시다음 24시간v1,<old>v1,<new>교체 후v1,<new>만새 시크릿 배포시크릿 교체 전, 중, 후에 webhook-signature에 담기는 것교체 전v1,<old>교체새 시크릿은 한 번만 표시다음 24시간v1,<old> v1,<new>교체 후v1,<new>만새 시크릿 배포
  1. 교체 전에는 각 전달에 이전 시크릿으로 만든 서명 하나가 담깁니다.
  2. Roll signing secret을 고르고 새 시크릿을 복사합니다.
  3. 다음 24시간 동안 각 전달에 서명 두 개가 담기므로 어느 시크릿을 가진 서버든 이를 받습니다. 이 기간에 새 시크릿을 배포하세요.
  4. 24시간이 지나면 전달에 새 서명만 담기며, 여전히 이전 시크릿을 가진 서버는 이를 거부합니다.
핸들러가 첫 번째 항목만이 아니라 모든 v1 항목을 확인한다면 이를 위한 특별한 코드는 필요 없습니다.

24시간 뒤에도 이전 시크릿이 배포되어 있으면 모든 전달이 실패합니다. Ikiform은 각 전달을 약 사흘에 걸쳐 8번 재시도하므로, 올바른 시크릿을 배포한 뒤 이미 포기한 전달은 전달 로그의 Retry를 쓰세요.

확인을 통과한 뒤

유효한 서명은 요청이 Ikiform에서 왔고 바뀌지 않았다는 것을 증명합니다. 내용이 안전하거나 새롭다는 것을 증명하지는 않습니다. 세 가지 습관으로 마무리하세요.

  • 반복 건너뛰기. 고유 제약 조건과 함께 webhook-id나, 같은 값인 페이로드의 id를 저장하세요. 이미 처리한 전달의 재시도는 아무 일도 하지 않게 됩니다.
  • 먼저 응답하고 나중에 작업하기. 이벤트를 저장하거나 큐에 넣는 즉시 2xx를 반환하세요. Ikiform은 15초보다 느린 것은 실패로 집계하고 재시도하며, 그러면 중복 문제가 다시 생깁니다.
  • 필드를 신뢰하지 않기. data.fields에는 방문자가 공개 폼에 입력한 내용이 담겨 있습니다. HTML, 채팅 메시지, 이메일에 렌더링하기 전에 이스케이프하고, 매개변수화하지 않은 채 셸이나 쿼리에 넘기지 마세요.

실제 운영 전에 테스트하기

Ikiform은 443 포트의 공개 https: URL로만 전달하므로 localhost는 웹훅을 받을 수 없습니다. cloudflared tunnel --url http://localhost:3000 같은 터널을 실행하고, 작업하는 동안 출력된 URL을 두 번째 엔드포인트로 추가하세요. 엔드포인트 페이지의 Send test는 실제 전달과 똑같이 서명된 endpoint.test 이벤트를 재시도 없이 한 번 보냅니다. 끝나면 테스트 엔드포인트를 삭제하세요. 자체 시크릿이 있으므로 다른 것은 바뀌지 않습니다.

전달이 실패했는데 이유를 모르겠다면 로그에서 헤더와 본문을 복사해 서명 검증기에 넣으세요. 브라우저에서 같은 확인을 실행하고 어느 단계에서 실패했는지 알려 줍니다.

검증이 잘못되는 다섯 가지 경우

실수증상해결
whsec_... 문자열 전체를 키로 사용모든 전달에서 no_matching_signaturewhsec_를 떼고 base64 디코딩하세요. 키는 32바이트입니다
파싱된 본문을 검증no_matching_signature, 때로는 일부 페이로드에서만원본 본문을 검증하고 그 뒤에 파싱하세요
첫 번째 서명만 확인교체 직후 시작되는 실패공백으로 구분된 모든 v1 항목을 순회하세요
base64 대신 hexno_matching_signatureStandard Webhooks는 hex나 base64url이 아니라 패딩이 있는 표준 base64를 씁니다
정확한 대소문자로 헤더 조회일부 프록시 뒤에서 missing_headers헤더 이름은 대소문자를 구분하지 않습니다. Ikiform은 소문자로 보내니 대소문자 구분 없이 조회하세요

원클릭 통합은 없습니다. 모든 도구는 서명된 웹훅으로, 직접 또는 Zapier나 n8n 같은 자동화 도구를 거쳐 연결합니다. 워크스페이스에는 각자 시크릿이 있는 엔드포인트를 3개까지 둘 수 있습니다. 전체 페이로드, 재시도 일정, 전달 로그는 웹훅에 있습니다.

상황별 가이드

각 언어 페이지에는 관용적인 핸들러, 라이브러리 옵션, 이 페이지의 테스트 벡터를 실행하는 단위 테스트가 있습니다.

작동 확인하기

먼저 스테이징 엔드포인트에서, 그다음 프로덕션에서 다시 하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.

  1. 테스트 벡터를 코드에 통과시키세요. 예상 서명을 만들고, 서명 두 개짜리 헤더는 받고, 보기 좋게 출력한 본문은 거부해야 합니다.
  2. 대시보드에서 Webhooks를 열고 엔드포인트의 공개 https: URL을 추가한 뒤 whsec_ 시크릿을 서버 환경에 복사하세요.
  3. 엔드포인트 페이지에서 Send test를 고르세요. 전달 로그에 204가 표시되고 이벤트가 로그에 도착합니다.
  4. 서버의 시크릿을 한 글자 바꾸고 테스트를 다시 보내세요. 로그에 no_matching_signature와 함께 400이 표시됩니다.
  5. 실제로 폼을 제출하고 submission.created 전달이 성공하는지 확인하세요.

문제 해결

문제해결 방법
400 no_matching_signature잘못된 시크릿, 파싱된 본문, 또는 base64 디코딩하지 않은 키입니다. 같은 코드 경로로 테스트 벡터를 실행해 어느 것인지 확인하세요.
400 timestamp_too_old 또는 timestamp_too_new서버 시계가 어긋났거나, 저장한 요청을 직접 재전송하고 있습니다. 시계를 동기화하고, 다시 서명하는 전달 로그의 Retry를 쓰세요.
400 missing_headers프록시나 프레임워크가 webhook-* 헤더를 떨어뜨렸거나, 요청이 Ikiform에서 오지 않았습니다. 핸들러가 보는 모든 헤더 이름을 로그로 남기세요.
테스트 이벤트는 되지만 실제 제출은 실패함실제 페이로드에는 비ASCII 문자와 더 긴 텍스트가 있습니다. 코드가 검증 전에 본문을 디코딩하거나 다시 인코딩합니다. 정확한 바이트를 검증하세요.
시크릿을 교체한 직후 모든 전달이 실패함핸들러가 첫 번째 서명만 확인하거나, 이전 시크릿이 배포된 채로 24시간이 지났습니다. 모든 v1 항목을 확인하고 새 시크릿을 배포하세요.
전달 로그에는 타임아웃이 표시되지만 핸들러는 실행됨Ikiform은 15초를 기다립니다. 먼저 응답하고 느린 작업은 나중에 하며, 반복된 webhook-id 값은 건너뛰세요.

질문

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