Slack webhook signature verification
Slack signs requests with v0= plus an HMAC-SHA256 over the version, the request timestamp and the raw body. Paste all three into this checker.
Last reviewed · scheme facts from the site's provider table · Slack documentation
Check a Slack signature
The exact rule
These are the facts this verifier implements, read from the same provider table that powers /providers. A mismatch here is a bug in the tool, not a preference.
- Signature header
- x-slack-signature = v0=hex
- Algorithm
- HMAC-SHA256
- Signed string
- v0:{timestamp}:{body}
- Encoding
- hex
- Timestamp / tolerance
- 5 min
- Signing secret
- App signing secret
How Slack signs a webhook
The scheme is `X-Slack-Signature: v0=<hex>` with `X-Slack-Request-Timestamp: <unix seconds>`. Slack computes HMAC-SHA256 with the app's signing secret as the key and the string `v0:<timestamp>:<raw body>` as the message, then hex-encodes it. The `v0=` prefix is part of the header value and is compared along with the digest, unlike GitHub's `sha256=`, which is stripped before comparing hex.
The timestamp value in the header is used verbatim in the signed string: if the header says `1700000000`, the HMAC input starts `v0:1700000000:`. Parsing it for the tolerance check is expected, but build the HMAC input from the header's string, not from a reformatted number.
Slack's five-minute window is enforced against the timestamp, not the signature. The tool applies it by default for this provider, because an old Slack request is exactly what Slack itself would reject; the code samples show both the window check and the constant-time compare.
Which secret to use, and what the timestamp means
Use the app's signing secret from api.slack.com/apps → your app → Basic Information → App Credentials → Signing Secret. The verification token and the client secret are different values; using either produces a v0 signature that never matches. Slack has also moved away from the verification token, which is another reason not to reach for it.
Slack's tolerance is five minutes, and the provider table this site publishes records it. The tolerance matters for two reasons: it rejects old captured requests, and it makes clock skew visible. A server whose clock is minutes off will fail valid Slack requests even with the right secret, so check the timestamp delta when a delivery that looks correct is rejected.
Store the signing secret per installed app, in an environment variable or secret manager, and read it at verification time. A workspace can install more than one app on the same endpoint; if the receiver keeps only one global secret, every request from the second app fails while the first keeps working. Log the app or team id alongside the failure so a mismatch is obvious rather than mysterious.
The raw body is the input
The HMAC covers the raw request body. Slack's Events API posts JSON, and interactive payloads can be form-encoded; either way, the bytes matter, not the parsed structure. Read the body before your framework's parser or keep a raw copy, and compute the HMAC over that exact value.
Do not URL-decode, JSON.parse-then-stringify or trim the body before verifying. Slack's sample payloads in documentation are illustrative; your verifier has to match the bytes on the wire, not the pretty-printed example.
Interactive payloads are the other shape: Slack can post a form body containing a `payload` field whose value is JSON. The signature still covers the raw form bytes, including the percent-encoding, so a handler that reads the parsed field and re-encodes it will fail. Verify first, then decode the field.
A Slack retry is an ordinary signed request with the same timestamp as the first attempt; X-Slack-Retry-Num and X-Slack-Retry-Reason describe it and are not part of the HMAC. Deduplicate on the event's own id (or the interactive callback's id) rather than on the signature, which will match both requests.
A sample signature header
The sample signature and timestamp below were generated from this body with the made-up secret above and a fixed timestamp; this tool accepts it only while that timestamp is inside Slack's five-minute window. Load a fresh sample for a set that verifies right now.
Header(s)
{
"x-slack-signature": "v0=d2d900b426f05cc3db93227dc22b3cd5d1c622cf3783a19fc3daacd0896ede59",
"x-slack-request-timestamp": "1700000000"
}Raw body
{"type":"event_callback","event":{"type":"app_mention","text":"hello"},"team_id":"T_EXAMPLE"}Example secret (not a real credential)
slack_example_signing_secret_not_real
Verify it in your own server
Three complete implementations of the same rule. Each reads the exact header(s), HMACs the raw body with the secret and compares in constant time; the comment at the top says where the raw body comes from in that runtime.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody exact; signatureHeader: x-slack-signature; timestampHeader: x-slack-request-timestamp.
export function verifySlackSignature(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader.startsWith("v0=")) return false;
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > 300) return false;
const expected = "v0=" + createHmac("sha256", secret)
.update("v0:" + timestampHeader + ":" + rawBody)
.digest("hex");
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(signatureHeader);
return (
expectedBuffer.length === providedBuffer.length &&
timingSafeEqual(expectedBuffer, providedBuffer)
);
}Python
import hashlib
import hmac
import time
def verify_slack_signature(raw_body: bytes, signature_header: str,
timestamp_header: str, secret: str) -> bool:
if not signature_header.startswith("v0="):
return False
try:
timestamp = int(timestamp_header)
except ValueError:
return False
if abs(int(time.time()) - timestamp) > 300:
return False
base = f"v0:{timestamp_header}:".encode() + raw_body
expected = "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)PHP
<?php
// $rawBody = file_get_contents("php://input");
function verify_slack_signature(string $rawBody, string $signatureHeader,
string $timestampHeader, string $secret): bool {
if (!str_starts_with($signatureHeader, "v0=")) {
return false;
}
if (abs(time() - (int) $timestampHeader) > 300) {
return false;
}
$expected = "v0=" . hash_hmac("sha256", "v0:" . $timestampHeader . ":" . $rawBody, $secret);
return hash_equals($expected, $signatureHeader);
}Common Slack failures
Each row is a failure seen while verifying this scheme, not a generic webhook checklist. The symptom column is what the checker or your handler reports; the fix is the change that resolves it.
| Mistake | Symptom | Fix |
|---|---|---|
| Verification token used as the signing secret | Every request fails the HMAC; the token looks like a Slack credential. | Use the Signing Secret from Basic Information → App Credentials. |
| Timestamp left out of the signed string | The v0 digest never matches, even with the correct secret and body. | HMAC `v0:<timestamp>:<raw body>` where the timestamp is the header value verbatim. |
| No timestamp-tolerance check | Old captured requests verify forever; replay protection is lost. | Reject when the absolute difference from now exceeds 300 seconds, as Slack does. |
| Body parsed then re-encoded | Verification passes in the tool but fails in the handler that reads JSON first. | Read the raw body once and use that string for both the HMAC and parsing. |
| Missing or reordered header read | One of the two headers is empty at the verifier, or the wrong header is used as the timestamp. | Require both X-Slack-Signature and X-Slack-Request-Timestamp; do not substitute a local clock value. |
| Prefix dropped before comparison | A hex digest is compared against the full `v0=…` header and always fails. | Compare the whole header value with `v0=` plus the computed hex, or compare after removing it from both sides. |
| Clock skew on the receiving server | Valid requests fail only when the server clock drifts; retries behave differently. | Keep NTP running and report the timestamp delta in logs while diagnosing. |
| Several apps share one endpoint | Only one app's requests verify; the others fail with the same code. | Each Slack app has its own signing secret; select the secret per app before verifying. |
Slack signature FAQ
- Where is the Slack signing secret?
- api.slack.com/apps → your app → Basic Information → App Credentials → Signing Secret. It is not the client secret and not the verification token.
- Why is the timestamp part of the signature?
- It binds the signature to a moment so Slack can reject stale requests. Without including it in the HMAC, an attacker could reuse a captured signature with any timestamp.
- Does this checker enforce Slack's five minutes?
- Yes, for Slack. The page reports the timestamp delta and rejects a signature older than five minutes, matching Slack's own rule. The code samples show both checks so you can keep them together.
- Can I verify a Slack request without Slack's SDK?
- Yes. The HMAC and the constant-time compare in the samples use only the standard library; the SDK is convenience, not a requirement.
- Why does the v0 prefix stay in the comparison?
- Slack puts `v0=` in the header and the prefix identifies the signature version. Compare the full header value; dropping the prefix from only one side is a common mismatch.
- What if my server clock is wrong?
- Every request will fail the age check even with the right secret. Compare the reported timestamp delta with a known-good clock; fixing NTP usually fixes the verifier.
What this tool cannot tell you
- It cannot prove Slack sent the request; a valid HMAC proves the request was signed with the app's signing secret.
- It cannot check whether your server clock is accurate beyond reporting the timestamp delta.
- It cannot verify Slack's IP ranges or OAuth scopes; those are separate controls.
- It cannot validate an interactive payload's business meaning — only the signature over its bytes.
Related
Other schemes
Next checks
A valid signature does not mean the delivery succeeded. Time your handler against each sender's response budget, and keep the event so a failure can be replayed.