웹훅 서명 검증

웹훅 서명 검증4분 분량

Ruby와 Rails에서 웹훅 서명 검증하기

Sinatra에서는 OpenSSL과 Minitest 테스트로, Rails에서는 standardwebhooks gem과 request.raw_post로 Ikiform 웹훅 서명을 검증하세요.

Preet SutharIkiform 창업자

원본 본문(Sinatra에서는 request.body.read, Rails에서는 request.raw_post)과 세 webhook-* 헤더를 읽으세요. base64 디코딩한 whsec_ 시크릿을 키로 OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")를 계산하고 base64로 인코딩한 뒤 OpenSSL.secure_compare로 각 v1 서명과 비교하세요. Rails에서는 standardwebhooks gem이 Webhook#verify에서 전체 확인을 합니다.

Sinatra는 Minitest 모음이 있는 의존성 없는 검증기를, Rails는 API 컨트롤러의 standardwebhooks gem과 반복을 처리하는 job을 씁니다. 모두 Rack 위에 있으므로 어느 검증기든 어느 프레임워크에서도, Hanami나 Roda에서도 동작합니다.

Ruby에서 다른 점

Rack은 모든 요청 헤더를 환경 키로 바꿉니다. webhook-id는 HTTP_WEBHOOK_ID가 됩니다. Rails는 이름을 대신 대응시켜 주는 request.headers["webhook-id"] 뒤에 이를 숨깁니다. Sinatra는 그렇지 않으므로 request.env["webhook-id"]는 항상 nil이고, nil 헤더를 받은 검증기는 Ikiform이 보냈는데도 missing_headers로 실패합니다. Rails에는 본문 함정의 자체 버전이 있습니다. application/json 콘텐츠 타입 때문에 JSON 본문을 params로 파싱하고, wrap_parameters가 컨트롤러 이름 아래로 키를 복사할 수 있습니다. 둘 다 검증할 유일한 값인 request.raw_post는 바꾸지 않습니다.

Sinatra와 Rails에서 Ikiform 웹훅 헤더가 도착하는 위치SinatraRailswebhook-id:dlv_... 헤더전송 중env["HTTP_WEBHOOK_ID"]Rack이 부르는 이름request.body.read원본 본문verify! 후JSON.parsewebhook-id:dlv_... 헤더전송 중request.headers["webhook-id"]대신 대응시켜 줌request.raw_post원본 본문Webhook#verifySinatra와 Rails에서 Ikiform 웹훅 헤더가 도착하는 위치SinatraRailswebhook-id: dlv_... 헤더전송 중env["HTTP_WEBHOOK_ID"]Rack이 부르는 이름request.body.read원본 본문verify! 후 JSON.parsewebhook-id: dlv_... 헤더전송 중request.headers["webhook-id"]대신 대응시켜 줌request.raw_post원본 본문Webhook#verify
  1. Ikiform이 소문자 webhook-id 헤더를 보냅니다.
  2. Sinatra에서는 Rack이 이를 요청 환경에 HTTP_WEBHOOK_ID로 저장하므로 그곳에서 읽으세요.
  3. Sinatra의 request.body.read는 원본 본문을 반환하며, JSON.parse 전에 검증됩니다.
  4. Rails에서는 request.headers가 헤더 이름을 Rack 키로 대응시켜 주고, request.raw_post가 gem이 검증할 원본 본문을 반환합니다.
같은 헤더, 두 가지 표기. 검증기에는 소문자 키를 가진 해시에 담긴 세 값만 있으면 됩니다.

OpenSSL을 쓰는 Sinatra

ikiform_signature.rb
require "openssl"

module IkiformSignature
  class Invalid < StandardError; end

  TOLERANCE = 5 * 60

  def self.verify!(body, headers, secret, now: Time.now.to_i)
    id = headers["webhook-id"].to_s
    timestamp = headers["webhook-timestamp"].to_s
    signatures = headers["webhook-signature"].to_s
    raise Invalid, "missing_headers" if id.empty? || timestamp.empty? || signatures.empty?
    raise Invalid, "invalid_timestamp" unless timestamp.match?(/\A[0-9]{1,12}\z/)

    age = now - timestamp.to_i
    raise Invalid, "timestamp_too_old" if age > TOLERANCE
    raise Invalid, "timestamp_too_new" if age < -TOLERANCE

    key = secret.delete_prefix("whsec_").unpack1("m0")
    expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
    matched = signatures.split(" ").any? do |entry|
      version, value = entry.split(",", 2)
      version == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
    end
    raise Invalid, "no_matching_signature" unless matched
  end
end
app.rb
require "json"
require "sinatra"
require_relative "ikiform_signature"

