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

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

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

원본 본문(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 웹훅 헤더가 도착하는 위치

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`:

```text
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`:

```text
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`:

```text
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`와 함께 [테스트 벡터](https://ikiform.com/ko/guides/webhook-signature-verification.md#test-vector)를 씁니다. 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`:

```text
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`:

```text
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`:

```text
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`:

```text
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 -- base64` | Ruby 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`를 검증하세요 |

## 질문

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

request.raw_post를 쓰세요. 액션이 실행될 즈음 Rails는 JSON 본문을 params로 파싱했지만, raw_post에는 여전히 보낸 그대로의 바이트가 있습니다.

### Sinatra에서 커스텀 헤더는 어떻게 읽나요?

Rack 환경에서 대문자로 바꾸고 대시를 밑줄로 바꾼 뒤 앞에 HTTP_를 붙인 이름으로 읽으세요. webhook-id는 request.env["HTTP_WEBHOOK_ID"]입니다.

### Ruby용 Standard Webhooks gem이 있나요?

네, standardwebhooks입니다. StandardWebhooks::Webhook.new(secret).verify(body, headers)는 whsec_ 시크릿과 소문자 헤더 키를 가진 해시를 받고, 확인에 실패하면 WebhookVerificationError를 발생시킵니다.

### Rails 웹훅에서 CSRF 보호를 건너뛰어야 하나요?

컨트롤러가 ActionController::Base를 상속할 때만 그렇습니다. ActionController::API 컨트롤러에는 CSRF 확인이 없습니다. 어느 쪽이든 이 액션의 보호 수단은 서명입니다.

### OpenSSL::HMAC.hexdigest가 서명과 일치하지 않는 이유는 무엇인가요?

Standard Webhooks 서명은 hex가 아니라 base64입니다. OpenSSL::HMAC.digest로 원본 바이트를 얻어 [digest].pack("m0")로 인코딩한 뒤, 헤더의 v1, 뒤에 있는 값과 비교하세요.

## 가이드 더 보기

- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [PHP](https://ikiform.com/ko/guides/webhook-signature-verification/php.md): 일반 PHP에서는 hash_hmac과 hash_equals로, Laravel에서는 Standard Webhooks 라이브러리와 CSRF 예외로 Ikiform 웹훅 서명을 검증하세요.
- [Python](https://ikiform.com/ko/guides/webhook-signature-verification/python.md): standardwebhooks 패키지나 표준 라이브러리로 Flask, FastAPI, Django에서 Ikiform 웹훅 서명을 검증하고, pytest 테스트도 더합니다.
- [Go net/http 사용](https://ikiform.com/ko/guides/webhook-signature-verification/go.md): Go의 net/http로 Ikiform 웹훅 서명을 검증하세요. 본문을 한 번 읽고, crypto/hmac으로 확인하고, 테스트하거나, Standard Webhooks 라이브러리를 쓰세요.
- [Node.js](https://ikiform.com/ko/guides/webhook-signature-verification/nodejs.md): Node.js에서 Ikiform 웹훅 서명을 검증하세요. Express와 Fastify의 원본 본문, SDK, standardwebhooks 패키지, 테스트된 node:crypto 버전을 다룹니다.
