> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bastion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying Bastion webhook signatures

> Verify Bastion webhook signatures: decode the whsign_ secret, parse the JWS JSON envelope, and validate the detached HMAC-SHA256 in Go, Node.js, or Python.

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](https://www.rfc-editor.org/rfc/rfc7515#section-7.2.1)) with a **detached payload** ([RFC 7797](https://www.rfc-editor.org/rfc/rfc7797), `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:

| Header              | Value                                                                                              |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `Webhook-Signature` | JSON-encoded JWS JSON General Serialization envelope. See [Envelope shape](#envelope-shape) below. |

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:

```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
whsign_3q2-7_8xQvWmRYxLfBgA0123456789abcdefABCDEF01
```

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:

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "signatures": [
      { "protected": "<base64url-encoded protected header>", "signature": "<base64url-encoded HMAC-SHA256>" }
    ]
  }
  ```
</CodeGroup>

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:

<CodeGroup>
  ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  {
    "alg": "HS256",
    "typ": "webhook+jws",
    "kid": "2cFp7yShQ8vN3kRtZ1wL9bGmJxY",
    "iat": 1714501234,
    "jti": "msg_2cFp9aBdE4gH6jK8mN0pQ2rS",
    "b64": false,
    "crit": ["b64"]
  }
  ```
</CodeGroup>

* `alg`: always `"HS256"` today. **You MUST pin this** — see [Algorithm pinning](#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:

```text theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
base64url(protected) + "." + raw_request_body_bytes
```

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](https://www.rfc-editor.org/rfc/rfc7518#section-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)

<CodeGroup>
  ```go theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  import (
      "encoding/base64"
      "encoding/json"
      "errors"
      "io"
      "net/http"
      "strings"
      "time"

      "github.com/go-jose/go-jose/v4"
  )

  const tolerance = 5 * time.Minute

  // DecodeSecret turns the dashboard secret ("whsign_<base64url>") into the raw
  // HMAC key bytes. Store the result keyed by kid.
  func DecodeSecret(s string) ([]byte, error) {
      return base64.RawURLEncoding.DecodeString(strings.TrimPrefix(s, "whsign_"))
  }

  // keysByKid maps each kid to its DECODED secret bytes (see DecodeSecret).
  func Verify(r *http.Request, keysByKid map[string][]byte) ([]byte, error) {
      headerValue := r.Header.Get("Webhook-Signature")
      if headerValue == "" {
          return nil, errors.New("missing Webhook-Signature header")
      }
      body, err := io.ReadAll(r.Body)
      if err != nil {
          return nil, err
      }
      // Pin alg=HS256 to reject alg:none and future-algorithm confusion.
      sig, err := jose.ParseSigned(headerValue, []jose.SignatureAlgorithm{jose.HS256})
      if err != nil {
          return nil, err
      }
      for _, entry := range sig.Signatures {
          // Enforce the iat freshness window before trusting the signature.
          var hdr struct {
              Iat int64  `json:"iat"`
              Kid string `json:"kid"`
          }
          if err := json.Unmarshal(entry.Protected, &hdr); err != nil {
              continue
          }
          if delta := time.Since(time.Unix(hdr.Iat, 0)); delta > tolerance || delta < -tolerance {
              continue
          }
          secret, ok := keysByKid[hdr.Kid]
          if !ok {
              continue
          }
          // DetachedVerify recomputes the HMAC over base64url(protected) + "." + body.
          if _, err := sig.DetachedVerify(body, secret); err == nil {
              return body, nil
          }
      }
      return nil, errors.New("no valid signature")
  }
  ```
</CodeGroup>

## Node.js (TypeScript) verifier (panva `jose`)

<CodeGroup>
  ```tsx theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  import { generalVerify } from 'jose';

  const TOLERANCE_MS = 5 * 60 * 1000;

  // Turn the dashboard secret ("whsign_<base64url>") into the raw HMAC key bytes.
  function decodeSecret(s: string): Uint8Array {
    return new Uint8Array(Buffer.from(s.replace(/^whsign_/, ''), 'base64url'));
  }

  // keysByKid maps each kid to its DECODED secret bytes (see decodeSecret).
  export async function verify(
    headers: Record<string, string>,
    body: string,
    keysByKid: Record<string, Uint8Array>,
  ): Promise<boolean> {
    const headerValue = headers['webhook-signature'];
    if (!headerValue) return false;
    let env;
    try {
      env = JSON.parse(headerValue);
    } catch {
      return false;
    }
    if (!Array.isArray(env.signatures)) return false;

    for (const entry of env.signatures) {
      let hdr;
      try {
        hdr = JSON.parse(Buffer.from(entry.protected, 'base64url').toString('utf8'));
      } catch {
        continue;
      }
      // iat freshness — check before calling the library verify.
      if (Math.abs(Date.now() - hdr.iat * 1000) > TOLERANCE_MS) continue;
      const secret = keysByKid[hdr.kid];
      if (!secret) continue;
      try {
        await generalVerify({ ...env, payload: new TextEncoder().encode(body), signatures: [entry] }, secret, {
          algorithms: ['HS256'],
        });
        return true;
      } catch {
        // Try the next entry (e.g. during a rotation overlap).
      }
    }
    return false;
  }
  ```
</CodeGroup>

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

<CodeGroup>
  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  import base64
  import json
  import time

  from authlib.jose import JsonWebSignature

  TOLERANCE = 5 * 60  # seconds

  def decode_secret(s: str) -> bytes:
      """Dashboard secret ('whsign_<base64url>') → raw HMAC key bytes."""
      raw = s.removeprefix("whsign_")
      return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))

  def verify(headers, body, keys_by_kid):
      """keys_by_kid maps kid -> DECODED secret bytes (see decode_secret)."""
      header_value = headers.get("Webhook-Signature")
      if not header_value:
          return False
      try:
          env = json.loads(header_value)
      except (ValueError, TypeError):
          return False
      if not isinstance(env.get("signatures"), list):
          return False
      jws = JsonWebSignature(algorithms=["HS256"])  # algorithm pinning
      for entry in env["signatures"]:
          try:
              protected = json.loads(
                  base64.urlsafe_b64decode(entry["protected"] + "=" * (-len(entry["protected"]) % 4))
              )
          except Exception:
              continue
          if abs(time.time() - protected["iat"]) > TOLERANCE:
              continue
          secret = keys_by_kid.get(protected["kid"])
          if not secret:
              continue
          try:
              jws.deserialize_json(
                  {"payload": "", "signatures": [entry]}, secret, payload=body
              )
              return True
          except Exception:
              continue
      return False
  ```
</CodeGroup>

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

<CodeGroup>
  ```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  import base64
  import hmac
  import json
  import time
  from hashlib import sha256

  TOLERANCE = 5 * 60  # seconds

  def b64url_decode(s: str) -> bytes:
      return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

  def decode_secret(s: str) -> bytes:
      return b64url_decode(s.removeprefix("whsign_"))

  def verify(raw_body: bytes, header_value: str, secrets_by_kid: dict) -> bool:
      env = json.loads(header_value)
      for entry in env["signatures"]:
          protected = json.loads(b64url_decode(entry["protected"]))

          if protected["alg"] != "HS256":                 # algorithm pinning
              continue
          if abs(time.time() - protected["iat"]) > TOLERANCE:  # iat freshness window
              continue
          secret = secrets_by_kid.get(protected["kid"])    # raw bytes via decode_secret
          if secret is None:
              continue

          # signing input = base64url(protected) + "." + raw body bytes
          signing_input = entry["protected"].encode("ascii") + b"." + raw_body
          expected = hmac.new(secret, signing_input, sha256).digest()
          got = b64url_decode(entry["signature"])

          if hmac.compare_digest(got, expected):           # constant-time compare
              return True
      return False  # caller responds 4xx, NOT 200
  ```
</CodeGroup>

Use your language's constant-time comparison primitive — never `==` on the raw signature bytes:

| Language | Primitive                |
| -------- | ------------------------ |
| Go       | `hmac.Equal`             |
| Node.js  | `crypto.timingSafeEqual` |
| Python   | `hmac.compare_digest`    |

A Node.js sketch of the same recipe, using only the built-in `crypto` module:

<CodeGroup>
  ```tsx theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
  import { createHmac, timingSafeEqual } from 'crypto';

  function decodeSecret(s: string): Buffer {
    return Buffer.from(s.replace(/^whsign_/, ''), 'base64url');
  }

  function verify(rawBody: Buffer, headerValue: string, secretsByKid: Record<string, Buffer>): boolean {
    const env = JSON.parse(headerValue);
    for (const entry of env.signatures) {
      const hdr = JSON.parse(Buffer.from(entry.protected, 'base64url').toString('utf8'));
      if (hdr.alg !== 'HS256') continue;                          // algorithm pinning
      if (Math.abs(Date.now() / 1000 - hdr.iat) > 300) continue;  // iat freshness window
      const secret = secretsByKid[hdr.kid];
      if (!secret) continue;

      const signingInput = Buffer.concat([Buffer.from(entry.protected, 'ascii'), Buffer.from('.'), rawBody]);
      const expected = createHmac('sha256', secret).update(signingInput).digest();
      const got = Buffer.from(entry.signature, 'base64url');

      if (got.length === expected.length && timingSafeEqual(got, expected)) return true;
    }
    return false; // caller responds 4xx, NOT 200
  }
  ```
</CodeGroup>

## 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.


## Related topics

- [Deposit notifications](/v2/api-reference/webhooks/notifications/deposit-notifications.md)
- [Rotate Webhook Signing Key](/v2/api-reference/webhooks/rotate-webhook-signing-key.md)
- [API authentication overview](/guides/security/api-authentication.md)
- [Security architecture overview](/guides/security/overview.md)
- [Request signing with JWTs](/v2/api-reference/authentication/request-signing.md)
