Webhook Payload Signing Best Practices (2026): HMAC, Ephemeral Keys and Replay Protection

By the HookListener Security TeamWritten and maintained by engineers building webhook infrastructure.
Last updated: September 26, 2026Originally published September 25, 202515 min read

The best practice for webhook payload signing in 2026 is HMAC-SHA256 over the raw request body and a timestamp, verified with a constant-time comparison, a roughly 5-minute replay window, and signing keys that carry a key ID and rotate with a short overlap. IP allowlists and schema validation are useful extra layers, but the signature is the control you cannot skip.

Key takeaways

  • Sign the raw bytes, not parsed JSON. A webhook signature is an HMAC-SHA256 of timestamp.body; any JSON re-serialization before verification breaks it.
  • Compare in constant time. Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python), never ==.
  • Reject stale timestamps. A 300-second tolerance is the common default (Stripe's SDKs use it); add a delivery-ID cache to block replays inside that window.
  • Make keys ephemeral and identifiable. Send a key ID with each signature, rotate on a schedule, and sign with old and new keys during a short overlap so retries keep verifying.
  • IP allowlists are a second layer, not authentication. Stripe and GitHub publish webhook source ranges; Shopify does not, so HMAC must stand on its own.
  • Test with real traffic before deploying. Capture a real webhook on a Hooklistener endpoint, check its signature against your stored secret, and replay it to localhost re-signed with a fresh timestamp.

What are the best practices for webhook payload signing?

Sign every delivery with HMAC-SHA256 over the timestamp and raw body, identify the key that signed it, keep keys short-lived, and verify all of it before your handler touches the payload. In order, a receiver should:

  • Read the raw request body and the signature, timestamp and key ID (kid) headers.
  • Look up the key by kid in a small set of active keys (current plus recently rotated) and reject unknown or expired IDs.
  • Recompute the HMAC and compare it in constant time.
  • Reject timestamps outside the tolerance window, then record the delivery ID so the same request cannot be replayed inside it.

After implementing these checks, review the dedicated webhook signing and HMAC verification checklist, test signatures in our webhook debugger, compare authentication strategies, and review advanced rotation patterns.

What changed in webhook signing between 2024 and 2026?

The core scheme did not change: HMAC-SHA256 over a timestamp and the raw body is still what Stripe, GitHub, Slack and the Standard Webhooks spec use. What changed is how keys are managed around it.

Zero-downtime rotation is now expected. Standard Webhooks puts a space-delimited list of signatures in one webhook-signature header so senders can sign with the old and new secret at once, and signs id.timestamp.body so the delivery ID is covered too. Stripe's “roll secret” keeps the old secret alive for up to 24 hours and sends one v1 signature per active secret during the overlap (Stripe docs). Short-lived, per-tenant keys with a key ID are the natural next step for teams that sign their own webhooks.

Asymmetric signatures are an option. Standard Webhooks defines a v1a Ed25519 scheme, so receivers only hold a public key. It removes the shared secret but adds key distribution work, and Ed25519 is not quantum-resistant.

Post-quantum planning does not affect HMAC. NIST's draft transition plan (IR 8547) deprecates quantum-vulnerable public-key algorithms such as RSA-2048 and P-256 after 2030 and disallows them after 2035. HMAC-SHA256 is a symmetric construction and is not on that schedule. The migration work lands in your TLS termination (hybrid key exchange) and in any asymmetric signing scheme, not in your HMAC verifier.

To secure a webhook endpoint, verify every incoming request using HMAC signature verification, enforce HTTPS, validate timestamps to prevent replay attacks, and implement rate limiting. The most important step is signature verification: the sender signs the payload with a shared secret, and your server recomputes the signature to confirm the request is authentic and untampered.

This guide covers the full security stack: common threats (spoofing, replay, tampering), authentication methods compared, HMAC implementation with code examples, transport security, and monitoring for incidents.

Why Webhook Security Matters

Webhooks are exposed HTTP endpoints that can be targeted by attackers. Without proper security measures, malicious actors can:

  • Send fake webhook events to your application
  • Intercept and tamper with webhook payloads
  • Launch replay attacks using captured webhook data
  • Overwhelm your endpoints with malicious traffic
  • Exploit webhook vulnerabilities to access sensitive systems

Common Webhook Security Threats

Spoofing Attacks

Attackers send fake webhooks pretending to be legitimate services.

Impact: Unauthorized actions, data corruption, financial fraud

Replay Attacks

Captured webhook payloads are replayed multiple times.

Impact: Duplicate processing, double charges, data inconsistency

Payload Tampering

Webhook data is modified during transmission.

Impact: Data integrity loss, incorrect processing, security bypasses

Endpoint Discovery

Attackers probe for and discover webhook endpoints.

Impact: Unauthorized access, information disclosure, attack surface expansion

Multi-Layered Security Strategy

1. Setup Phase Security

Secure webhook configuration and initial setup:

  • Endpoint Verification: Implement challenge-response verification during setup
  • Authentication Setup: Configure strong authentication credentials
  • Access Controls: Restrict who can configure webhook endpoints
  • URL Validation: Verify webhook URLs point to legitimate destinations

2. Runtime Security Controls

Active protection during webhook delivery:

// Multi-layer webhook validation
async function validateWebhook(request) { // 1. Verify HTTPS connection if (!request.secure) { throw new Error('Webhook must use HTTPS'); } // 2. Check authentication if (!validateAuthentication(request.headers)) { throw new Error('Authentication failed'); } // 3. Verify signature if (!verifySignature(request.body, request.headers)) { throw new Error('Invalid signature'); } // 4. Check timestamp (prevent replay) if (!isTimestampValid(request.headers)) { throw new Error('Request too old or timestamp invalid'); } // 5. Validate payload structure if (!validatePayloadStructure(request.body)) { throw new Error('Invalid payload format'); } return true; }

3. Compensatory Controls

Additional protective measures:

  • IP Whitelisting: Restrict access to known source IPs
  • Rate Limiting: Prevent abuse through request throttling
  • API Callbacks: Verify webhook authenticity through reverse API calls
  • Monitoring & Alerting: Track suspicious patterns and anomalies

Essential Authentication Methods

Basic Authentication

Simple username/password authentication:

Authorization: Basic {base64(username:password)}
Pros:
  • • Simple to implement
  • • Widely supported
  • • Low overhead
Cons:
  • • Credentials can be decoded
  • • No payload validation
  • • Vulnerable to replay attacks

Bearer Token Authentication

Token-based authentication:

Authorization: Bearer {access_token}
Pros:
  • • Protects credentials
  • • Token revocation possible
  • • OAuth 2.0 compatible
Cons:
  • • More complex setup
  • • Limited payload validation
  • • Still vulnerable to replay

HMAC Signature Verification (Recommended)

Cryptographic signature validation:

X-Webhook-Signature: sha256=a4d5f... X-Webhook-Timestamp: 1672531200
Pros:
  • • Validates payload integrity
  • • Prevents tampering
  • • Timestamp prevents replay
  • • Industry standard
Considerations:
  • • Requires implementation
  • • Secret key management
  • • Clock synchronization

How do you verify a webhook HMAC signature?

Rebuild the exact string the sender signed from the timestamp header and the raw body, compute HMAC-SHA256 with the shared secret, and compare it to the received signature in constant time after checking the timestamp. The two halves look like this.

How the sender creates the signature

// Webhook provider creates signature function createSignature(payload, secret, timestamp) { const signingString = `${timestamp}.${payload}`; const signature = crypto .createHmac('sha256', secret) .update(signingString, 'utf8') .digest('hex'); return `sha256=${signature}`; }

How the receiver verifies it

payload must be the raw body string exactly as received. Check lengths before timingSafeEqual, which throws on buffers of different length.

// Verify webhook signature function verifySignature(payload, signature, secret, timestamp) { // Check timestamp first (prevent replay attacks) const currentTime = Math.floor(Date.now() / 1000); const timestampDiff = Math.abs(currentTime - Number(timestamp)); if (!Number.isFinite(timestampDiff) || timestampDiff > 300) { // 5 minute tolerance return false; } // Recreate the signature over the raw body const signingString = `${timestamp}.${payload}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(signingString, 'utf8') .digest('hex'); const expected = Buffer.from(`sha256=${expectedSignature}`); const received = Buffer.from(signature ?? ''); // Constant-time comparison; timingSafeEqual throws on length mismatch if (received.length !== expected.length) return false; return crypto.timingSafeEqual(received, expected); }

What tolerance should the timestamp check use?

Use 300 seconds unless you have a reason not to. It is the default in Stripe's official libraries, it absorbs normal clock skew and queueing delay, and it keeps the replay window short. Keep servers NTP-synced, tighten the window for high-value actions (refunds, payouts), and never pass 0: most SDKs read that as “disable the check”. A timestamp alone does not stop a replay inside the window, so also store each delivery ID (for example webhook-id, X-GitHub-Delivery, or Stripe's event.id) for at least as long as the window.

What are ephemeral webhook signing keys, and how do you rotate them?

Ephemeral signing keys are short-lived HMAC keys, each with a key ID and an expiry, that replace one long-lived shared secret; you rotate them by publishing the next key, signing with both for a short overlap, and then retiring the old one. Static signing secrets — a single long-lived HMAC key shared between sender and receiver — remain the default for most providers, but they are a liability at scale. A leaked secret from a CI log, a compromised employee laptop, or a vulnerable dependency grants an attacker the ability to forge every future payload until the key is rotated. Replay windows widen the blast radius: captured traffic can be re-sent against the endpoint for as long as the clock skew tolerance allows.

An emerging hardening pattern — not yet standard among major providers, but increasingly recommended in security research — is ephemeral signing tokens: short-lived, per-delivery (or per-batch) tokens derived from a root secret and rotated aggressively. Each delivery carries a signature, a monotonic timestamp, and a single-use nonce, so even a captured request cannot be replayed or forged beyond its TTL.

  • Ephemeral tokens: issue per-delivery or per-batch signing material with a short exp claim. Use lifetimes from minutes to hours based on delivery volume, retry behavior, and risk.
  • Dual-secret rotation window: during rotation, accept both the current key and a recently rotated key for a small overlap window so in-flight retries do not fail. Emit a structured metric whenever the old key is used so you can confirm the cutover.
  • Nonce + timestamp: reject requests older than 300 seconds and, only after the signature checks out, store nonces in a short-TTL cache (Redis SET key 1 NX EX 300) to block replays inside the window.
  • KMS-backed root keys: keep the root secret in AWS KMS, GCP KMS, or HashiCorp Vault. Either compute MACs through the KMS API (AWS KMS supports HMAC keys) or derive short-lived per-key secrets from it and hold only those in memory.

Verifying against a rotating key set

The receiver keeps a tiny map of key ID to secret and expiry. Unknown or expired key IDs fail closed; the previous key stays in the map only for the overlap window.

// keys: loaded from your secret manager, refreshed on rotation
const keys = new Map([
  ["k_2026_09_26", { secret: process.env.WH_KEY_CURRENT, expiresAt: null }],
  ["k_2026_09_19", { secret: process.env.WH_KEY_PREVIOUS, expiresAt: 1790467200 }],
]);

function keyFor(kid: string): string | null {
  const key = keys.get(kid);
  if (!key) return null;                              // unknown kid: reject
  if (key.expiresAt && Date.now() / 1000 > key.expiresAt) return null; // retired
  return key.secret;
}

// const secret = keyFor(req.headers["x-webhook-key-id"]);
// if (!secret) return res.status(401).end();
// then run verify(rawBody, req.headers, secret, seenNonces) as below

Python: sign & verify with timestamp + nonce

Illustrative example. seen_nonces is sketched as a simple interface — in production, back it with Redis (SETEX nonce:<value> 300 1 to add, GET to check) or an equivalent short-TTL store.

import hmac
import hashlib
import secrets
import time

MAX_AGE_SECONDS = 300  # reject anything older than 5 minutes

def sign(payload: bytes, secret: bytes) -> dict:
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    signing_string = f"{timestamp}.{nonce}.".encode() + payload
    mac = hmac.new(secret, signing_string, hashlib.sha256).hexdigest()
    return {
        "X-Webhook-Timestamp": timestamp,
        "X-Webhook-Nonce": nonce,
        "X-Webhook-Signature": f"sha256={mac}",
    }

def verify(payload: bytes, headers: dict, secret: bytes, seen_nonces) -> bool:
    try:
        timestamp = int(headers["X-Webhook-Timestamp"])
        nonce = headers["X-Webhook-Nonce"]
        received = headers["X-Webhook-Signature"].split("=", 1)[1]
    except (KeyError, ValueError):
        return False

    # 1. Reject stale requests (replay window)
    if abs(time.time() - timestamp) > MAX_AGE_SECONDS:
        return False

    # 2. Constant-time signature comparison
    signing_string = f"{timestamp}.{nonce}.".encode() + payload
    expected = hmac.new(secret, signing_string, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        return False

    # 3. Reject replays inside the window. Record the nonce only after the
    #    signature is valid, so forged requests cannot poison the cache.
    if nonce in seen_nonces:
        return False
    seen_nonces.add(nonce, ttl=MAX_AGE_SECONDS)
    return True

Node.js: sign & verify with timing-safe comparison

import crypto from "node:crypto";

const MAX_AGE_SECONDS = 300;

export function sign(payload: Buffer, secret: string) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString("hex");
  const signingString = Buffer.concat([
    Buffer.from(`${timestamp}.${nonce}.`),
    payload,
  ]);
  const mac = crypto
    .createHmac("sha256", secret)
    .update(signingString)
    .digest("hex");
  return {
    "x-webhook-timestamp": timestamp,
    "x-webhook-nonce": nonce,
    "x-webhook-signature": `sha256=${mac}`,
  };
}

export function verify(
  payload: Buffer,
  headers: Record<string, string>,
  secret: string,
  seenNonces: { has(n: string): boolean; add(n: string, ttl: number): void },
): boolean {
  const timestamp = Number(headers["x-webhook-timestamp"]);
  const nonce = headers["x-webhook-nonce"];
  const received = (headers["x-webhook-signature"] ?? "").split("=", 2)[1];
  if (!timestamp || !nonce || !received) return false;

  // Replay window
  if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) return false;

  const signingString = Buffer.concat([
    Buffer.from(`${timestamp}.${nonce}.`),
    payload,
  ]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signingString)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;

  // Record the nonce only after the signature is valid
  if (seenNonces.has(nonce)) return false;
  seenNonces.add(nonce, MAX_AGE_SECONDS);
  return true;
}

