windmill-labs/windmill · error
signature mismatch
Error message
signature mismatch
What it means
SlackVerifier::verify recomputes the Slack 'v0' HMAC-SHA256 signature over "v0:{timestamp}:{body}" using the signing secret and compares it to the X-Slack-Signature header. Any mismatch means the request was not signed with the expected secret or the payload changed in transit.
Source
Thrown at backend/windmill-oauth/src/lib.rs:1124
pub struct SlackVerifier {
mac: HmacSha256,
}
impl SlackVerifier {
pub fn new<S: AsRef<[u8]>>(secret: S) -> anyhow::Result<SlackVerifier> {
HmacSha256::new_from_slice(secret.as_ref())
.map(|mac| SlackVerifier { mac })
.map_err(|_| anyhow::anyhow!("invalid secret"))
}
pub fn verify(&self, ts: &str, body: &str, exp_sig: &str) -> anyhow::Result<()> {
let basestring = format!("v0:{}:{}", ts, body);
let mut mac = self.mac.clone();
mac.update(basestring.as_bytes());
let sig = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
if sig != exp_sig {
Err(anyhow::anyhow!("signature mismatch"))?;
}
Ok(())
}
}
/// Fetch user info from OAuth provider
pub async fn http_get_user_info<T: DeserializeOwned>(
http_client: &reqwest::Client,
url: &str,
token: &str,
) -> error::Result<T> {
let res = http_client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(to_anyhow)
.map_err(|e| error::Error::InternalErr(format!("failed to fetch user info: {}", e)))?;View on GitHub (pinned to e474e8803c)
Solutions
- Verify the exact raw request body bytes are passed to verify — never a re-serialized or pretty-printed JSON
- Confirm the signing secret matches the Slack app that sent the webhook (reset/re-copy from Slack app settings)
- Ensure the timestamp header (X-Slack-Request-Timestamp) is forwarded unmodified
- Check no reverse proxy (nginx, Cloudflare) is rewriting the body (e.g. gzip decompression with re-encoding, WAF modification)
Example fix
// before (body re-serialized by handler framework) let body = serde_json::to_string(&payload)?; // alters key order/spacing verify(&ts, &body, &sig)?; // after let body = raw_body_bytes; // exact bytes Slack sent verify(&ts, &body, &sig)?;
Defensive patterns
Strategy: try-catch
Validate before calling
// Before verifying, ensure you have raw bytes and a plausible v0 signature
if !exp_sig.starts_with("v0=") { anyhow::bail!("not a Slack v0 signature"); }
if ts.parse::<i64>().map(|t| now - t > 300).unwrap_or(true) { anyhow::bail!("stale or bad timestamp"); } Type guard
fn is_v0_signature(s: &str) -> bool { s.starts_with("v0=") && s[3..].len() == 64 && s[3..].bytes().all(|b| b.is_ascii_hexdigit()) } Try / catch
if let Err(e) = verifier.verify(&ts, &raw_body, &sig) {
if e.to_string() == "signature mismatch" {
// reject the webhook: wrong secret or mutated body
return HttpResponse::Unauthorized().finish();
}
return HttpResponse::BadRequest().finish();
} Prevention
- Always hash the exact raw request body bytes, never a re-serialized struct
- Reject requests older than ~5 minutes (Slack recommends timestamp replay protection)
- Keep one signing secret per Slack app and route webhooks accordingly
- Check for proxies/WAFs that might rewrite request bodies
When it happens
Trigger: verify(ts, body, expected_sig) called with a timestamp/body that differs from what Slack signed, or an expected signature computed with a different signing secret.
Common situations: A proxy or framework re-serializing/reordering the JSON body before verification; verifying against the wrong Slack app's signing secret (multiple Slack apps / workspaces); replayed or modified webhooks; comparing against a truncated or URL-decoded signature.
Related errors
- invalid secret
- Invalid signature
- signature mismatch
- bundle produced no javascript:\n${buildOutput}
- No local instance profile named ${instanceName}
AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03).
Data as JSON: /api/errors/a3835db2212c1645.
Report an issue: GitHub.