SECRET = ENV.fetch("IKIFORM_WEBHOOK_SECRET")

post "/webhooks/ikiform" do
  body = request.body.read
  headers = {
    "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
    "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
    "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"]
  }

  begin
    IkiformSignature.verify!(body, headers, SECRET)
  rescue IkiformSignature::Invalid => error
    halt 400, error.message
  end

  event = JSON.parse(body)
  logger.info("submission #{event["id"]}") if event["type"] == "submission.created"
  status 204
end
  • unpack1("m0")과 pack("m0")은 base64 라이브러리 없이 쓰는 엄격한 base64입니다. 이 라이브러리는 Ruby 3.4에서 기본 gem에서 빠졌으므로, 그렇지 않으면 Gemfile에 추가해야 합니다.
  • OpenSSL.secure_compare는 상수 시간이며 Ruby 3.0 이상이 필요합니다. 두 값을 먼저 해시하므로 길이가 달라도 괜찮습니다.
  • ASCII id와 바이너리 본문으로 서명 대상 문자열을 만드는 것은 안전합니다. Ruby는 본문의 바이트를 그대로 유지합니다.
  • Sinatra는 JSON 본문을 파싱하지 않으므로 request.body.read가 첫 번째 읽기입니다. 이 라우트에서는 Rack::JSONBodyParser 같은 미들웨어를 피하세요.
  • halt 400은 사유를 본문으로 담아 라우트를 멈추고, 이것이 Ikiform 전달 로그에 표시되므로 잘못된 시크릿은 그곳에서 no_matching_signature로 읽힙니다.
  • Rack 3은 더 이상 요청 본문을 되감을 수 있다고 보장하지 않으므로, 한 번 문자열로 읽어 검증과 파싱 모두에 그 문자열을 쓰세요.

Minitest 테스트

ikiform_signature_test.rb
require "json"
require "minitest/autorun"
require_relative "ikiform_signature"

class IkiformSignatureTest < Minitest::Test
  SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
  ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
  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."}}'
  HEADERS = {
    "webhook-id" => "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
    "webhook-timestamp" => "1792300000",
    "webhook-signature" => "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA="
  }.freeze
  NOW = 1_792_300_000 + 30

  def test_accepts_the_vector
    assert_nil IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW)
  end

  def test_accepts_either_secret_during_a_roll
    both = HEADERS.merge("webhook-signature" => "#{HEADERS["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")
    assert_nil IkiformSignature.verify!(BODY, both, SECRET, now: NOW)
    assert_nil IkiformSignature.verify!(BODY, both, ROLLED, now: NOW)
  end

  def test_rejects_a_reserialized_body
    pretty = JSON.pretty_generate(JSON.parse(BODY))
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(pretty, HEADERS, SECRET, now: NOW) }
    assert_equal "no_matching_signature", error.message
  end

  def test_rejects_a_stale_timestamp
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW + 600) }
    assert_equal "timestamp_too_old", error.message
  end
end

ruby ikiform_signature_test.rb로 실행하세요. 고정된 now와 함께 테스트 벡터를 씁니다. Ruby에서 본문이 다시 직렬화되는 방식이 JSON.pretty_generate이므로, 이 경우는 확인이 이를 잡아낸다는 것을 보여 줍니다. Bundler를 쓰는 프로젝트라면 Gemfile의 test 그룹에 minitest를 추가하세요. Ruby에 번들 gem으로 포함되어 있지만 Bundler는 Gemfile에 나열된 gem만 불러옵니다.

standardwebhooks gem을 쓰는 Rails

Gemfile에 gem "standardwebhooks"를 추가하고 bundle install을 실행하세요. 시크릿은 bin/rails credentials:edit으로 ikiform.webhook_secret 아래에 저장하거나 ENV에서 읽으세요.

app/controllers/ikiform_webhooks_controller.rb
class IkiformWebhooksController < ActionController::API
  def create
    webhook = StandardWebhooks::Webhook.new(Rails.application.credentials.dig(:ikiform, :webhook_secret))
    event = webhook.verify(request.raw_post, {
      "webhook-id" => request.headers["webhook-id"],
      "webhook-timestamp" => request.headers["webhook-timestamp"],
      "webhook-signature" => request.headers["webhook-signature"]
    })

    if event["type"] == "submission.created"
      IkiformSubmissionJob.perform_later(event["id"], event.dig("data", "fields"))
    end
    head :no_content
  rescue StandardWebhooks::WebhookVerificationError => error
    render plain: error.message, status: :bad_request
  end
end
config/routes.rb
Rails.application.routes.draw do
  post "webhooks/ikiform", to: "ikiform_webhooks#create"
