Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Ruby

How to sign and verify a message with HMAC-SHA256 in Ruby

To sign and verify a message with HMAC-SHA256 in Ruby, use the openssl library: OpenSSL::HMAC.hexdigest('SHA256', secret, body) computes the MAC and OpenSSL.secure_compare compares it in constant time. The typical case is a webhook header such as GitHub's X-Hub-Signature-256; this recipe checks GitHub's documented example and verifies deliveries in a Sinatra route.

Tested with openssl (Ruby gem) 4.0.2 (Ruby 4.0.7, OpenSSL 3.5.7) and Sinatra 4.2.1 (route exercised with Rack::MockRequest).

webhook.rb — sign and verify a sha256=<hex> header
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
end
end
app.rb — a Sinatra route that verifies the raw body before parsing JSON
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"
end
end

How it works

  1. OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body) returns the 64 lower-case hex digits; the header is sha256= followed by them.
  2. OpenSSL.secure_compare(a, b) hashes both strings and compares the digests in constant time, so it returns false — 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.
  3. In Sinatra, request.body.read returns the body bytes as received; the route verifies them before JSON.parse. Rack exposes the header as request.env["HTTP_X_HUB_SIGNATURE_256"].
  4. ENV.fetch("WEBHOOK_SECRET") raises at start-up when the secret is missing, instead of signing with an empty string.
  5. The run checks GitHub's example (Hello, World!, secret It's a Secret to Everybody → sha256=757107ea…3e17) and drives the route with Rack::MockRequest.

Gotchas

  • OpenSSL.fixed_length_secure_compare raises ArgumentError: inputs must be of equal length for a truncated header; use secure_compare, which returns false, 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 with 401. Verify request.body.read, not a re-serialized params hash.
  • A missing header must fail the request (verify returns false for nil), not be read as "nothing to check".

Related recipes