Both implementations bind the signature to the timestamp and nonce, reject requests outside a 5-minute window, and use constant-time comparison to block timing side-channels. For a production example with a real provider signing scheme, see our Stripe webhooks implementation guide for a real-world HMAC verification example.

IP allowlisting vs HMAC signature verification: which do you need?

You need HMAC signature verification on every webhook endpoint; an IP allowlist (often called an IP whitelist) is an optional second layer for providers that publish their source ranges. IP allowlisting restricts webhook ingress to a known set of source addresses. It is useful for high-security endpoints — payments, admin actions, compliance-sensitive data flows — where you want a second factor beyond HMAC. It should never be your primary authentication: NAT, shared egress, and spoofable source IPs on private networks all weaken it.

The practical limitation is churn. Cloud provider egress ranges change frequently, serverless platforms (Lambda, Cloud Run, Vercel Functions) rotate outbound IPs without notice, and large SaaS senders publish CIDR lists that must be re-fetched on a schedule. An allow-list that is not automated will eventually start dropping legitimate traffic.

The correct pattern is defense-in-depth: HMAC signature verification is the primary auth, and the IP allow-list is an additional L4 filter that rejects traffic before your application stack even parses the body. Pull provider CIDR blocks from their official sources on a cron and refresh your allow-list automatically: Stripe publishes webhook IPs at docs.stripe.com/ips (plain-text list at stripe.com/files/ips/ips_webhooks.txt), and GitHub returns them in the hooks key of GET https://api.github.com/meta. Shopify publishes no webhook source ranges and relies on its X-Shopify-Hmac-SHA256 signature, which is why an allowlist can never be your only check.

