DEV Community

Cover image for Five ways chat platforms sign webhooks, and how to verify every one
Ibrahim Hajjaj
Ibrahim Hajjaj

Posted on

Five ways chat platforms sign webhooks, and how to verify every one

If you connect one chat platform you write one signature check. If you connect six you discover they agree on almost nothing: not the algorithm, not what gets signed, not even whether there is a signature at all.

I wired up eleven channels for an open source support agent and had to verify all of them. This is what each platform actually does, which three have a detail that will cost you an afternoon, and how to test the whole set without opening six developer accounts.

The five schemes

Across every mainstream chat platform there are only five patterns. Once you have written these, a new platform is almost always a variation.

1. HMAC over the raw body. Meta's platforms: WhatsApp, Messenger, Instagram.

X-Hub-Signature-256: sha256=<hmac(appSecret, rawBody)>
Enter fullscreen mode Exit fullscreen mode

2. HMAC over a timestamped payload. Slack signs a string it builds from a version tag, a timestamp and the body.

v0:<timestamp>:<rawBody>  ->  hmac  ->  X-Slack-Signature: v0=<hex>
Enter fullscreen mode Exit fullscreen mode

3. HMAC over the exact URL plus sorted parameters. Twilio, for SMS and voice.

4. Ed25519 public key signature. Discord. Not an HMAC at all: you verify with their public key rather than a shared secret.

5. No signature, a shared secret instead. Telegram sends a token you registered, in a header, and you compare it.

Plus one that is none of the above: Microsoft Teams sends a JWT through the Bot Connector, which you validate like any other token, against their signing keys.

The three that bite

Everything above is in the docs. These are the parts that are technically in the docs and still cost people a day.

Raw body, not parsed body

Every HMAC scheme signs the bytes that arrived. If your framework has already parsed the JSON and you re-serialise it to check the signature, you have changed it. Key order can move. Unicode can be re-escaped. A trailing newline can vanish.

The signature then fails on exactly the requests that contain an emoji or a non-Latin name, which is a spectacular bug to debug because it works in every test you wrote.

Capture the raw body before anything touches it:

const raw = await request.text()
const valid = await verify(raw, request.headers.get('x-hub-signature-256'))
const body = JSON.parse(raw)   // only after verifying
Enter fullscreen mode Exit fullscreen mode

Compare in constant time

// leaks how many leading bytes were correct, one request at a time
if (mine === theirs) { ... }
Enter fullscreen mode Exit fullscreen mode

A plain string comparison returns as soon as two bytes differ, so how long it takes tells an attacker how much of their guess was right. Use a timing-safe comparison. On a runtime with Web Crypto and no Node built-ins, crypto.subtle.verify does this for you; otherwise compare byte by byte and accumulate into a single result rather than returning early.

Slack's timestamp is part of the defence

Slack puts the timestamp in the signed string so you can reject replays. Verifying the HMAC and ignoring the timestamp throws away half of what the scheme buys you: someone who captures one valid request can send it again forever.

Reject anything older than five minutes.

const ts = Number(request.headers.get('x-slack-request-timestamp'))
if (Math.abs(Date.now() / 1000 - ts) > 60 * 5) return new Response('stale', { status: 401 })
Enter fullscreen mode Exit fullscreen mode

Testing without six developer accounts

Verification code is exactly the kind of code that is never exercised until it fails in production, because the happy path also passes when your check is wrong. A check that returns true unconditionally ships very comfortably.

You do not need real accounts to test it. Every platform publishes an example payload and, in several cases, an example signature. Stand your adapters up on a real HTTP server and drive them with genuinely signed requests you construct yourself:

// sign a fixture the way Slack would, then send it
const base = `v0:${ts}:${rawBody}`
const sig = 'v0=' + hmacSha256Hex(signingSecret, base)

const res = await fetch(url, {
  method: 'POST',
  headers: {
    'x-slack-request-timestamp': String(ts),
    'x-slack-signature': sig,
  },
  body: rawBody,
})
Enter fullscreen mode Exit fullscreen mode

Check each adapter three ways, not one:

  1. Accepted when correctly signed.
  2. Answered with whatever that platform expects back, which is often not a bare 200.
  3. Refused when you tamper with a byte.

That third one is the test that matters and the one people skip. A verifier that accepts everything passes tests one and two perfectly.

Mine runs 26 such checks across seven adapters, plus Slack's replay window and Meta's subscribe handshake. Twilio's published example signature is reproduced byte for byte offline, which means their scheme is proven without an account existing anywhere.

Handshakes, which are a separate thing

Meta will not deliver anything until you answer a verification GET:

GET /webhook?hub.mode=subscribe&hub.verify_token=...&hub.challenge=1133679718
Enter fullscreen mode Exit fullscreen mode

You echo hub.challenge back as plain text, not JSON, and only if the token matches. Returning JSON here fails with no useful message.

What "tested" is worth saying out loud

There is a real difference between an adapter that has been run against a live account and one written faithfully against published documentation and tested on recorded shapes. Both work. Only one has met the platform's actual behaviour, including the parts the documentation does not mention.

I keep a file recording which is which, with dates, rather than blurring them into "supported". Nine of mine have been through real traffic on real accounts. The rest follow the published API and are driven by signed fixtures. Someone integrating deserves to know which they are getting, and the distinction costs nothing to record while it is still true.

The signature verification for all of these is MIT and readable at github.com/ibrahimhajjaj/recourse if you would rather copy a working one than write a sixth.

Top comments (0)