Verify a Slack request signature

"v0=" + hex(HMAC-SHA256(signing secret, "v0:" + timestamp + ":" + raw body))

X-Slack-Signature is v0= followed by the hex HMAC-SHA256 of the basestring v0:<timestamp>:<raw body>, keyed with your app’s signing secret read as plain UTF-8 text. The timestamp is the X-Slack-Request-Timestamp header. Slack’s docs check it first and drop any request more than five minutes from local time, since it could be a replay. Their worked example, a slash command:

const crypto = require('node:crypto');

const secret = '8f742231b10e8888abcd99yyyzzz85a5';
const body = 'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c';
'v0=' + crypto.createHmac('sha256', secret).update(`v0:1531420618:${body}`).digest('hex');
// 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503'

The tool below signs the same 376-byte basestring and matches Slack’s a2114d57….

Verify it in Node

function verifySlack(secret, rawBody, timestamp, signature) {
  const now = Math.floor(Date.now() / 1000);
  if (!/^\d+$/.test(timestamp ?? '') || Math.abs(now - Number(timestamp)) > 60 * 5) return false;
  const expected = Buffer.from('v0=' + crypto.createHmac('sha256', secret).update(`v0:${timestamp}:`).update(rawBody).digest('hex'));
  const received = Buffer.from(signature ?? '');
  // timingSafeEqual throws on different lengths, so check them first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Slash commands and interactive components arrive as application/x-www-form-urlencoded, Events API callbacks as JSON. Read both raw:

const express = require('express');
const app = express();

app.post('/slack/commands', express.raw({ type: ['application/x-www-form-urlencoded', 'application/json'] }), (req, res) => {
  const ok = Buffer.isBuffer(req.body)
    && verifySlack(process.env.SLACK_SIGNING_SECRET, req.body, req.get('X-Slack-Request-Timestamp'), req.get('X-Slack-Signature'));
  if (!ok) return res.status(401).send('Bad signature');
  const command = new URLSearchParams(req.body.toString('utf8'));
  res.send(`ok ${command.get('command')}`);
});

With express.urlencoded() in front instead, req.body is an object. Passing it on throws The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Object, and JSON.stringify(req.body) signs {"token":…}, which Slack never sent. Bolt for JavaScript verifies requests itself, and exports verifySlackRequest for other servers.

Verify it in Python

import hashlib
import hmac
import time

def verify_slack(secret: bytes, raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not (timestamp or "").isdecimal() or abs(time.time() - int(timestamp)) > 60 * 5:
        return False
    basestring = b"v0:" + timestamp.encode() + b":" + raw_body
    expected = "v0=" + hmac.new(secret, basestring, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (signature or "").encode())

In Flask, call request.get_data() before anything touches request.form, as Slack’s docs say. Read the form first and get_data() returns 0 bytes. In FastAPI, await request.body() gives the bytes, and parse_qs reads them without the python-multipart package that request.form() needs:

import os
from urllib.parse import parse_qs

from fastapi import FastAPI, HTTPException, Request
from flask import Flask, abort, request

SECRET = os.environ["SLACK_SIGNING_SECRET"].encode()
app = Flask(__name__)

@app.post("/slack/commands")
def slack_command():
    raw = request.get_data()
    if not verify_slack(SECRET, raw, request.headers.get("X-Slack-Request-Timestamp"), request.headers.get("X-Slack-Signature")):
        abort(401)
    return f"ok {request.form['command']}"

api = FastAPI()

@api.post("/slack/commands")
async def slack_command_fastapi(request: Request):
    raw = await request.body()
    if not verify_slack(SECRET, raw, request.headers.get("X-Slack-Request-Timestamp"), request.headers.get("X-Slack-Signature")):
        raise HTTPException(status_code=401)
    return {"ok": parse_qs(raw.decode())["command"][0]}

The official SDK has the same check: slack_sdk.signature.SignatureVerifier(secret).is_valid(body, timestamp, signature).

The five-minute window

The timestamp is part of the basestring, so it can’t be changed without breaking the signature, and the window is what stops a captured request from being replayed later. That makes the check strict about time: Slack’s own example from 2018 fails it today, and SignatureVerifier.is_valid returns False for it. Test with fresh timestamps, and keep the server’s clock synced. hmac.compare_digest needs no length check, but give it bytes: a str with a non-ASCII character makes it raise TypeError.

Try it in the HMAC Generator

Signed as the UTF-8 bytes of what's pasted, spaces included, with every line break written as the one chosen. This box keeps line breaks as LF whatever was pasted, so pick CRLF for a body sent with CRLF line endings.

The key never leaves your browser: the signature is worked out here in the page, and nothing is sent anywhere.

Algorithm
Written as
Signature · SHA-256

a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503

376 bytes of message, 32 of key.

Matches — this message, signed with this key.

How webhook signatures are checked

Both services below sign exactly the way this tool does, so the value after the prefix is an HMAC you can check here.

ServiceWhat it sendsWhat the signature is made of
GitHubX-Hub-Signature-256: sha256=<hex>HMAC-SHA256 of the raw request body, keyed with the webhook secret.
StripeStripe-Signature: t=<time>,v1=<hex>HMAC-SHA256 of <time>.<raw body>, keyed with the signing secret, whsec_… and all.

GitHub: paste the raw body as the message, the secret as the key, and what follows sha256= into Check a signature. Stripe: sign t.body — the timestamp from the header, a dot, then the raw body — with the whole signing secret as the key, and compare against v1=.

Runs in your browser. Nothing you enter is sent anywhere.