Example webhook security configuration schema

Keeping each endpoint's security settings in one declarative object makes reviews and rotations boring. A per-endpoint config that covers IP allowlisting, HMAC verification, key rotation and payload validation can look like this:

{
  "endpoint": "/webhooks/stripe",
  "ip_allowlist": {
    "enabled": true,
    "source": "https://stripe.com/files/ips/ips_webhooks.txt",
    "refresh_every": "24h",
    "on_fetch_failure": "keep_last_known_list"
  },
  "signature": {
    "scheme": "hmac-sha256",
    "header": "Stripe-Signature",
    "signed_content": "{timestamp}.{raw_body}",
    "tolerance_seconds": 300,
    "keys": [
      { "kid": "current",  "secret_ref": "vault:webhooks/stripe/current" },
      { "kid": "previous", "secret_ref": "vault:webhooks/stripe/previous", "expires_at": "2026-09-27T12:00:00Z" }
    ]
  },
  "replay": { "dedupe_on": "body.id", "ttl_seconds": 604800 },
  "payload": { "max_bytes": 1048576, "json_schema": "schemas/stripe-event.json" }
}

Combined middleware example

from ipaddress import ip_address, ip_network

ALLOWED_CIDRS = [ip_network(c) for c in load_provider_cidrs()]  # refreshed by cron

