SapotaCorp

Verifying Slack Request Signatures: HMAC, Timestamps, and Replay Defense

Any POST that reaches your Slack endpoint could be forged. This walks through the HMAC-SHA256 signature Slack puts on every request, the 5-minute timestamp window that blocks replay, and the timing-safe comparison that keeps your verify path honest. Node.js and Python code included.

Verifying Slack Request Signatures: HMAC, Timestamps, and Replay Defense

Key takeaways

  • Slack signs every request with HMAC-SHA256 over the basestring v0:timestamp:body, and your endpoint must recompute that signature before trusting any payload.
  • Reject requests older than five minutes against the X-Slack-Request-Timestamp header so a captured request cannot be replayed indefinitely.
  • Compare signatures with a constant-time function such as crypto.timingSafeEqual or hmac.compare_digest, never with == or ===, to avoid leaking bytes through timing.
  • The signature proves a request came from Slack but not that it is unique, so a nonce store keyed on event_id or trigger_id is what actually stops replay.
  • Verification stops at authenticity; you still need a state parameter for OAuth CSRF, CSRF tokens on any dashboard, HTTPS, and input sanitization.

Your Slack app exposes an HTTP endpoint, and that endpoint is public. Slash commands, event subscriptions, interaction payloads all land there as plain POST requests, and anyone who learns the URL can POST to it pretending to be Slack. Without slack request signature verification, a forged request to a command like /payment-approve runs with the same authority as the real thing. So the first thing your handler does, before it parses a single field, is prove the request actually came from Slack.

Slack gives you the tool to do that. It signs every request with HMAC-SHA256 using a shared Signing Secret from your app config page. You recompute the signature on your side and check it matches. If it does not, you reject with a 401 and never touch the body.

Why this is not optional

I worked on a Slack app for the compliance team at a Vietnamese commercial bank. One slash command kicked off large inter-bank transfers, and compliance pulled the audit log every quarter. The rule was blunt: signature verification on 100% of endpoints, full stop. The threat model is easy to picture. If verification is missing and an attacker knows the endpoint URL, they POST a forged /payment-approve for a ten-figure transfer, the bot executes, money moves, and you are explaining a breach to a regulator. Authenticity at the edge is the whole game.

What Slack sends, and how the signature is built

Every request from Slack carries two headers plus the raw body:

  • X-Slack-Signature is the signature, formatted as v0=<hex>.
  • X-Slack-Request-Timestamp is the epoch seconds when Slack sent the request.

The body is raw, either form-urlencoded or JSON depending on the endpoint. The basestring is assembled from a version marker, the timestamp, and the raw body:

basestring = "v0:" + timestamp + ":" + raw_body
signature  = "v0=" + HMAC_SHA256(signing_secret, basestring).hex()

You compute the signature from your own basestring and compare it against the X-Slack-Signature header. Equal means the request is genuine. The word raw is load-bearing here, and it is where most people trip. More on that below.

