Skip to main content
Bastion signs outbound webhooks with HMAC-SHA256 under a per-endpoint secret you generate from the dashboard. The wire format is a JWS JSON General Serialization envelope (RFC 7515 §7.2.1) with a detached payload (RFC 7797, b64: false). This page shows how to verify a signature in Go, Node.js, and Python, plus a no-dependency manual fallback. Signed webhooks carry one new header: The HTTP body remains the original JSON event — verification does not consume or transform it. Parse and act on the body exactly as you would have without signing.

Your signing secret

When you generate or rotate a signing key, the dashboard shows the secret once, in this form:
This string is not the HMAC key directly. To get the key bytes:
  1. Strip the whsign_ prefix.
  2. base64url-decode the remainder (RFC 4648 §5, unpadded) → 32 raw bytes.
HMAC verification uses those 32 decoded bytes, never the whsign_… string. Every snippet below shows the decode step; if you skip it, signatures will never match. Store secrets keyed by their key id (kid, see below) so you can verify during a rotation overlap when two keys are briefly live.

Envelope shape

The Webhook-Signature header value, JSON-parsed:
During a rotation overlap, the signatures array carries two entries — current first, previous second. Accept the delivery if any entry verifies under a key you trust.

Protected header (per signature)

Each entry’s protected field, base64url-decoded, is JSON of this shape:
  • alg: always "HS256" today. You MUST pin this — see Algorithm pinning.
  • typ: always "webhook+jws".
  • kid: identifies which signing key produced this signature. Use it to look up the right secret in your (kid → secret) map. The kid is an opaque identifier — do not assume a format or prefix; treat it as a string.
  • iat: issued-at, Unix seconds. Reject deliveries whose iat is more than 5 minutes off your local clock. This is your replay window.
  • jti: message id (msg_<…>). It changes on retries — do not use it as your idempotency key. Use a payload-level id from the event body instead.
  • b64: false + crit: ["b64"]: detached, unencoded payload (RFC 7797). The signing input is over the raw body bytes, not a base64-encoded copy.

Signing input

For each signature entry, the bytes that were HMAC-SHA256’d are:
That is: the entry’s protected field exactly as it appears (still base64url-encoded), a literal ., then the exact bytes of the HTTP request body. The HMAC output is base64url-encoded, unpadded, and compared against the entry’s signature field.

Algorithm pinning

Pass algorithms=['HS256'] (or your library’s equivalent) to the verify call explicitly. This rejects two attacks:
  1. alg: none forgery. RFC 7518 §3.1 defines a none algorithm with an empty signature. A verifier that does not pin alg will accept a forged envelope whose protected header sets alg: none and whose signature segment is empty. Pinning rejects this outright.
  2. Algorithm confusion at a future asymmetric rollout. Bastion may eventually add asymmetric algorithms (ES256, EdDSA). We will announce any algorithm change before flipping alg. Until then your allow-list is ['HS256'] only.

Receiver behavior on signature failure

Return HTTP 4xx (400 or 401) when verification fails — never 200. Returning 200 after silently discarding a webhook:
  • Suppresses our retry signal. The dispatcher only retries on a non-2xx response. A 200 tells us the delivery succeeded, so we drop the event.
  • Hides receiver misconfiguration. If your verifier silently rejects valid signatures (a stale key after rotation, clock skew on iat, an unexpected alg), you lose webhooks without ever noticing.

Rotation

When you click Rotate in the dashboard, you receive a new secret immediately. For an overlap window (currently 24h) we sign each delivery with both keys — the new and the old appear as two entries in the signatures array, each with its own kid. Hold both (kid → secret) mappings during the overlap. Drop the old one once you have confirmed deliveries verifying under the new key and the overlap window has passed.

Go verifier (go-jose v4)

Node.js (TypeScript) verifier (panva jose)

The body passed here must be the raw request body string, byte-identical to what was delivered. If your framework parses JSON before you see it, capture the raw body first (e.g. an Express verify callback on express.json, or req.text() before parsing).

Python verifier (Authlib)

Authlib’s handling of detached (b64: false) JSON-serialized JWS varies across versions. If deserialize_json raises on a signature you believe is valid, use the dependency-free manual verifier below — it implements the exact signing input and works on any Python.

Manual fallback (no JOSE library)

If your language’s JOSE library does not handle JWS JSON General Serialization with b64: false natively, verify by recomputing the HMAC directly. This is the canonical recipe — every library snippet above does the same thing under the hood.
Use your language’s constant-time comparison primitive — never == on the raw signature bytes: A Node.js sketch of the same recipe, using only the built-in crypto module:

Checklist

  • Read the raw request body bytes before any JSON parsing — verification is over the exact bytes delivered.
  • Decode the whsign_… secret to raw key bytes; HMAC with those bytes.
  • Pin alg to HS256.
  • Reject deliveries whose iat is more than ±5 minutes from your clock.
  • Compare signatures in constant time.
  • Accept if any entry in the signatures array verifies (rotation overlap).
  • Return 4xx on failure, never 200.
  • Use a payload-level id (from the event body) for idempotency — not jti, which changes on retries.