HMAC-SHA256 webhook signing and verification for Rust
cargo add philiprehberger-webhook-signatureHMAC-SHA256 webhook signing and verification for Rust
[dependencies]
philiprehberger-webhook-signature = "0.6.0"
use philiprehberger_webhook_signature::sign;
let signed = sign("payload", "secret");
println!("{}", signed.to_header()); // "t=...,sha256=..."
use philiprehberger_webhook_signature::verify_header;
verify_header("payload", "secret", &header, 300)?; // max age 300 seconds
Or with manual parsing:
use philiprehberger_webhook_signature::{verify, parse_header};
let (sig, ts) = parse_header(&header)?;
verify("payload", "secret", &sig, ts, 300)?;
use philiprehberger_webhook_signature::SignatureError;
match verify(payload, secret, &sig, ts, 300) {
Ok(()) => println!("Valid!"),
Err(SignatureError::Mismatch) => eprintln!("Bad signature"),
Err(SignatureError::Expired { age_secs, max_age_secs }) => {
eprintln!("Expired: {}s > {}s", age_secs, max_age_secs);
}
Err(e) => eprintln!("Error: {}", e),
}
verify(payload, secret, &sig, ts, 0)?; // no expiry check
use philiprehberger_webhook_signature::{Signer, Verifier};
let signer = Signer::new("my-secret");
let signed = signer.sign("webhook body");
println!("{}", signed); // "t=...,sha256=..."
let verifier = Verifier::new("my-secret", 300);
verifier.verify_header("webhook body", &signed.to_header())?;
Verify against multiple secrets during key rotation:
use philiprehberger_webhook_signature::{sign, verify_with_secrets};
let signed = sign("payload", "new-secret");
// Accepts signatures from either the old or new secret
let result = verify_with_secrets(
"payload",
&["old-secret", "new-secret"],
&signed.signature,
signed.timestamp,
300,
);
assert!(result.is_ok());
Check how old a signed payload is:
use philiprehberger_webhook_signature::sign;
let signed = sign("payload", "secret");
println!("Signature age: {:?}", signed.age());
Allow a tolerance window for clock drift between signer and verifier:
use philiprehberger_webhook_signature::{sign, verify_relaxed};
let signed = sign("payload", "secret");
let result = verify_relaxed(
"payload",
"secret",
&signed.signature,
signed.timestamp,
300, // max_age_secs
10, // tolerance_secs
);
assert!(result.is_ok());
Webhook secrets are credentials — treat them with the same care as API keys or database passwords.
Load from environment variables, never hard-code secrets in source or commit them to version control:
use philiprehberger_webhook_signature::Signer;
let secret = std::env::var("WEBHOOK_SECRET")
.expect("WEBHOOK_SECRET must be set");
let signer = Signer::new(&secret);
Rotate secrets periodically using the built-in key-rotation support so in-flight signatures from the previous secret remain valid during the handover window:
use philiprehberger_webhook_signature::Verifier;
let current = std::env::var("WEBHOOK_SECRET").unwrap();
let previous = std::env::var("WEBHOOK_SECRET_PREVIOUS").ok();
let secrets: Vec<String> = previous.into_iter().chain(std::iter::once(current)).collect();
let verifier = Verifier::new_with_secrets(secrets, 300);
Never log secrets (or full signatures) — avoid println!, dbg!, and
structured logging fields that could leak credentials into log sinks.
Constant-time comparison is already used internally (via the subtle
crate) when checking signatures, so you do not need to implement your own
timing-safe equality check.
serde FeatureEnable the serde feature to derive Serialize and Deserialize for
SignedPayload and SignatureError:
[dependencies]
philiprehberger-webhook-signature = { version = "0.6.0", features = ["serde"] }
| Function / Type | Description |
|---|---|
sign(payload, secret) | Sign a payload, returns SignedPayload |
sign_at(payload, secret, timestamp) | Sign with a specific timestamp |
verify(payload, secret, signature, timestamp, max_age_secs) | Verify a signature (set max_age to 0 to skip age check) |
parse_header(header) | Parse a t=...,sha256=... header into signature and timestamp |
verify_header(payload, secret, header, max_age_secs) | Parse and verify a header in one call |
Signer::new(secret) | Create a reusable signer bound to a secret |
signer.sign(payload) | Sign a payload using the bound secret |
signer.sign_at(payload, timestamp) | Sign with a specific timestamp |
Verifier::new(secret, max_age_secs) | Create a reusable verifier bound to a secret and max age |
verifier.verify(payload, signature, timestamp) | Verify a signature |
verifier.verify_header(payload, header) | Parse and verify a header |
verify_with_secrets(payload, secrets, sig, ts, max_age) | Verify against multiple secrets (key rotation) |
verify_header_with_secrets(payload, secrets, header, max_age) | Verify header against multiple secrets |
verify_relaxed(payload, secret, sig, ts, max_age, tolerance) | Verify with clock skew tolerance |
Verifier::new_with_secrets(secrets, max_age) | Create a reusable multi-secret verifier |
signed.age() | Get the age of a signed payload as Duration |
SignedPayload | Struct with signature, timestamp, body fields and to_header() |
SignatureError | Enum: Mismatch, Expired, InvalidHeader |
cargo test
cargo clippy -- -D warnings
If you find this project useful: