b64: false). This page shows how to verify a signature in Go, Node.js, and Python, plus a no-dependency manual fallback.
Header
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:- Strip the
whsign_prefix. - base64url-decode the remainder (RFC 4648 §5, unpadded) → 32 raw bytes.
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
TheWebhook-Signature header value, JSON-parsed:
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’sprotected 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. Thekidis an opaque identifier — do not assume a format or prefix; treat it as a string.iat: issued-at, Unix seconds. Reject deliveries whoseiatis 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: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
Passalgorithms=['HS256'] (or your library’s equivalent) to the verify call explicitly. This rejects two attacks:
alg: noneforgery. RFC 7518 §3.1 defines anonealgorithm with an empty signature. A verifier that does not pinalgwill accept a forged envelope whose protected header setsalg: noneand whose signature segment is empty. Pinning rejects this outright.- Algorithm confusion at a future asymmetric rollout. Bastion may eventually add asymmetric algorithms (
ES256,EdDSA). We will announce any algorithm change before flippingalg. 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 unexpectedalg), 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 thesignatures 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)
Thebodypassed 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 Expressverifycallback onexpress.json, orreq.text()before parsing).
Python verifier (Authlib)
Authlib’s handling of detached (b64: false) JSON-serialized JWS varies across versions. Ifdeserialize_jsonraises 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 withb64: false natively, verify by recomputing the HMAC directly. This is the canonical recipe — every library snippet above does the same thing under the hood.
== 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
algtoHS256. - Reject deliveries whose
iatis more than ±5 minutes from your clock. - Compare signatures in constant time.
- Accept if any entry in the
signaturesarray 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.