require "openssl" module Webhook module_function # Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>" def sign(secret, raw_body) "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)}" end # Receiver: recompute over the raw body and compare in constant time def verify(secret, raw_body, header) return false if header.nil? # a missing header fails, it never skips the check OpenSSL.secure_compare(sign(secret, raw_body), header) # false, not an error, for another length endend require "json"require "sinatra/base"require_relative "webhook" class WebhookApp < Sinatra::Base set :secret, ENV.fetch("WEBHOOK_SECRET") # fails at start-up when unset post "/webhook" do raw_body = request.body.read # the bytes as received, before any JSON parsing signature = request.env["HTTP_X_HUB_SIGNATURE_256"] # how Rack names X-Hub-Signature-256 halt 401, "bad signature\n" unless Webhook.verify(settings.secret, raw_body, signature) event = JSON.parse(raw_body) "accepted #{event['action']} ##{event['number']}\n" endend How it works
OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)returns the 64 lower-case hex digits; the header issha256=followed by them.OpenSSL.secure_compare(a, b)hashes both strings and compares the digests in constant time, so it returnsfalse— never raises — for inputs of different lengths. Plain==stops at the first differing character, and its timing tells a forger how much of a guess was right.- In Sinatra,
request.body.readreturns the body bytes as received; the route verifies them beforeJSON.parse. Rack exposes the header asrequest.env["HTTP_X_HUB_SIGNATURE_256"]. ENV.fetch("WEBHOOK_SECRET")raises at start-up when the secret is missing, instead of signing with an empty string.- The run checks GitHub's example (
Hello, World!, secretIt's a Secret to Everybody→sha256=757107ea…3e17) and drives the route withRack::MockRequest.
Gotchas
OpenSSL.fixed_length_secure_compareraisesArgumentError: inputs must be of equal lengthfor a truncated header; usesecure_compare, which returnsfalse, unless you check lengths first.- The MAC covers bytes, not JSON: the pretty-printed event re-generated with
JSON.generate(JSON.parse(body))is rejected with401. Verifyrequest.body.read, not a re-serializedparamshash. - A missing header must fail the request (
verifyreturnsfalsefornil), not be read as "nothing to check".