DEV Community

EventDock
EventDock

Posted on • Originally published at eventdock.app

How to Verify Slack Request Signatures (and Not Get Your Endpoint Disabled)

Slack signs every request it sends your app with a scheme it calls v0. The verification itself is a few lines, but two details trip people up more than anything else: you have to hash the raw request body (not a re-encoded copy of it), and the timestamp is part of the signed string. Get either wrong and every request looks forged. This post shows the exact v0 verification, the raw-body trap, and why a failed check can quietly get your endpoint disabled.

For how this compares to the other major providers, see webhook signature verification compared across Stripe, Paddle, HubSpot, Slack, and Twilio.

The two headers Slack sends

Every request from Slack carries two headers you need:

  • X-Slack-Signature: the signature, always prefixed with v0= followed by a hex digest.
  • X-Slack-Request-Timestamp: a Unix timestamp (seconds) for when Slack sent the request.

The signing secret you verify against is not a bot token. You find it in your app config under Basic Information, and it looks like a 32-character hex string. One signing secret per Slack app.

What Slack actually signs

Slack builds one string and signs it:

v0:{timestamp}:{raw_request_body}
Enter fullscreen mode Exit fullscreen mode

Three parts joined by colons: the literal v0, the value from the X-Slack-Request-Timestamp header, and the exact raw body of the request. It then computes an HMAC-SHA256 of that string using your signing secret as the key, hex-encodes it, and prefixes v0=. That final value is what arrives in X-Slack-Signature.

The part people miss: the timestamp is inside the signed string. If you hash only the body, or only the body plus secret, you will never match Slack. The timestamp being signed is also what lets you reject replays, because an attacker cannot swap in a fresh timestamp without breaking the signature.

The raw-body trap

HMAC signs bytes. Slack computed its digest over the exact bytes it put on the wire. Slash commands arrive as application/x-www-form-urlencoded and event and interaction payloads arrive as JSON, but in both cases you must verify against the untouched body.

Here is how the bytes get changed without you noticing. Your web framework reads the body and parses it into an object for you. You then rebuild a string from that object to pass to your verify function. That rebuilt string is not byte-identical to what Slack sent, because form encoding, key order, and escaping can all shift. The HMAC no longer matches and you reject every real request. Verify against the raw body first, parse it only after the signature passes.

Verifying a v0 signature in Node.js

This reads the two headers, rebuilds the signed string, computes the HMAC, compares in constant time, and rejects anything older than five minutes.

import crypto from 'crypto';

function verifySlackRequest(
  rawBody,          // the exact bytes Slack sent, NOT a re-encoded object
  signatureHeader,  // X-Slack-Signature, e.g. "v0=a2b1c3..."
  timestamp,        // X-Slack-Request-Timestamp
  signingSecret     // from Basic Information -> App Credentials
) {
  // Reject replays. The timestamp is signed, so it cannot be forged fresh.
  const fiveMinutes = 60 * 5;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > fiveMinutes) {
    return false;
  }

  const base = 'v0:' + timestamp + ':' + rawBody;
  const expected = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(base, 'utf8')
    .digest('hex');

  // Constant-time compare to avoid leaking the digest through timing.
  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

There is no JSON.parse or form parser in that function. Parsing happens in your handler, after the check returns true.

Getting the raw body in Express and Next.js

The verify function is the easy part. Handing it the untouched body is where frameworks fight you, because they parse the body by default.

// Express: raw parser on the Slack route so req.body stays a Buffer
app.post('/slack/events',
  express.raw({ type: '*/*' }),   // Slack sends both urlencoded and JSON
  (req, res) => {
    const sig = req.get('X-Slack-Signature');
    const ts = req.get('X-Slack-Request-Timestamp');
    if (!verifySlackRequest(req.body.toString('utf8'), sig, ts, process.env.SLACK_SIGNING_SECRET)) {
      return res.status(401).send('bad signature');
    }
    res.status(200).send('');       // ack within 3 seconds
    handleSlackEvent(req.body.toString('utf8')).catch(console.error);
  }
);
Enter fullscreen mode Exit fullscreen mode

In a Next.js route handler, read the body once with await req.text() and do not call req.json() or req.formData() first, because the stream can only be read once. In Cloudflare Workers, await request.text() gives you the raw bytes and the Web Crypto API does the HMAC.

Why a failed check is worse than a rejected request

Slack expects a 2xx response within 3 seconds. For the Events API, if your endpoint is slow, errors, or rejects a request Slack thinks is valid, Slack retries the delivery up to three times: nearly immediately, then after 1 minute, then after 5 minutes. And if your failure rate climbs past 95 percent of delivery attempts inside a 60-minute window, Slack temporarily disables event delivery to your app, so you stop receiving events entirely until you notice and re-enable it. A bad deploy or a database timeout during a busy window can push you there, and a missed app_mention or interaction is a user action your app silently ignored.

Signature verification protects you from forged requests. It does nothing for the requests you drop because your handler was mid-deploy, or the ones Slack stops sending after it decides your endpoint is unhealthy. That is the gap EventDock fills. You point Slack at EventDock instead of your app. EventDock verifies the request, stores it, and acknowledges Slack right away so the 3-second window is never the thing that fails, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait in the queue and arrive once it recovers.

You can wire it up on the free tier and watch every Slack event get captured and delivered. Start with EventDock free, or read how the exactly-once processing pattern handles the duplicate deliveries that Slack, like every retrying sender, will eventually send you.

Top comments (0)