Verifying Slack Webhook Signatures (With AI Help)
Correctly verify Slack request signatures using the v0 HMAC SHA256 scheme, constant-time compare, and replay window, with AI as a fast junior you review.
- #slack
- #chatops
- #security
- #webhooks
The first Slack app I shipped had a signature check that “worked.” It returned 200 for every real request, my slash command fired, and I felt great. Then a colleague replayed a captured request from a week earlier and my endpoint cheerfully processed it. My check verified the secret but ignored the timestamp, so I had no replay protection at all. It compiled, it ran, the demo looked perfect — and it was wrong in exactly the way security code is most dangerous to be wrong.
I bring this up because I now write this code with AI assistance, and that experience is the whole reason I trust AI here only as a fast junior engineer: it produces a confident, runnable draft in seconds, and confident-but-subtly-wrong is precisely the trap. You own correctness. The model helps you get there faster.
How Slack actually signs requests
Slack signs every request to your endpoint. Two headers matter:
X-Slack-Request-Timestamp— Unix seconds when Slack sent it.X-Slack-Signature— the HMAC, prefixed with the version tagv0=.
The signature is computed by concatenating a basestring of the form v0:{timestamp}:{raw_body}, then taking HMAC-SHA256 of that string keyed by your app’s signing secret (found under Basic Information → App Credentials in the Slack app config — not the bot token). You recompute the same value and compare.
Three details are where every broken implementation goes wrong:
- You must hash the raw, unparsed request body. If your framework parses JSON or form data and you re-serialize it, the bytes won’t match and every signature fails — or worse, you “fix” it by loosening the check.
- The compare must be constant-time. A naive
==leaks timing information that can, in principle, be exploited to forge a signature byte by byte. - You must reject old timestamps — Slack recommends a 5-minute window — to stop replay attacks. This is the part my younger self forgot.
A correct Python (Flask) implementation
Here’s an implementation I’m willing to stand behind. Read it as the reference, not as something to paste unread.
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"].encode() # never hard-code
def verify_slack_request(raw_body: bytes, timestamp: str, signature: str) -> bool:
# 1. Reject stale requests (replay protection): 5-minute window.
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > 60 * 5:
return False
# 2. Recompute over the EXACT basestring: v0:timestamp:raw_body
basestring = b"v0:" + timestamp.encode() + b":" + raw_body
computed = "v0=" + hmac.new(SIGNING_SECRET, basestring, hashlib.sha256).hexdigest()
# 3. Constant-time comparison.
return hmac.compare_digest(computed, signature or "")
@app.post("/slack/events")
def slack_events():
raw = request.get_data() # RAW bytes, before any parsing
ts = request.headers.get("X-Slack-Request-Timestamp", "")
sig = request.headers.get("X-Slack-Signature", "")
if not verify_slack_request(raw, ts, sig):
abort(403)
# ...only now is it safe to parse and act on the payload
return "", 200
Note request.get_data() is called before anything touches the body. The moment you let Flask parse it and reach for request.json, you’ve lost the original bytes.
Pro Tip: Test the failure paths, not just the happy path. Send a request with a timestamp from ten minutes ago, one with a flipped byte in the signature, and one with an empty signature header. If any of those returns 200, your check is broken regardless of how well legitimate requests pass.
The same logic in Node
If your stack is JavaScript and you’re not using Bolt’s built-in verification, the structure is identical. The trap here is Express’s body parser — capture the raw buffer first.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Preserve the raw body so we can verify the signature over exact bytes.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function verifySlack(req) {
const ts = req.get('X-Slack-Request-Timestamp');
const sig = req.get('X-Slack-Signature') || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const base = `v0:${ts}:${req.rawBody.toString('utf8')}`;
const computed = 'v0=' +
crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET)
.update(base).digest('hex');
const a = Buffer.from(computed);
const b = Buffer.from(sig);
// timingSafeEqual throws if lengths differ, so guard first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/slack/events', (req, res) => {
if (!verifySlack(req)) return res.sendStatus(403);
res.sendStatus(200);
});
crypto.timingSafeEqual throws on length mismatch, which is why the length guard comes first. A model drafting this often forgets that guard and ships code that crashes on malformed input — a denial-of-service waiting to happen. If you use the framework’s built-in verifier (Bolt does this for you), prefer it; it’s battle-tested. Hand-rolling is for when you genuinely can’t.
Where AI helps, and where it must not be trusted
I genuinely use AI to draft and review this code. It’s faster than I am at boilerplate and it catches typos I’d miss. But here’s the line I never cross: I never give the model a real signing secret or token. The secret lives in an environment variable; the model only ever sees the shape of the code. There is no reason it should hold a credential, and every reason it shouldn’t — anything you paste into a prompt is out of your control.
The productive workflow is to have the model audit your verification, not write it unsupervised. I’ll paste my implementation and ask it to find ways an attacker could bypass the check. It’s surprisingly good at that — it’ll spot the missing replay window or the non-constant-time compare. I run exactly that pass through /dashboard/code-review/ on every security-sensitive change, and I keep a sharpened audit prompt in /prompts/. Tools like Claude, Cursor, and GitHub Copilot all draft this competently — and all three will, on a bad day, hand you something that looks right and isn’t.
So: let the fast junior write the first pass and stress-test your final version. But the decision that the signature check is correct is yours, made by reading the code and running adversarial tests — never by trusting the model’s “looks good to me.”
Wrapping up
Slack’s signing scheme is not complicated, but it’s unforgiving: raw body, v0:timestamp:body basestring, HMAC-SHA256 with the signing secret, constant-time compare, and a 5-minute replay window. Get all five right and your endpoint is solid. Miss one and you’ve got a hole that demos perfectly.
Use AI to move faster, treat it as the talented junior it is, verify every signature, and keep your secrets out of the prompt. For more on building safe Slack integrations, browse /categories/slack/ — and may you never process a week-old replayed request the way I once did.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.