Rails 웹훅: 컨트롤러에서 HMAC 서명 검증하기
request.raw_post를 읽고 그 액션만 CSRF를 건너뛴 뒤, OpenSSL::HMAC.hexdigest를 각 sume-v1 항목과 secure_compare로 비교하고 head 204로 응답하세요.

Rails에서 웹훅을 받으려면, 어떤 코드도 본문을 파싱하기 전에 정확한 본문 바이트인 request.raw_post를 읽는 컨트롤러 액션으로 POST를 라우팅하고, 그 액션에서만 CSRF 보호를 건너뛰세요. 서명을 확인하고 head :no_content로 응답한 뒤, 느린 작업은 백그라운드 잡에 넘기세요. Sume 웹훅이라면 OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw}")를 계산하고, sume-v1= 뒤에 그 다이제스트를 붙인 값을 서명 헤더의 쉼표로 구분된 각 항목과 ActiveSupport::SecurityUtils.secure_compare로 비교하세요.
Rails 관련 내용은 Rails 8.1.4 API 문서의 ActionDispatch::Request, RequestForgeryProtection, SecurityUtils, Head와 Rails 가이드의 API 전용 애플리케이션, Active Job 문서에서 가져왔고, 다이제스트는 Ruby OpenSSL::HMAC 문서를 참고했습니다. Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-28에 확인했습니다. Sume는 Ruby gem을 제공하지 않습니다. SDK는 TypeScript용이며, 문서에 다른 언어로 수신기를 만드는 경우를 위한 서명 방식이 자세히 나와 있습니다. PHP 버전은 PHP 웹훅 서명 검증에 있습니다.
Sume 서명은 Ruby·Rails 호출과 어떻게 대응하나요?
Sume는 <timestamp>.<raw_body>에 HMAC-SHA256으로 서명해 x-sume-webhook-signature에 sume-v1=<hex_signature>를 담아 보내고, 타임스탬프는 x-sume-webhook-timestamp에 담습니다. 서명 시크릿을 교체하는 동안에는 헤더에 유효한 시크릿마다 항목이 하나씩 쉼표로 구분되어 실리며, 어느 항목이든 일치하면 유효한 전달입니다. 단계마다 대응하는 Ruby 또는 Rails 호출이 있습니다.
secure_compare는 길이가 가변적인 문자열을 위한 Rails의 안전한 비교 함수입니다. 타이밍 공격으로는 비교하는 내용을 알아낼 수 없고 길이만 알 수 있는데, 여기서는 어차피 길이가 고정되어 있습니다.
| 단계 | Sume 규칙 | Ruby 또는 Rails |
|---|---|---|
| 원본 본문 | JSON을 파싱하기 전에 원본 바이트를 검증 | request.raw_post |
| 헤더 | x-sume-webhook-timestamp, x-sume-webhook-signature | request.headers["X-Sume-Webhook-Signature"] |
| 다이제스트 | <timestamp>.<raw_body>에 대한 HMAC-SHA256, hex 인코딩 | OpenSSL::HMAC.hexdigest("SHA256", secret, data) |
| 비교 | 상수 시간, sume-v1= 항목 중 어느 것이든 일치하면 수락 | header.split(",")의 각 항목과 secure_compare로 비교 |
| 재전송 허용 시간 | 벗어난 타임스탬프는 거부. 오 분이 무난한 기본값 | (Time.now.to_i - ts.to_i).abs > 300 |
Rails 컨트롤러에서 웹훅은 어떻게 검증하나요?
액션은 먼저 원본 본문을 검증하고, 같은 문자열을 파싱하고, 이벤트를 큐에 넣은 뒤 204로 응답합니다. 현재 코드의 Sume TypeScript 검증기처럼 모든 항목을 확인하고 빈 시크릿을 거부하므로, 설정되지 않은 변수가 누구나 계산할 수 있는 HMAC 키가 되지 않습니다.
# config/routes.rb: post "/hooks/sume", to: "sume_webhooks#create"
class SumeWebhooksController < ApplicationController
skip_forgery_protection only: :create # a webhook sender has no CSRF token
def create
raw = request.raw_post # the exact bytes Sume signed
return head :unauthorized unless sume_signature_valid?(raw)
SumeWebhookJob.perform_later(JSON.parse(raw)) # dedupe and work in the job
head :no_content
end
private
def sume_signature_valid?(raw)
secret = ENV.fetch("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
ts = request.headers["X-Sume-Webhook-Timestamp"].to_s
header = request.headers["X-Sume-Webhook-Signature"].to_s
return false if secret.empty? || !ts.match?(/\A\d+\z/) || (Time.now.to_i - ts.to_i).abs > 300
expected = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
header.split(",").map(&:strip).reduce(false) do |ok, entry|
ActiveSupport::SecurityUtils.secure_compare(entry, expected) || ok # check every entry
end
end
endCSRF 보호는 왜 건너뛰고, 왜 이 액션에서만 건너뛰나요?
Rails의 위조 방지(forgery protection)는 GET과 HEAD 요청은 확인하지 않지만, 웹훅은 POST로 도착하고 보내는 쪽에는 보낼 authenticity token이 없습니다. default_protect_from_forgery가 true이면 Rails는 with: :exception으로 보호하며, 이 방식은 ActionController::InvalidAuthenticityToken을 발생시킵니다. skip_forgery_protection은 skip_before_action :verify_authenticity_token을 감싼 것이고, only:는 건너뛰기를 액션 하나로 제한하므로 컨트롤러의 나머지 부분은 계속 보호됩니다. 요청이 Sume에서 왔다는 증거는 HMAC 검사가 대신 맡습니다.
컨트롤러가 ActionController::API를 상속하는 API 전용 앱에서는 skip_forgery_protection 줄을 빼세요. Rails 가이드가 API 전용 컨트롤러에 나열한 모듈에 위조 방지가 없으므로 건너뛸 것이 없습니다.
왜 모든 서명 검증이 실패하나요?
다음 Rails 실수부터 확인하세요.
- 본문을
params에서 가져온 경우. 키 순서와 공백도 서명 대상의 일부이므로, 파싱했다가 다시 직렬화한 객체는 검증되지 않습니다.request.raw_post를 쓰세요. - 다이제스트가 바이너리인 경우.
OpenSSL::HMAC.digest는 바이너리 문자열을 반환하지만 서명은 hex이며, hex를 반환하는 것은hexdigest입니다. - 헤더 전체를 비교한 경우. 시크릿 교체 중에는 헤더에 유효한 시크릿마다 항목이 하나씩 실리므로, 헤더 전체에 대한 동등 비교는 그 기간의 모든 전달에서 실패합니다.
- 시크릿이 맞지 않는 경우.
x-sume-webhook-secret-fingerprint헤더를 대시보드에서 시크릿 옆에 표시된 지문과 비교하세요. 나머지는 Sume 웹훅 전달 디버깅에서 다룹니다.
검증한 뒤 액션은 무엇을 해야 하나요?
빠르게 응답하고 작업은 잡으로 넘기세요. Sume는 시도당 10초를 허용하고, 느린 엔드포인트에는 재시도하며, 최대 10회까지 시도합니다. Rails 8.0부터 기본 Active Job 백엔드인 Solid Queue는 잡을 데이터베이스에 저장하므로, head :no_content가 응답하기 전에 perform_later가 이벤트를 저장합니다.
잡에서는 재시도에도 값이 반복되므로 실행 웹훅은 request_id로, Job 웹훅은 job_id로 중복을 제거하고, event로 라우팅하세요. 액션은 모르는 이벤트 타입을 포함해 검증된 모든 전달에 204로 응답합니다. Sume 문서에 따르면 그래야 새로 추가된 이벤트 타입이 500과 재시도 폭주로 번지지 않습니다. 실행 웹훅의 payload는 GET /v1/format-runs/{run_id}의 data와 바이트 단위로 동일하므로, 잡 하나로 웹훅과 폴링을 똑같이 처리할 수 있습니다.
출처
- Run 웹훅 (영문)
- 웹훅 (영문)
- 웹훅 검증
- Rails API: ActionDispatch::Request (2026-09-28 확인)
- Rails API: RequestForgeryProtection::ClassMethods (2026-09-28 확인)
- Rails API: AbstractController::Callbacks (2026-09-28 확인)
- Rails API: ActiveSupport::SecurityUtils (2026-09-28 확인)
- Rails API: ActionController::Head (2026-09-28 확인)
- Rails API: ActionDispatch::Http::Headers (2026-09-28 확인)
- Rails 가이드: API 전용 애플리케이션에 Rails 사용하기 (2026-09-28 확인)
- Rails 가이드: Active Job 기초 (2026-09-28 확인)
- Rails 가이드: 레이아웃과 렌더링 (2026-09-28 확인)
- Ruby 3.4: OpenSSL::HMAC (2026-09-28 확인)
관련 글
연동 카테고리의 다른 글
- Rust HMAC-SHA256: axum에서 웹훅 서명 검증하기
Rust에서는 hmac·sha2 크레이트로 HMAC-SHA256을 계산합니다. axum에서는 Bytes로 받아 timestamp.body에 MAC을 적용하고 각 항목을 verify_slice로 확인하세요.
- Slack API 파일 업로드: files.upload 대신 세 번 호출
files.upload 지원 중단 뒤에는 files.getUploadURLExternal, upload_url에 바이트 POST, channel_id와 files.completeUploadExternal 순입니다.
- Strands Agents MCP: 에이전트를 Sume MCP 서버에 연결
MCPClient로 Strands 에이전트를 원격 MCP 서버에 연결하세요. Sume 호스팅 MCP URL과 API 키 헤더를 넣고, tool_filters로 유료 도구를 뺍니다.
- Teams Incoming Webhook 종료: Workflows로 전환
Microsoft는 Teams의 Office 365 커넥터를 May 18~22, 2026에 비활성화하기로 했습니다. 대신 Workflows 웹훅 URL을 만들고 Adaptive Card를 POST하세요.
작성자 Sume