def webhook_middleware(request, secret, seen_nonces):
    # 1. L4 filter: reject if source IP is outside the provider allow-list
    src = ip_address(request.client_ip)
    if not any(src in net for net in ALLOWED_CIDRS):
        return 403, "ip_not_allowed"

    # 2. L7 auth: HMAC signature is still the source of truth
    if not verify(request.body, request.headers, secret, seen_nonces):
        return 401, "invalid_signature"

    return 200, "ok"

The allow-list filter runs cheaply at the edge (Cloudflare WAF, AWS WAF, or an Nginx allow/deny block) and absorbs scanning traffic before it reaches your HMAC verifier. When inspecting signature headers during integration testing, use our webhook debugger to inspect incoming signatures during testing — it surfaces the raw headers, body, and source IP side-by-side so you can confirm both layers are behaving correctly before promoting to production.

Transport Security Best Practices

HTTPS/TLS Requirements

Always use HTTPS for webhook endpoints. TLS encryption protects data in transit and prevents eavesdropping.

  • Serve TLS 1.2 or 1.3 (Stripe only delivers webhooks over those versions)
  • Keep a valid, publicly trusted certificate with the full intermediate chain; senders reject broken chains
  • When you send webhooks, verify the receiver's certificate and never disable TLS verification
  • Reject plain HTTP in production instead of redirecting (many senders, including Stripe, treat a 3xx as a failed delivery)