Node.js implementation (Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

// Capture the raw body. Do NOT let express.json() touch this route first.
app.use("/slack/events", express.raw({ type: "*/*" }));

function verifySlackRequest(req, signingSecret) {
  const timestamp = req.header("X-Slack-Request-Timestamp");
  const signature = req.header("X-Slack-Signature");

  if (!timestamp || !signature) {
    return false;
  }

  // 1. Reject anything older than five minutes.
  const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
  if (parseInt(timestamp, 10) < fiveMinutesAgo) {
    return false;
  }

  // 2. Build the basestring and compute the signature.
  const rawBody = req.body.toString("utf8");
  const baseString = `v0:${timestamp}:${rawBody}`;
  const computed =
    "v0=" +
    crypto
      .createHmac("sha256", signingSecret)
      .update(baseString, "utf8")
      .digest("hex");

  // 3. Constant-time compare.
  const a = Buffer.from(computed, "utf8");
  const b = Buffer.from(signature, "utf8");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post("/slack/events", (req, res) => {
  if (!verifySlackRequest(req, process.env.SLACK_SIGNING_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const body = JSON.parse(req.body.toString("utf8"));
  // handle the event here
  res.status(200).send("");
});

Three steps: check the timestamp window, compute the signature, compare in constant time. Step three uses crypto.timingSafeEqual, not ===.

Python implementation (Flask)

import hmac
import hashlib
import time
import os
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"].encode("utf-8")

def verify_slack_request(req):
    timestamp = req.headers.get("X-Slack-Request-Timestamp", "")
    signature = req.headers.get("X-Slack-Signature", "")

    if not timestamp or not signature:
        return False

    # Five-minute window in both directions.
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False

    raw_body = req.get_data(as_text=True)
    base_string = f"v0:{timestamp}:{raw_body}"
    computed = "v0=" + hmac.new(
        SIGNING_SECRET,
        base_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(computed, signature)

@app.route("/slack/events", methods=["POST"])
def slack_events():
    if not verify_slack_request(request):
        abort(401)

    payload = request.get_json(force=True, silent=True) or request.form
    # handle the event
    return "", 200

Python uses hmac.compare_digest, which is constant-time and resistant to timing attacks, instead of ==.

The 5-minute timestamp window

The signature alone proves the request is genuine, but a genuine request can be captured and replayed. If an attacker grabs one valid Slack request, the signature stays valid because the payload never changes, so they could fire it again and again. The X-Slack-Request-Timestamp header is how you close that down: reject anything older than five minutes. An attacker who captures, decodes, and resends a request burns time doing it, and once five minutes pass the request is stale and refused.

Five minutes is a deliberate balance. It is long enough to tolerate the one-to-two minutes of clock skew you see in real fleets, and short enough to keep the replay window small. If your servers run NTP cleanly, you can tighten it. If you deploy across regions with sloppier sync, you may need to widen it, which I will come back to.

Why the comparison must be timing-safe

Comparing two strings with == returns early at the first byte that differs. That early exit leaks information. An attacker who can measure your server's response time probes the signature byte by byte: a wholly wrong guess is rejected almost instantly, a guess with one correct leading byte takes marginally longer because the comparison got one step further, and so on. Repeat the measurement and you reconstruct a valid signature one byte at a time.

A constant-time comparison always examines the full string, so every guess takes the same time and the attacker measures nothing useful. Use the one your runtime ships: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP, subtle.ConstantTimeCompare in Go. HMAC-SHA256 is not reversible even with byte probing, but timing-safe comparison is the established best practice and reviewers will look for it.

That came up directly in the bank audit. A junior dev had shipped if (computed === signature). The tests passed, because a correct signature matches and a wrong one does not, so nothing flagged it in CI. Only the security review caught it, and the audit required documented evidence of a timing-safe compare. We refactored to timingSafeEqual and added a line to the PR template: "HMAC verify uses a timing-safe comparison?"

The pitfalls that actually bite

Parsing the body before you verify. Express's express.json() parses the body into an object, and if you then re-stringify it to build your basestring, the bytes will not match what Slack signed. Whitespace and key order differ after a round trip. Register express.raw() on the Slack route before any global JSON parser, verify against the raw bytes, then parse.

The signing secret leaking into logs. A console.log("verifying with secret:", signingSecret) ships your secret to Datadog or Splunk, and anyone with log access can then forge every request. The bank's rule was that any env var ending in _SECRET, _KEY, or _TOKEN was banned from logs, enforced by a CI grep that rejected matching PRs. Wrap your logger to redact those fields.

Clock skew breaking verification quietly. If a host's NTP drifts, its Date.now() diverges from the timestamp Slack sends and every request starts failing as "stale." The tell is nasty: the app works on deploy, then degrades over a day, or one region in a multi-region deploy starts rejecting while the others are fine. Keep NTP synced, widen the window if you are distributed, and expose a healthcheck that returns server time so ops gets alerted when drift exceeds a threshold.

A skip toggle for development. The pattern if (process.env.NODE_ENV !== "production") skipVerify is a loaded gun. Someone forgets to flip the env on staging, or that code reaches prod, and the endpoint is wide open. Never give verification an off switch. Test locally with ngrok and a real signing secret, or use Bolt's socket mode, which avoids HTTP entirely.

Verification is necessary, not sufficient

A valid signature proves the request came from Slack. It does not prove the request is unique, and it does not prove the payload is safe. Those are separate problems.

Replay, even inside the five-minute window, lets an attacker resend a captured request many times. The fix is a nonce store: every event carries an event_id and every interaction a trigger_id, so record the ones you have processed in shared storage and drop duplicates. A Redis SET ... NX EX 600 does this in one call, with a TTL slightly longer than the timestamp window so memory frees itself.

async function isReplay(eventId) {
  // SET if not exists, expire after 10 minutes.
  const set = await redis.set(`slack:event:${eventId}`, "1", "EX", 600, "NX");
  return set === null; // null means the key already existed, so this is a replay
}

Two things matter here. Ack a replay with a 200, not a 4xx, or Slack keeps retrying. And do not use an in-memory Set if you run more than one instance, because a request hitting pod A leaves pod B unaware, and the replay sails through pod B. The bank's compliance requirement was that each /payment-approve processed exactly once with a unique transaction id in the log, and only a shared store delivers that.

CSRF shows up the moment your app has a web surface. In the OAuth install flow, an attacker can craft a callback URL with a stolen code and trick a user into clicking it; defend with a random state parameter bound to the user's session and verified on the callback. If you build a dashboard, say for compliance staff to revoke permissions or export logs, a malicious page can auto-submit a form against your POST endpoints while the admin's session cookie rides along. Defend with CSRF tokens, SameSite=Strict cookies, and an Origin or Referer check. Bolt verifies Slack signatures for you, but it does not cover a dashboard you built yourself.

And input is still untrusted. A verified signature confirms Slack is the sender, not that the modal text some user typed is safe to feed into a shell or a SQL string. Sanitize, escape, and validate the schema regardless of the signature.

Takeaway

Verify the HMAC-SHA256 signature on every Slack endpoint, enforce the five-minute timestamp window, and compare with a constant-time function. Then go past authenticity: a nonce store stops replay, a state parameter and CSRF tokens cover your web surfaces, HTTPS protects confidentiality, and input validation handles the payload. If you let a framework like Bolt verify for you, still understand the mechanism, because the day it fails in production you will be the one debugging it.

If your team is shipping a Slack app that touches money, PII, or anything an auditor will read, we can help you get the security model right before go-live. Reach out through /contact or see how we work on /service.

Engineering certifications

Sapota engineers hold credentials on Slack. Each badge links to the individual engineer's credly profile.

Browse Slack certs

Need this on your team?

Sapota engineers ship the patterns you read here. Two-week paid trial, direct pricing from $1,800/ engineer/month, no agency markup.

Get a quote
Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project