Verifying Webhook Signatures
Webhook JWT Verification
Verify webhook authenticity and integrity using HMAC-signed JWTs.
At a glance
| Item | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Header | Authorization: Bearer <jwt> |
| Default TTL | 5 minutes |
| Signed scope | Raw HTTP body only (route, query string, and other headers are not part of the signature) |
| Issuer | spidr-webhook-deliverer (constant across all environments) |
Contents
- Header
- JWT Format
- Sequence
- Verification Steps
- Code Examples
- Security Best Practices
- Troubleshooting
- Testing
- What Spidr provides you
- Need help?
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
| Property | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Type | JWT |
| Default TTL | 5 minutes (exp - iat) |
Claims
| Claim | Type | Description |
|---|---|---|
sub | string | Webhook delivery UUID (also identifies the request for support / idempotency) |
payload_hash | string | Hex-encoded SHA-256 digest of the raw HTTP request body |
iss | string | spidr-webhook-deliverer (constant across all environments) |
iat | number | Issued-at, Unix seconds |
exp | number | Expiration, 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
- Read the raw HTTP body before any JSON parsing. The
payload_hashis computed over the exact bytes Spidr sent. If your framework auto-parses JSON, capture the raw buffer first. - Extract the token from the
Authorizationheader (strip theBearerprefix). - Verify the JWT signature using your shared secret, restricting the algorithm to
HS256. This step also enforcesexp(rejects expired tokens) andiat. - Verify
issequalsspidr-webhook-deliverer. - Compute SHA-256 of the raw body and compare hex digest to the
payload_hashclaim using a timing-safe comparison. - 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 claimsFlask
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", 200Go (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
endSecurity Best Practices
- Pin the algorithm to
HS256. Always pass an explicitalgorithms: ['HS256']allow-list to your JWT library. Libraries that accept the algorithm from the token header are vulnerable to algorithm-confusion andalg: noneattacks. - Verify
iss. Reject tokens whose issuer claim does not matchspidr-webhook-deliverer. - Use the raw request body for the
payload_hashcheck. Don't parse and re-stringify, whitespace differences will break the hash. - Use a timing-safe comparison for the
payload_hashcheck. - Trust the JWT library's
expenforcement. The default 5-minute TTL is your replay-protection window, make sure your server clock is in sync via NTP. - Store the shared secret in a secrets manager, not in source control.
- Treat the webhook UUID (
sub) as the idempotency key. Retries can occur — make sure your handler is idempotent. - Log verification failures for security monitoring, but do not log the token or secret.
Troubleshooting
| Symptom | Likely Cause |
|---|---|
invalid signature | Wrong secret for the environment, or your library is HMAC'ing with a different algorithm |
jwt expired | Server clock drift > 5 minutes, or you're processing a delayed retry — check iat/exp |
payload_hash mismatch | Body was parsed/re-stringified before hashing. You must hash the raw bytes from the wire |
invalid issuer | Wrong issuer string, or you're verifying production traffic with a non-prod expectation |
missing Authorization header | A reverse proxy (ALB, CloudFront, nginx) is stripping the header — allow-list Authorization |
Testing
- Capture a real webhook to a tunnel (e.g. ngrok) and save both the raw body bytes and the
Authorizationheader. - 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.
- Independently compute
sha256(rawBody)in your shell (shasum -a 256 body.bin) and compare to thepayload_hashclaim. - 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 string —
spidr-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:
- Spidr provisions the new secret on our side.
- You redeploy your verifier with the new secret.
- Between steps 1 and 2, deliveries signed with the new secret will fail your verifier (which still has the old secret).
- 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.
Updated 2 days ago