ToolsFree tool
Slack request signature verifier
Check an X-Slack-Signature header for slash commands, interactivity, and Events API requests, and see why verification fails. Nothing you paste leaves your browser.
The result shows up here
Paste your app's signing secret, the two X-Slack headers, and the raw body from a request, then verify. To see how it works first, use Try an example.
What gets signed
HMAC-SHA256(secret, "v0:" + timestamp + ":" + body)The body was decoded or parsed first
Slash commands and interactive components arrive as application/x-www-form-urlencoded, and Slack signs those raw bytes with their percent-escapes. Once a framework parses the form into fields, the signed bytes are gone. In Flask, call request.get_data() before reading request.form; in Express, use express.raw({ type: "*/*" }) on the route.
The interactivity payload was taken out of the form
Interactive requests send payload= with the JSON URL-encoded inside it. Verify the whole form body first, then read and parse the payload field.
The base string is wrong
Slack signs v0:timestamp:body, joined with colons, using the X-Slack-Request-Timestamp value exactly as sent. Leaving out v0:, joining with periods, or using your own clock's time all produce a different signature.
The wrong secret
Use the Signing Secret from your app's Basic Information page. The Verification Token on the same page is a different, deprecated value, and bot or user tokens (xoxb-, xoxp-) aren't signing secrets at all. Regenerating the secret stops the old one from working.
The request is too old
Reject requests whose timestamp is more than five minutes from your clock, to block replays. A signature that verifies here but fails in your app may just be a request captured earlier. Keep your clock synced with NTP.
Something already read the body
Bolt checks the signature for you when you give it the signing secret. If you add your own check after Bolt or another middleware has consumed the body, it hashes something other than the raw request and fails even for genuine requests.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received (a Buffer), before any form or JSON parsing.
export function verifySlackRequest(rawBody, headers, signingSecret) {
const timestamp = headers["x-slack-request-timestamp"] ?? "";
const header = headers["x-slack-signature"] ?? "";
if (!/^\d+$/.test(timestamp) || !header.startsWith("v0=")) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", signingSecret)
.update("v0:" + timestamp + ":")
.update(rawBody)
.digest();
const signature = Buffer.from(header.slice("v0=".length), "hex");
return signature.length === expected.length && timingSafeEqual(signature, expected);
}With Express, use express.raw({ type: "*/*" }) on this route so rawBody is the original Buffer, form-encoded or JSON. Bolt for JavaScript runs this check itself.
Slack signs each request with your app's signing secret and sends two headers:
X-Slack-Request-Timestamp: when the request was sent, in Unix seconds.X-Slack-Signature:v0=followed by the HMAC-SHA256 of the base string, as hex.
To verify a request:
- Reject it if the timestamp is more than five minutes from your clock.
- Build the base string
"v0:" + timestamp + ":" + raw body. - Compute HMAC-SHA256 of it, keyed with the signing secret's UTF-8 bytes, and hex-encode the result.
- Add the
v0=prefix and compare it in constant time withX-Slack-Signature.
This works the same for slash commands, interactivity, and the Events API; only the body's format differs. Slack's Bolt frameworks do the check for you when you configure the signing secret.
Sending webhooks from your own product? Cascadehook signs every delivery with Standard Webhooks, retries failures for up to 72 hours, and keeps every attempt for replay. Create a project or try the Standard Webhooks verifier.
Slack is a trademark of Slack Technologies, LLC. Cascadehook isn't affiliated with or endorsed by Slack.