Network Security Controls

// Network security configuration const securityConfig = { // IP allowlist: GitHub "hooks" ranges from GET https://api.github.com/meta // (snapshot from September 2026; refresh from the API, don't hardcode) allowedIPs: [ '192.30.252.0/22', '185.199.108.0/22', '140.82.112.0/20', '143.55.64.0/20', '2a0a:a440::/29', '2606:50c0::/32', ], // Rate limiting rateLimit: { windowMs: 15 * 60 * 1000, // 15 minutes max: 100 // limit each IP to 100 requests per windowMs }, // Request validation maxPayloadSize: '1mb', timeout: 30000 // 30 seconds };

Advanced Security Features

  • Mutual TLS (mTLS): Client certificate authentication for high-security environments
  • Request Signing: Sign entire HTTP requests, not just payloads
  • Nonce Validation: Use one-time values to prevent replay attacks
  • Payload Encryption: Encrypt sensitive data within webhook payloads

Security Monitoring & Incident Response

Security Metrics to Track

Authentication Metrics

  • • Failed authentication attempts
  • • Signature verification failures
  • • Invalid timestamp patterns
  • • Unusual source IP addresses

Traffic Anomalies

  • • Unusual request patterns
  • • Payload size anomalies
  • • Rate limit violations
  • • Geographic access patterns

