Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Rust

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

To sign and verify a message with HMAC-SHA256 in Rust, use the RustCrypto hmac and sha2 crates: Hmac<Sha256> computes the MAC and verify_slice checks a received one in constant time. This recipe signs and verifies a webhook header such as X-Hub-Signature-256 and checks GitHub's documented example.

Tested with hmac 0.13.0 (Rust 1.99.1, sha2 0.11.1, hex 0.4.3).

Cargo.toml — hmac and sha2 from RustCrypto, hex for the header encoding
[dependencies]
hmac = "=0.13.0"
sha2 = "=0.11.1"
hex = "=0.4.3"
src/webhook.rs — sign and verify a sha256=<hex> header
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>".
pub fn sign(secret: &[u8], raw_body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC takes keys of any length");
mac.update(raw_body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
/// Receiver: recompute over the raw body; verify_slice compares in constant time.
pub fn verify(secret: &[u8], raw_body: &[u8], header: Option<&str>) -> bool {
let Some(received) = header
.and_then(|value| value.strip_prefix("sha256="))
.and_then(|hex_mac| hex::decode(hex_mac).ok())
else {
return false; // a missing or malformed header fails, it never skips the check
};
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC takes keys of any length");
mac.update(raw_body);
mac.verify_slice(&received).is_ok() // Err for a wrong MAC and for a wrong length
}

How it works

  1. HmacSha256::new_from_slice(secret) creates the MAC state (HMAC accepts keys of any length, hence the expect); update feeds the body and finalize().into_bytes() returns the 32 bytes.
  2. verify_slice(&received) finalizes the MAC and compares it with the received bytes in constant time, returning Err for a mismatch and for a slice of the wrong length. Comparing into_bytes() with == would compare plain arrays with an early exit; verify_slice (like the CtOutput that finalize() returns) does not.
  3. verify turns every malformed input — no header, no sha256= prefix, invalid hex — into false with one let … else, so no branch can accidentally accept an unsigned request.
  4. The run checks GitHub's example: Hello, World! with the secret It's a Secret to Everybody gives sha256=757107ea…3e17, while a tampered body, a wrong secret and a truncated header are rejected.

Gotchas

  • hex::decode refuses an odd number of digits (Err(OddLength)) and any non-hex character; treat every decoding error as an invalid signature.
  • hex::decode accepts upper-case digits — the run verifies sha256=757107EA… — so normalising the header's case is unnecessary when you compare bytes, not strings.
  • A truncated header decodes to fewer than 32 bytes, and verify_slice rejects it (truncated header: false) instead of comparing a prefix; do not slice the computed MAC to the received length to make them "match".

Related recipes