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.