Currently Available: Need a skilled Software Developer for your next project?
Categories
APIs

How to Verify Webhook Signatures Before Processing a Request

A webhook lets an external service send an HTTP request to a URL you control. Anyone can send a request to a public URL, so your server should verify that the sender created it before acting on the payload.

The provider calculates a webhook signature from the request body and a shared secret; your server calculates the expected signature and compares it with the one in the request. Check the signature against the original body bytes before parsing or processing the payload. This confirms that someone with the secret created the body. Separate checks handle duplicate deliveries and unsafe payload content.

Verify the original request body

A signature depends on the exact bytes the provider signed. If your server parses the request as JSON and then serializes it again, whitespace or key order can change. The new body may represent the same data but produce a different signature.

Keep the raw request body available and pass those bytes to the signature check. Configure your server framework to preserve the body before JSON parsing consumes or transforms it. The exact setting depends on the framework.

Webhook providers use different signature headers, algorithms, and encodings, so follow the provider’s specification. For example, GitHub uses the X-Hub-Signature-256 header with an HMAC-SHA256 signature. HMAC (keyed-hash message authentication code) uses a secret and a hash function to create a value that someone without the secret cannot reproduce. GitHub’s delivery validation instructions describe its signature format and verification process.

Here is a Node.js example for GitHub’s format. It expects rawBody to be a Buffer containing the exact request bytes and signatureHeader to contain the header value:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyGitHubSignature(rawBody, signatureHeader, secret) {
  if (!Buffer.isBuffer(rawBody) || !secret || !signatureHeader) {
    return false;
  }

  const match = /^sha256=([0-9a-f]{64})$/.exec(signatureHeader);
  if (!match) {
    return false;
  }

  const expected = createHmac("sha256", secret)
    .update(rawBody)
    .digest();

  const received = Buffer.from(match[1], "hex");

  return received.length === expected.length &&
    timingSafeEqual(received, expected);
}

The function rejects missing or malformed signatures and uses Node’s constant-time comparison for byte strings of equal length. The comparison takes roughly the same time whether few or many initial bytes match. This limits timing information an attacker could use to guess a signature. Do not compare signatures with ordinary string equality. GitHub also recommends constant-time comparison in its verification guidance.

If verification fails, do not use the payload or trigger side effects. Store the endpoint’s secret in an environment variable or secrets manager, not in source code. Require HTTPS to encrypt the request in transit.

Add replay and duplicate-delivery protection

A valid signature proves that a request matches a message created with the shared secret, but it cannot establish whether the sender has sent that message before. An attacker who obtains a valid request could resend it. Providers can also deliver the same event more than once.

Use the replay protections the provider supports. If the provider includes a timestamp in the signed message, check that it falls within an allowed age. The signature must cover the timestamp; otherwise, someone could change it without invalidating the signature. A narrow acceptance window limits how long someone can reuse a captured request. Clock differences between your server and the provider can also cause valid requests to fail. Replay-prevention guidance describes timestamp checks and their limits.

Some providers do not include timestamps in their signatures. Use a delivery or event ID to detect repeats. Store the ID in durable storage, and record it atomically with the decision to process the event. This prevents concurrent duplicate requests from triggering the same side effect. GitHub’s signature format does not provide timestamp-based replay protection, so deduplicate deliveries with the provider’s delivery identifier.

Validate the payload after verification

A valid signature confirms that the request came from someone with the shared secret and that its contents match the signed message. It does not make every field safe. Parse the verified body and check that it matches the expected event structure. Validate its values before using them in database queries, commands, or other operations. Guidance on webhook security risks also says to treat authenticated payloads as untrusted input.

Each provider has its own signature format. Shopify uses a Base64-encoded HMAC-SHA256 value. Stripe includes a timestamp in the signed content. Follow the provider’s documentation for the algorithm, header, encoding, and signed input. Do not adapt another provider’s verification code. The HMAC verification overview summarizes these differences.

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Email updates

Usually a new article and a few links I found interesting.

No spam. Unsubscribe with one click.

Leave a Reply

Your email address will not be published. Required fields are marked *