skip to content

In Ruby, how do you compute and verify an HMAC-SHA256 signature on an incoming API request body with the standard library?

level: middleimportance: should knowfreq 44%

answer

  1. OpenSSL::HMAC, not Digest::SHA256
  2. argument order: digest, key, data
  3. HMAC.new takes key first
  4. sign the raw body bytes
  5. OpenSSL.secure_compare, never ==

basics

~10 s

Compute OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body), digest name first, then key, then data, and compare it with the received signature using OpenSSL.secure_compare, never ==. Sign the raw body bytes, before any JSON parsing.

solid answer

~40 s

With `require "openssl"`, `OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)` returns the signature as lowercase hex; `OpenSSL::HMAC.digest` returns raw bytes and `base64digest` Base64, so match whatever encoding the sender uses. Watch the argument order: the class methods take `(digest, key, data)`, but `OpenSSL::HMAC.new(key, digest)` takes the key first. `Digest::SHA256` has no key parameter at all, so it is not the tool for authenticating a message. Compute over the **raw** request body exactly as received: parsing JSON and re-serialising it changes whitespace and key order. Then compare with `OpenSSL.secure_compare(expected, received)`, which runs in constant time and tolerates a received value of the wrong length; `==` can return as soon as it finds a difference. Include a timestamp in the signed data and reject stale requests to limit replay.

code

ruby · 9 lines
ruby
require "openssl"

def valid_signature?(raw_body, timestamp, received, secret)
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  signed = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  OpenSSL.secure_compare(expected, received.to_s.downcase)
end

go deeper

for a junior

Recall OpenSSL::HMAC.hexdigest("SHA256", key, data) for computing signatures, and that a received signature is always compared with OpenSSL.secure_compare.

for a middle

Explain the argument orders of the class methods and HMAC.new, why the raw body is signed, and how hex, raw and Base64 outputs differ.

for a senior

Build a verifier with timestamps, replay windows, prefix handling and two-key rotation, and test it against the sender's published examples.

for a principal

Decide one signing scheme for all internal and partner APIs, with key rotation and replay rules written down once.

## The pieces in the standard library Ruby has two families of hashing APIs, and they answer different questions: | API | Keyed? | Returns | Typical use | |---|---|---|---| | `Digest::SHA256.hexdigest(data)` | no | 64 hex characters | checksums, cache keys, storing a token's digest | | `Digest::SHA256.digest(data)` | no | 32 raw bytes | the same, binary | | `OpenSSL::HMAC.hexdigest("SHA256", key, data)` | yes | 64 hex characters | request and webhook signatures | | `OpenSSL::HMAC.digest("SHA256", key, data)` | yes | 32 raw bytes | signatures you encode yourself | | `OpenSSL::HMAC.base64digest("SHA256", key, data)` | yes | Base64 string | senders that transmit Base64 | | `OpenSSL::HMAC.new(key, "SHA256")` then `<<` / `update` | yes | an object with `hexdigest`, `digest` | streaming large bodies | `Digest` comes from `require "digest"`; `OpenSSL::HMAC` comes from `require "openssl"`, a default gem that wraps the system OpenSSL library. The digest argument may be a name such as `"SHA256"` or an `OpenSSL::Digest` instance. An **HMAC** mixes a secret key into the hash so that only holders of the key can produce a valid tag. A plain `Digest::SHA256` call has no key parameter; building one by concatenating a secret with the body is a different, weaker construction, and the reasons belong to hash-function theory rather than to Ruby's API. ## The argument-order trap - The **class methods** `OpenSSL::HMAC.digest`, `hexdigest` and `base64digest` take `(digest, key, data)`. - The **constructor** `OpenSSL::HMAC.new` takes `(key, digest)`, key first, and data is fed afterwards with `update` or `<<`. - Swapping key and data in the class method still returns a valid-looking 64-character hex string: the code "works" and verifies nothing, because the "key" is now the public body. Tests that compare against a known signature from the sender's documentation catch it. ## Verifying an incoming request 1. **Read the raw body** before any parsing. The sender signed bytes; re-serialising parsed JSON can reorder keys and change whitespace, and the signatures stop matching for legitimate requests. 2. **Rebuild the signed string** exactly as the sender documents it, often a timestamp, a separator and the body. 3. **Compute** `OpenSSL::HMAC.hexdigest("SHA256", secret, signed_string)`. 4. **Compare in constant time** with `OpenSSL.secure_compare(expected, received)`. It hashes both inputs with SHA-256 before comparing, so a received value of the wrong length returns `false` instead of raising. 5. **Check freshness**: reject timestamps outside a small window so a captured request cannot be replayed later. 6. **Only then parse** the body and act on it. ## Encoding details that break verification - `hexdigest` returns **lowercase** hex. If a sender uses uppercase, normalise the received value with `downcase` before comparing. - Some senders prefix the value, such as `sha256=<hex>`; strip the prefix, do not include it in the comparison. - Base64 senders need `base64digest` or `Base64.strict_encode64(digest)`; comparing hex against Base64 always fails. Note that `base64` is a bundled gem since Ruby 3.4, so under Bundler it must be in the Gemfile; `OpenSSL::HMAC.base64digest` needs no extra gem. - Keep secrets in the environment or a secrets store, and support **two active keys** during rotation by accepting a match against either. ## Where Digest::SHA256 does belong - Fingerprinting file contents: `Digest::SHA256.file(path).hexdigest`. - Storing a digest of a high-entropy token so a database leak does not expose the token. - Cache keys and deduplication, where nobody is trying to forge a match with a secret. ## Testing the verifier - **A known vector.** Most senders publish an example body, secret and signature; a test that reproduces it catches swapped arguments and wrong encodings at once. - **A tampered body** with the original signature must return `false`. - **A header of the wrong length**, or an empty one, must return `false` rather than raise, which is exactly what `OpenSSL.secure_compare` guarantees. - **A stale timestamp** with a correct signature must be rejected. - **Large bodies** can be signed incrementally with `OpenSSL::HMAC.new(secret, "SHA256")` and `update` on each chunk; the final `hexdigest` equals the one-shot class method, which a test can assert.

  • Why does verification fail when you sign JSON.generate(JSON.parse(body)) instead of the body?
    The sender signed the exact bytes it transmitted. Parsing and regenerating can change whitespace, key order, number formatting and escaping, so the recomputed HMAC covers different bytes. Always sign the raw body string you received.
  • How do you rotate the shared secret without rejecting in-flight requests?
    Accept signatures made with either the old or the new secret for an overlap window: compute both HMACs and succeed if `OpenSSL.secure_compare` matches either. Then retire the old secret once senders have switched.

saying these in an interview costs you the question

  • Digest::SHA256.hexdigest(secret + body) is an HMAC.
  • OpenSSL::HMAC.hexdigest takes the key as its first argument.
  • Comparing signatures with == is fine because both are hex strings.
  • Parse the JSON first, then sign the parsed hash for a canonical form.
  • Base64 must be required to call OpenSSL::HMAC.base64digest.