Verifying Webhook Signatures

Webhook JWT Verification

Verify webhook authenticity and integrity using HMAC-signed JWTs.

At a glance

ItemValue
AlgorithmHS256 (HMAC-SHA256)
HeaderAuthorization: Bearer <jwt>
Default TTL5 minutes
Signed scopeRaw HTTP body only (route, query string, and other headers are not part of the signature)
Issuerspidr-webhook-deliverer (constant across all environments)

Contents

Header

Each webhook request includes a single auth header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...

The token is a standard JWT. See JWT Format for the claims it carries.

JWT Format

PropertyValue
AlgorithmHS256 (HMAC-SHA256)
TypeJWT
Default TTL5 minutes (exp - iat)

Claims

ClaimTypeDescription
substringWebhook delivery UUID (also identifies the request for support / idempotency)
payload_hashstringHex-encoded SHA-256 digest of the raw HTTP request body
issstringspidr-webhook-deliverer (constant across all environments)
iatnumberIssued-at, Unix seconds
expnumberExpiration, Unix seconds

Example decoded payload:

{
  "sub": "84f4cf12-3a8c-4b77-9a8f-b2f7e3d9e1aa",
  "payload_hash": "5f3d2b1c9e7a4f8b...",
  "iss": "spidr-webhook-deliverer",
  "iat": 1761840000,
  "exp": 1761840300
}

Sequence

Verification Steps

  1. Read the raw HTTP body before any JSON parsing. The payload_hash is computed over the exact bytes Spidr sent. If your framework auto-parses JSON, capture the raw buffer first.
  2. Extract the token from the Authorization header (strip the Bearer prefix).
  3. Verify the JWT signature using your shared secret, restricting the algorithm to HS256. This step also enforces exp (rejects expired tokens) and iat.
  4. Verify iss equals spidr-webhook-deliverer.
  5. Compute SHA-256 of the raw body and compare hex digest to the payload_hash claim using a timing-safe comparison.
  6. Only after all checks pass, parse and process the body.

Scope: The signature binds the JWT to the raw HTTP body only. Route, query string, and headers other than Authorization are not part of the signature.

Code Examples

Node.js (jsonwebtoken)

const crypto = require('crypto');
const jwt = require('jsonwebtoken');

function verifySpidrWebhook(rawBody, authHeader, secret) {
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('Missing or malformed Authorization header');
  }
  const token = authHeader.slice('Bearer '.length);

  // Throws on bad signature, expired token, or wrong issuer.
  // clockTolerance gives 30s of slack for NTP drift on either side.
  const claims = jwt.verify(token, secret, {
    algorithms: ['HS256'],
    issuer: 'spidr-webhook-deliverer',
    clockTolerance: 30,
  });

  // Bind the body to the token
  const expectedHash = crypto.createHash('sha256').update(rawBody).digest('hex');
  const a = Buffer.from(expectedHash, 'hex');
  const b = Buffer.from(claims.payload_hash, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('payload_hash mismatch');
  }

  return claims; // { sub, payload_hash, iss, iat, exp }
}

Express middleware

const express = require('express');
const app = express();

// IMPORTANT: capture the raw body — do not let express.json() parse first.
// Spidr always sends Content-Type: application/json. We use type: '*/*' here
// to be defensive against upstream proxies that rewrite the Content-Type.
app.post(
  '/webhook',
  express.raw({ type: '*/*' }),
  (req, res) => {
    try {
      const rawBody = req.body.toString('utf8');
      verifySpidrWebhook(rawBody, req.headers.authorization, process.env.SPIDR_WEBHOOK_SECRET);
      const payload = JSON.parse(rawBody);
      // process payload...
      res.status(200).send('OK');
    } catch (err) {
      res.status(401).send(err.message);
    }
  },
);

Python (PyJWT)

import hashlib
import hmac
import jwt  # PyJWT

ISSUER = "spidr-webhook-deliverer"

def verify_spidr_webhook(raw_body: bytes, auth_header: str, secret: str) -> dict:
    if not auth_header or not auth_header.startswith("Bearer "):
        raise ValueError("Missing or malformed Authorization header")
    token = auth_header[len("Bearer "):]

    # Validates signature, exp, and iss. leeway=30 gives slack for NTP drift.
    claims = jwt.decode(
        token,
        secret,
        algorithms=["HS256"],
        issuer=ISSUER,
        leeway=30,
    )

    expected_hash = hashlib.sha256(raw_body).hexdigest()
    if not hmac.compare_digest(expected_hash, claims["payload_hash"]):
        raise ValueError("payload_hash mismatch")

    return claims

Flask

from flask import Flask, request, abort
import os

app = Flask(__name__)

@app.route("/webhook", methods=["POST"])
def webhook():
    raw_body = request.get_data()  # bytes, before any JSON parsing
    try:
        verify_spidr_webhook(raw_body, request.headers.get("Authorization"), os.environ["SPIDR_WEBHOOK_SECRET"])
    except Exception as e:
        abort(401, str(e))

    payload = request.get_json()
    # process payload...
    return "OK", 200

