Slack request signature verification failed (X-Slack-Signature): causes and fixes

Slack signs every request it sends to your app (Events API, slash commands, interactivity). It builds the base string v0:{X-Slack-Request-Timestamp}:{raw request body}, runs HMAC-SHA256 over it with your app's Signing Secret, hex-encodes the result and sends it as X-Slack-Signature: v0=<hex>. Your check fails whenever any of those three inputs differs by a single byte.

Common causes

  1. Parsed body instead of raw body. Slash commands and interactivity payloads are application/x-www-form-urlencoded; events are JSON. If express.json(), express.urlencoded() or your framework parses the body first and you re-serialize it, the bytes change. Hash the raw bytes exactly as received.
  2. Wrong secret. Use Basic Information → App Credentials → Signing Secret. The Client Secret, the deprecated Verification Token and bot tokens (xoxb-) won't work.
  3. Missing v0= prefix or wrong encoding. The header is v0= plus a lowercase hex digest, not base64.
  4. Stale or mismatched timestamp. Slack recommends rejecting requests whose timestamp is more than 5 minutes from your clock (replay protection). Check your server clock (NTP), and use the timestamp from the header, not your own.
  5. Bolt plus your own body parser. Bolt's receiver verifies signatures itself and needs the raw body. Mounting a global body parser in front of Bolt's ExpressReceiver breaks verification.
  6. Retries look like duplicates. Slack expects an HTTP 200 within 3 seconds and retries otherwise, with X-Slack-Retry-Num and X-Slack-Retry-Reason. Acknowledge first and do slow work afterwards.

Node (Express)

const crypto = require('crypto');
app.post('/slack/events', express.raw({ type: '*/*' }), (req, res) => {
  const ts = req.get('X-Slack-Request-Timestamp') || '';
  const sig = req.get('X-Slack-Signature') || '';
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
  const base = `v0:${ts}:${req.body.toString('utf8')}`;          // req.body is a Buffer here
  const expected = 'v0=' + crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET).update(base).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
  const body = req.is('application/json') ? JSON.parse(req.body) : Object.fromEntries(new URLSearchParams(req.body.toString()));
  if (body.type === 'url_verification') return res.json({ challenge: body.challenge });
  res.sendStatus(200);                                              // ack within 3 s, then do the work
});

With Bolt for JavaScript, pass signingSecret to new App() and don't add your own body parser on the same routes; Bolt verifies for you.

Python (Flask)

import hmac, hashlib, os, time
from flask import request, abort

def verify_slack():
    ts = request.headers.get('X-Slack-Request-Timestamp', '')
    sig = request.headers.get('X-Slack-Signature', '')
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(401)
    raw = request.get_data()                                      # raw bytes, read before request.form
    base = b'v0:' + ts.encode() + b':' + raw
    expected = 'v0=' + hmac.new(os.environ['SLACK_SIGNING_SECRET'].encode(), base, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig): abort(401)

The official SDK has the same check: from slack_sdk.signature import SignatureVerifier, then SignatureVerifier(secret).is_valid_request(request.get_data(), request.headers). In FastAPI use await request.body() for the raw bytes.

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. Its built-in schemes cover Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub. Slack isn't one of them, so for Slack you would add a small verifier like the one above.

Fix Your Stripe Webhooks guide ($9) · Self-Hosted Webhook Relay Kit ($19)

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint. More fixes: all webhook error pages.