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

Paste the provider's signature header as a JSON object. Header names are matched case-insensitively.

The secret is sent to AnyHook's own API to compute the HMAC. It is not stored or logged, and the result only ever shows masked signatures.

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.

MistakeSymptomFix
Verification token used as the signing secretEvery 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 stringThe 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 checkOld 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-encodedVerification 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 readOne 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 comparisonA 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 serverValid 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 endpointOnly 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.

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.