Go (github.com/golang-jwt/jwt/v5)

package main

import (
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "errors"
    "fmt"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

const issuer = "spidr-webhook-deliverer"

type spidrClaims struct {
    PayloadHash string `json:"payload_hash"`
    jwt.RegisteredClaims
}

func verifySpidrWebhook(rawBody []byte, authHeader, secret string) (*spidrClaims, error) {
    if !strings.HasPrefix(authHeader, "Bearer ") {
        return nil, errors.New("missing or malformed Authorization header")
    }
    tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

    parser := jwt.NewParser(
        jwt.WithValidMethods([]string{"HS256"}),
        jwt.WithIssuer(issuer),
        jwt.WithExpirationRequired(),
        jwt.WithLeeway(30*time.Second),
    )

    claims := &spidrClaims{}
    _, err := parser.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
        return []byte(secret), nil
    })
    if err != nil {
        return nil, fmt.Errorf("jwt verify failed: %w", err)
    }

    sum := sha256.Sum256(rawBody)
    expected := hex.EncodeToString(sum[:])
    if subtle.ConstantTimeCompare([]byte(expected), []byte(claims.PayloadHash)) != 1 {
        return nil, errors.New("payload_hash mismatch")
    }

    return claims, nil
}

Ruby (ruby-jwt)

Requires Ruby 3.2+ for OpenSSL.fixed_length_secure_compare. For older Ruby, add the activesupport gem and use ActiveSupport::SecurityUtils.secure_compare instead.

require 'jwt'
require 'digest'
require 'openssl'

ISSUER = 'spidr-webhook-deliverer'

def verify_spidr_webhook(raw_body, auth_header, secret)
  raise 'Missing or malformed Authorization header' unless auth_header&.start_with?('Bearer ')
  token = auth_header.sub(/\ABearer /, '')

  payload, _header = JWT.decode(
    token,
    secret,
    true,
    algorithm: 'HS256',
    iss: ISSUER,
    verify_iss: true,
    verify_expiration: true,
    leeway: 30,
  )

  expected = Digest::SHA256.hexdigest(raw_body)
  raise 'payload_hash mismatch' unless OpenSSL.fixed_length_secure_compare(expected, payload['payload_hash'])

  payload
end

Security Best Practices

  1. Pin the algorithm to HS256. Always pass an explicit algorithms: ['HS256'] allow-list to your JWT library. Libraries that accept the algorithm from the token header are vulnerable to algorithm-confusion and alg: none attacks.
  2. Verify iss. Reject tokens whose issuer claim does not match spidr-webhook-deliverer.
  3. Use the raw request body for the payload_hash check. Don't parse and re-stringify, whitespace differences will break the hash.
  4. Use a timing-safe comparison for the payload_hash check.
  5. Trust the JWT library's exp enforcement. The default 5-minute TTL is your replay-protection window, make sure your server clock is in sync via NTP.
  6. Store the shared secret in a secrets manager, not in source control.
  7. Treat the webhook UUID (sub) as the idempotency key. Retries can occur — make sure your handler is idempotent.
  8. Log verification failures for security monitoring, but do not log the token or secret.

Troubleshooting

SymptomLikely Cause
invalid signatureWrong secret for the environment, or your library is HMAC'ing with a different algorithm
jwt expiredServer clock drift > 5 minutes, or you're processing a delayed retry — check iat/exp
payload_hash mismatchBody was parsed/re-stringified before hashing. You must hash the raw bytes from the wire
invalid issuerWrong issuer string, or you're verifying production traffic with a non-prod expectation
missing Authorization headerA reverse proxy (ALB, CloudFront, nginx) is stripping the header — allow-list Authorization

Testing

  1. Capture a real webhook to a tunnel (e.g. ngrok) and save both the raw body bytes and the Authorization header.
  2. Decode the JWT at jwt.io. Only paste non-production secrets into the website. For production secret verification, use a local CLI tool or library.
  3. Independently compute sha256(rawBody) in your shell (shasum -a 256 body.bin) and compare to the payload_hash claim.
  4. Replay the request through your handler and confirm it returns 200.

What Spidr provides you

To complete your integration, Spidr will share:

  • Shared signing secret — a 64-character hex string (representing 32 random bytes), delivered through an encrypted secret-sharing link from your Spidr contact. Pass the string directly to your JWT library — jsonwebtoken, PyJWT, golang-jwt, and ruby-jwt all accept it as-is, no decoding required.
  • Issuer stringspidr-webhook-deliverer, constant across all environments.

Secret rotation

Today, Spidr does not run a dual-secret window — the deliverer reads exactly one secret per company. During rotation:

  1. Spidr provisions the new secret on our side.
  2. You redeploy your verifier with the new secret.
  3. Between steps 1 and 2, deliveries signed with the new secret will fail your verifier (which still has the old secret).
  4. Failed deliveries retry automatically via our queue, so no events are dropped, but customers may see a brief delay.

We coordinate the cutover timing with you. If you need a lossless rotation flow (dual-secret window), let your Spidr contact know and we can prioritize it.