Incident Response Procedures

Security Incident Response Steps:

  1. Detection: Automated alerts for security violations
  2. Isolation: Temporarily block suspicious traffic sources
  3. Investigation: Analyze attack patterns and affected systems
  4. Containment: Rotate compromised secrets and update security rules
  5. Recovery: Restore service with enhanced security measures
  6. Review: Post-incident analysis and security improvements

Test signature verification with Hooklistener

Hooklistener lets you test your verifier against a real, provider-signed webhook instead of a hand-built fixture. Use the dashboard, or ask your AI assistant to run the same steps through the Hooklistener MCP server.

  1. Capture a real delivery. Create a debug endpoint, register its URL with Stripe, GitHub or Slack, and trigger one event. The raw bytes and every header, including the signature, are stored as received.
  2. Verify the signature. Save your signing secret with create_secret (stored encrypted, never returned), then run verify_request_signature. It reports HMAC validity separately from timestamp freshness, so you can tell a wrong secret from a stale request.
  3. Replay it to localhost, re-signed. Run hooklistener listen --endpoint <ENDPOINT_ID> --port 3000, then call replay_request with target: "cli", signing_provider and signing_secret_id. The signature is generated at delivery, so the timestamp is fresh and your 300-second check passes.
  4. Prove the rejection paths. Replay an edited body without re-signing, or wait past the tolerance and replay the original: your handler should return 400 or 401 both times, never 500.

Capturing and replaying work on the Free plan; stored secrets (needed for verification and re-signing) are on paid plans.

Frequently Asked Questions

What are the best practices for webhook payload signing with ephemeral tokens?

Sign the raw request body plus a timestamp with HMAC-SHA256, send a key ID (kid) with every signature, and keep signing keys short-lived: rotate on a schedule and immediately on suspected leaks. During a rotation, sign with both the old and new key (or accept both on the receiver) for a short overlap, such as the 24 hours Stripe allows, so in-flight retries still verify. Reject timestamps older than about 5 minutes and compare signatures in constant time.

How do I verify an HMAC webhook signature?

Read the raw request bytes before any JSON parsing, rebuild the string the sender signed (usually timestamp + '.' + body), compute HMAC-SHA256 with your signing secret, and compare the result to the signature header with a constant-time function such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. Then reject the request if the timestamp is outside your tolerance window.

Should I use IP allowlisting or HMAC signature verification for webhooks?

Use HMAC signature verification as the primary control and treat IP allowlisting as an optional extra layer. HMAC proves the payload came from the holder of the secret and was not modified; an IP allowlist only proves where the packet came from. Stripe and GitHub publish webhook source ranges (docs.stripe.com/ips and api.github.com/meta), but Shopify publishes none, so an allowlist cannot be your only check.

What tolerance should a webhook timestamp check use?

Five minutes (300 seconds) is the common default: it is what Stripe's official libraries use. Tighten it for high-value actions if your clocks are NTP-synced, never set it to 0 (that disables the check in most SDKs), and pair it with a per-delivery ID cache so a request cannot be replayed inside the window.

How can I test webhook signature verification without deploying?

Capture a real webhook on a Hooklistener endpoint, check its signature against your stored secret with the verify_request_signature MCP tool (Stripe, GitHub and Slack schemes), then replay it to localhost through the Hooklistener CLI tunnel with replay_request, re-signed at delivery so the timestamp is fresh. Replay an edited body without re-signing to confirm your handler rejects tampered payloads.

Related Security Resources