end
  • ActionController::API에는 CSRF 보호가 없습니다. 컨트롤러가 대신 ApplicationController를 상속한다면 skip_forgery_protection을 추가하세요. 그렇지 않으면 Rails가 POST를 거부합니다.
  • request.raw_post는 보낸 그대로의 본문이며 첫 읽기 뒤 캐시되므로 두 번 호출해도 안전합니다. params.to_json을 검증하지 마세요. Rails가 이미 JSON을 파싱해 params에 병합했으므로 바이트가 다릅니다.
  • gem은 정확한 소문자 키를 가진 해시에서 헤더를 읽으므로 보이는 대로 만드세요. 타임스탬프와 모든 v1 서명을 확인하고 파싱된 JSON을 반환합니다.
  • perform_later가 작업을 큐에 넣고 head :no_content가 바로 204로 응답하므로 Ikiform의 15초 안에 여유 있게 들어옵니다. job은 이미 본 전달 id를 건너뛸 수 있습니다.

job에서 반복 건너뛰기

바로 204로 응답하고 작업은 job에서 하는 것이 올바른 구조이며, 그러려면 job이 같은 전달을 두 번 처리할 수 있어야 합니다. Ikiform이 15초 안에 응답을 받지 못하면 같은 webhook-id로 재시도하고, 컨트롤러가 두 번째 job을 큐에 넣습니다. 고유 인덱스가 두 번째를 아무 일도 하지 않게 만듭니다.

db/migrate/20261025000000_create_webhook_deliveries.rb
class CreateWebhookDeliveries < ActiveRecord::Migration[8.0]
  def change
    create_table :webhook_deliveries do |t|
      t.string :delivery_id, null: false
      t.timestamps
    end
    add_index :webhook_deliveries, :delivery_id, unique: true
  end
end
app/jobs/ikiform_submission_job.rb
class IkiformSubmissionJob < ApplicationJob
  queue_as :default

  def perform(delivery_id, fields)
    WebhookDelivery.transaction do
      WebhookDelivery.create!(delivery_id: delivery_id)
      SubmissionMailer.with(fields: fields).notify_team.deliver_now
    end
  rescue ActiveRecord::RecordNotUnique
    Rails.logger.info("Skipping repeated delivery #{delivery_id}")
  end
end

삽입과 작업이 트랜잭션을 공유하므로, 메일이 실패하면 id가 롤백되고 job 자체의 재시도가 다시 실행됩니다. RecordNotUnique는 데이터베이스의 고유 인덱스에서 오며, 서로 다른 워커에서 두 job이 동시에 실행되어도 지켜집니다. 모델 수준의 validates :delivery_id, uniqueness: true만으로는 그렇지 않습니다. SubmissionMailer는 job이 필드로 하는 작업을 대신하는 예시입니다.

gem의 현재 릴리스는 Ruby 3.3 이상이 필요합니다. 이전 Ruby라면 컨트롤러에서 대신 위의 IkiformSignature 모듈을 쓰세요. Ruby 3.0 이상에서 실행됩니다.

작동 확인하기

  1. Minitest 파일을 실행하세요. 네 번 실행, 실패 없음입니다.
  2. 공개 https: URL 뒤에 배포하고 대시보드의 Webhooks에 추가하세요.
  3. credentials나 IKIFORM_WEBHOOK_SECRET에 시크릿을 설정하고 앱 서버를 재시작하세요.
  4. Send test를 고르세요. 전달 로그에 204가 표시되고, Rails 로그에 CSRF 경고 없이 요청이 IkiformWebhooksController#create에 도달한 것이 보입니다.
  5. 시크릿을 한 글자 바꾸고 테스트를 다시 보내세요. 로그에 사유와 함께 400이 표시됩니다.

주의할 점

문제원인과 해결
Sinatra에서 missing_headers코드가 request.env["webhook-id"]를 읽었습니다. Rack의 키는 HTTP_WEBHOOK_ID입니다
Rails에서 ActionController::InvalidAuthenticityToken컨트롤러가 CSRF 보호를 상속합니다. ActionController::API나 skip_forgery_protection을 쓰세요
Ruby 업그레이드 후 cannot load such file -- base64Ruby 3.4가 base64를 기본 gem에서 뺐습니다. Gemfile에 추가하거나 위처럼 pack("m0")을 쓰세요
NoMethodError: undefined method 'secure_compare'Ruby 3.0보다 오래된 버전입니다. 업그레이드하거나, 길이가 다르면 false를 반환하는 Rack::Utils.secure_compare를 쓰세요
Rails에서만 서명이 실패함컨트롤러 전에 무언가 본문을 파싱했거나 params가 다시 인코딩되었습니다. request.raw_post를 검증하세요

질문

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