Skip to content
All posts

How to verify webhook signatures: Stripe, GitHub, Slack

Every major webhook provider signs their requests with HMAC. Here's the working verification code for each, the timing-attack pitfall everyone hits, and a universal template.

DDDev DeskDeveloper Tools EditorPublished April 27, 20261 min readintermediate

# The universal pattern

Every webhook provider does some variant of:

1. Take the raw request body

2. Concatenate with a timestamp

3. HMAC-SHA256 with a shared secret

4. Send the hex digest in a header

You reverse it:

1. Read the raw body (not parsed JSON — byte-for-byte)

2. Extract timestamp + signature from headers

3. Recompute the HMAC

4. Constant-time compare

<div class="callout callout-warning" role="note"><div class="callout-title">Warning</div><div class="callout-body"><p>The #1 cause of "my verification fails" is that a middleware parsed the JSON before you got to the handler. JSON stringify → re-stringify is NOT byte-identical to the original. You need the raw bytes.</p></div></div>

# Stripe

Header: Stripe-Signature: t=1234567890,v1=abc123...


import { createHmac, timingSafeEqual } from "node:crypto";
function verifyStripe(rawBody: Buffer, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=") as [string, string]),
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;
  // 5-minute tolerance — reject old requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) return false;
  const signed = `${timestamp}.${rawBody.toString("utf8")}`;
  const expected = createHmac("sha256", secret).update(signed).digest("hex");
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}

# GitHub

Header: X-Hub-Signature-256: sha256=abc123...


function verifyGithub(rawBody: Buffer, header: string, secret: string): boolean {
  if (!header?.startsWith("sha256=")) return false;
  const sig = header.slice("sha256=".length);
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  if (expected.length !== sig.length) return false;
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex"));
}

GitHub doesn't include a timestamp in the header, so replay protection has to be done a different way (idempotency key, or checking the delivery ID).

# Slack

Headers: X-Slack-Signature: v0=abc123... + X-Slack-Request-Timestamp: 1234567890


function verifySlack(
  rawBody: Buffer,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) return false;
  const signed = `v0:${timestamp}:${rawBody.toString("utf8")}`;
  const expected = "v0=" + createHmac("sha256", secret).update(signed).digest("hex");
  if (expected.length !== signature.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

# The timing-attack lesson


// ❌ WRONG — leaks signature byte-by-byte via timing
if (expected === actual) { ... }
// ✅ RIGHT — constant time regardless of where they differ
if (timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) { ... }

<div class="callout callout-danger" role="note"><div class="callout-title">Danger</div><div class="callout-body"><p>Timing attacks are real. In 2013, a production Github attack vector existed because of non-constant-time comparison. Node's <code>crypto.timingSafeEqual</code> is the one-line fix.</p></div></div>

# Python


import hmac, hashlib, time
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

# Go


import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
)
func verify(rawBody []byte, signature, secret string) bool {
  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write(rawBody)
  expected := hex.EncodeToString(mac.Sum(nil))
  return hmac.Equal([]byte(expected), []byte(signature))
}

# Framework-specific: getting the raw body

# Express


// For a specific route — raw body instead of parsed JSON
app.post("/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const valid = verifyStripe(req.body, req.headers["stripe-signature"], SECRET);
    // req.body is a Buffer here
  },
);

# FastAPI (Python)


@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    raw = await request.body()
    sig = request.headers["stripe-signature"]
    if not verify(raw, sig, SECRET):
        return Response(status_code=400)

# Next.js

Use a Route Handler + request.text():


export async function POST(request: Request) {
  const raw = await request.text();
  const sig = request.headers.get("stripe-signature");
  // verify, then parse JSON
}

# Debugging "signature doesn't match"

When verification fails, check in this order:

1. Are you using the raw body? A parsed-and-re-stringified JSON body will not match. Bytes in = bytes the signer used.

2. UTF-8 vs UTF-16 vs Latin-1. JavaScript strings are UTF-16; sign/verify both sides as UTF-8 bytes.

3. Leading/trailing whitespace. Some frameworks trim headers; some add newlines. Log rawBody.length.

4. The right secret. Test mode vs live mode secrets are different in Stripe, Shopify, Square — easy to mix up.

5. Algorithm mismatch. Most providers use SHA-256; a few use SHA-1 (GitHub's old X-Hub-Signature header) or SHA-512. Read the docs.

# Try it

Generate or verify HMAC-SHA256 signatures in-browser with our HMAC Generator. Paste a secret + a message, get the hex/base64 signature back instantly — useful for testing your webhook handler with a known-good signature.

Frequently asked questions

Why does it matter if I verify the signature?

Anyone who finds your webhook URL can POST fake payloads. Without verification, they can trigger any side effect the handler does — issue refunds, send messages, update database records. The signature proves the request came from the provider you share a secret with.

What's the 'timing attack' everyone mentions?

Plain `==` string comparison returns as soon as two bytes differ. Measuring the response time byte-by-byte lets an attacker brute-force the signature one character at a time. Use a constant-time comparison function to make every comparison take the same time regardless of where they differ.

Do I need to verify the timestamp separately?

Yes. The signature proves the payload wasn't altered, but not that it wasn't replayed. Check the timestamp is recent (within 5 minutes is common) and reject older ones.

Haftada bir kez yeni gönderiler.

Pratik geliştirici rehberleri. Spam yok. İstediğiniz zaman abonelikten çıkabilirsiniz.

Tools mentioned

Keep reading