Verifying Webhooks
Authenticate a signed webhook from any provider with HMAC in CEL.
A webhook endpoint is a public URL that acts on whatever it is sent. The signature the provider puts in a header is the only thing separating a real delivery from a forged one, so verifying it is the endpoint's authentication.
Octo has dedicated blocks for Slack and
Notion. This guide covers everyone else: GitHub,
Stripe, Shopify, Twilio, and anything with no block of its own. It uses four CEL
functions and no provider-specific code, so a scheme nobody has implemented yet
is a validate rule away. The worked example is
samples/github-webhook.yaml.
An unguessable URL is not authentication. A secret in a path lands in proxy logs, in browser history, and in every redirect it passes through, and it cannot be rotated without reconfiguring the sender.
What every scheme has in common
Providers differ in only three ways: which bytes are signed, how the digest is rendered, and what prefixes it. All three are things an expression can say, which is why this needs no block:
| Provider | Signed payload | Header value |
|---|---|---|
| GitHub | the raw body | sha256= + hex |
| Slack | v0:<timestamp>:<body> | v0= + hex |
| Stripe | <timestamp>.<body> | hex, inside a compound t=…,v1=… header |
| Shopify | the raw body | base64 |
Capture the exact bytes
A signature covers the bytes the provider sent, so the flow has to keep them:
source:
connector: webhook
type: http
settings:
path: /github/${GITHUB_WEBHOOK_PATH}
# The exact request bytes, byte for byte.
rawBodyVar: rawBody
# A header only reaches vars if the route copies it.
headers:
- X-Hub-Signature-256
- X-GitHub-EventWithout rawBodyVar, the parsed body is all that survives, and re-serializing it
reorders keys and drops whitespace, so the HMAC would never match no matter how
correct it was.
Verify before anything else runs
- type: validate
name: verify-signature
rejectStatus: 401
rules:
- expr: >
"X-Hub-Signature-256" in vars &&
secureCompare(
vars["X-Hub-Signature-256"],
"sha256=" + hexEncode(hmacSha256(env.GITHUB_WEBHOOK_SECRET, vars.rawBody)))
message: invalid webhook signatureThree details in that rule are load-bearing.
Use secureCompare, never ==.
CEL's == on strings stops at the first differing byte, and how long it took to
stop is observable, so over enough requests an attacker learns the expected
signature one byte at a time.
The prefix is compared rather than stripped. Building the expected value as
"sha256=" + … means a request that omits the prefix fails; stripping it off the
incoming value instead would accept a bare digest.
The in guard turns a missing header into a rejection. Reading an absent variable
is an evaluation error, which fails the flow and answers 500, the one status a
webhook sender will retry for hours. Guarded, it is a plain false, which is the
401 you wanted.
has() will not do the guarding: it takes a field selection, and a header name
with dashes can only be reached as vars["X-Hub-Signature-256"].
Authentication is not authorization
A verified signature says the message came from the provider, not that it is one you should act on:
- type: validate
name: known-repository
rejectStatus: 403
rules:
- expr: 'has(body.repository.full_name) && body.repository.full_name == "juancavallotti/octo"'
message: webhook repository is not juancavallotti/octoKeep them as separate blocks with separate statuses: 401 for a sender you
cannot verify, 403 for a verified sender whose request you will not act on.
Timestamps and replay
Slack and Stripe sign a timestamp along with the body, so a captured request can
be bounded rather than replayed forever. now is in scope, so the check is an
ordinary rule alongside the signature:
- expr: >
"X-Slack-Request-Timestamp" in vars &&
now - timestamp(int(vars["X-Slack-Request-Timestamp"])) < duration("5m") &&
timestamp(int(vars["X-Slack-Request-Timestamp"])) - now < duration("5m")
message: request timestamp is outside the accepted windowBound it in both directions. A one-sided now - ts < 5m passes for any
future timestamp, so a captured request re-sent with a far-future timestamp would
sail through.
Other providers
Slack, with the timestamped payload and a v0= prefix:
- expr: >
"X-Slack-Signature" in vars && "X-Slack-Request-Timestamp" in vars &&
secureCompare(
vars["X-Slack-Signature"],
"v0=" + hexEncode(hmacSha256(
env.SLACK_SIGNING_SECRET,
"v0:" + vars["X-Slack-Request-Timestamp"] + ":" + vars.rawBody)))Shopify, which renders the digest as base64 from the
encoder library. hmacSha256 returns bytes and
rendering is a separate step, so the same call serves both schemes:
- expr: >
"X-Shopify-Hmac-Sha256" in vars &&
secureCompare(
vars["X-Shopify-Hmac-Sha256"],
base64.encode(hmacSha256(env.SHOPIFY_SECRET, vars.rawBody)))Stripe's header is compound (t=1234,v1=abc…), so pull the v1 element out
with the strings library before comparing.
Test it
GitHub's scheme signs the body alone, with no timestamp, so a signature committed
next to its payload never goes stale and a test suite can exercise the accept
path. samples/github-webhook_test.yaml does that, with vectors produced by:
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"The cut matters: openssl dgst -r prints the digest followed by *stdin, and
a signature carrying that suffix never verifies.
Then run it end to end. Send it as JSON: with a bare -d, curl sets
application/x-www-form-urlencoded and the flow has no body.repository to
read.
curl -i localhost:8080/github/hook \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"