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.

Common questions

Frequently asked.

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.

Nieuwe berichten, één keer per week.

Praktische handleidingen voor ontwikkelaars. Geen spam. Uitschrijven kan op elk moment.

Tools mentioned

Pick up where the post leaves off.

Keep reading

More from the field notes.