What Is a Webhook Signature?

A webhook signature is a cryptographic fingerprint a provider attaches to every webhook request so your server can prove the payload genuinely came from that provider and was not altered in transit. Without one, anyone who learns your endpoint URL can POST fake events to it.

Why webhook signatures matter

A webhook endpoint is a public URL. Search crawlers, logs, browser history, and leaked code can all expose it. If your handler trusts any request that reaches it, an attacker can forge an event such as "payment succeeded" or "subscription cancelled" and trigger real consequences in your system.

A signature solves this by binding the request body to a secret that only you and the provider know. Because the signature is computed from both the payload and the secret, an attacker who lacks the secret cannot produce a valid one, and any tampering with the body invalidates it. This gives you two guarantees at once: authenticity (it came from the real sender) and integrity (the bytes were not changed).

How HMAC signing works

Most providers (Stripe, GitHub, Shopify, Slack, Twilio and others) use HMAC, a keyed hash. The provider runs the raw request body through a hash function such as SHA-256, mixed with your shared secret, and sends the result in an HTTP header. You repeat the exact same computation on your side and compare. Identical results mean the request is legitimate.

The general formula is straightforward:

signature = HMAC-SHA256(secret, payload)

HMAC is preferred over a plain hash like SHA256(secret + payload) because a naive concatenation is vulnerable to length-extension attacks. HMAC's two-pass construction is purpose-built for message authentication. To see the difference between a raw hash and a keyed one, experiment with the Hash Generator and the HMAC Generator. For background on the underlying primitive, see what is a hash function.

What the provider actually signs

This is the detail most developers get wrong. The signature is computed over the raw, byte-exact request body, not the parsed object. If your framework deserializes JSON and you re-serialize it before verifying, key ordering, whitespace, and number formatting can all change, producing a different signature and a failed check. Always capture the raw body before any parsing middleware touches it.

How to verify a webhook signature

Verification is a fixed sequence of steps. Follow them in order:

  1. Read the raw request body as bytes or a string, before JSON parsing.
  2. Read the signature header the provider documents (e.g. X-Hub-Signature-256, Stripe-Signature).
  3. Look up the signing secret for that endpoint from your environment configuration.
  4. Recompute HMAC-SHA256(secret, rawBody) using the same algorithm and encoding (hex or Base64) the provider specifies.
  5. Compare your computed signature to the header value using a constant-time comparison.
  6. If they do not match, reject with HTTP 400 or 401 and stop. Never process an unverified payload.

A minimal Node.js example shows the shape of a correct implementation:

const crypto = require('crypto');

function verify(rawBody, headerSig, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(headerSig);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

The timingSafeEqual call is not optional, and the length check in front of it is required because the function throws when the two buffers differ in length. See the mistakes below for why a plain comparison is unsafe.

Timestamps and replay protection

A valid signature proves the payload is authentic, but a captured request can be replayed later. To block this, many providers include a timestamp in the signed data. Stripe, for example, signs the timestamp followed by a dot and the payload, then sends both the timestamp and the signature in one header.

On your side, after the signature checks out, also verify the timestamp is recent; a tolerance window of five minutes is a common choice (it is Stripe's default). Reject anything older. If you need to inspect or convert Unix timestamps while debugging, the Timestamp Converter is handy. Note that this only works when the timestamp is part of the signed string; a timestamp in an unsigned header can simply be edited by an attacker.

Signatures vs signed tokens

Webhook HMAC signatures are not the same as a JWT. A JWT is a self-contained, signed token the bearer carries to authenticate itself; a webhook signature authenticates a single message body against a shared secret. Some providers do send a JWT in a webhook header instead of an HMAC, in which case you verify the token's signature and claims rather than recomputing an HMAC. You can inspect such tokens with the JWT Decoder.

Common mistakes

  • Comparing with a plain equality check. A normal string comparison returns early on the first differing character, leaking timing information that can be used to forge a signature byte by byte. Always use a constant-time compare such as crypto.timingSafeEqual or hmac.compare_digest.
  • Signing the parsed body. Re-serialized JSON rarely matches the original bytes. Verify against the raw payload.
  • Wrong encoding. Mixing hex and Base64, or signing a different character encoding than the provider used, guarantees a mismatch. Match the documented format exactly.
  • Skipping verification in development. Code paths that bypass the check "for testing" have a habit of reaching production. Keep verification on everywhere.
  • Hardcoding the secret. Store signing secrets in environment variables or a secrets manager, never in source control.
  • Ignoring timestamps. Without replay protection, a single intercepted request can be resent indefinitely.
  • Leaking the secret in logs. Redact secrets and signature headers before logging request data.

Best-practice checklist

Before you ship a webhook receiver, confirm each of these:

  • The raw body is captured before parsing.
  • The signature is verified on every request, with no bypass.
  • Comparison is constant-time.
  • Timestamps are checked against a tolerance window when provided.
  • Secrets live in configuration, are unique per endpoint, and can be rotated.
  • Failures return a 4xx and are logged (without exposing the secret) for monitoring.
  • You serve the endpoint over HTTPS so the signature and payload cannot be read or modified in transit.

Get these right and your webhook endpoint moves from "trusts anyone who finds the URL" to "only acts on messages cryptographically proven to come from your provider."

Frequently Asked Questions

No. A signature does not hide the payload, so anyone who intercepts the request can still read it. It only proves who sent the message and that it was not altered. To keep the contents private in transit you rely on HTTPS/TLS, which is why serving your endpoint over HTTPS is essential alongside signature verification.

A standard string comparison stops at the first character that differs, so the time it takes leaks how many leading characters were correct. An attacker can measure this to reconstruct a valid signature one byte at a time. Constant-time functions like crypto.timingSafeEqual or hmac.compare_digest always take the same time regardless of where the mismatch is, closing that side channel.

Verification will usually fail. Parsing and re-serializing JSON can change whitespace, key order, and number formatting, so the bytes differ from what the provider signed, producing a different HMAC. Always capture and hash the exact raw request body before any JSON middleware processes it.

Not by themselves. A valid request can be captured and resent. Providers that include a signed timestamp let you add replay protection: after the signature checks out, reject requests whose timestamp falls outside a short tolerance window, such as five minutes. The timestamp must be part of the signed data, not an editable unsigned header.

HMAC with SHA-256 is the most common choice today and is used by Stripe, GitHub, Shopify, and many others. Some older integrations use HMAC-SHA1, which should be avoided for new work. Always match the exact algorithm and output encoding (hex or Base64) the provider documents, since any difference breaks verification.