Octov0.11.7
Guides

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:

ProviderSigned payloadHeader value
GitHubthe raw bodysha256= + hex
Slackv0:<timestamp>:<body>v0= + hex
Stripe<timestamp>.<body>hex, inside a compound t=…,v1=… header
Shopifythe raw bodybase64

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-Event

Without 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 signature

Three 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/octo

Keep 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 window

Bound 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"

On this page