Unverified Webhook Signature

ID

javascript.unverified_webhook_signature

Severity

high

Remediation Complexity

medium

Remediation Risk

high

Remediation Effort

low

Resource

Cryptography

Language

JavaScript

Tags

ASVS50:v11.1.1, ASVS50:v13.3.1, CWE:347, NIST.SP.800-53, OWASP:2025:A08, PCI-DSS:6.5.10, PCI-DSS:6.5.8

Description

A webhook or callback handler reads the signature header of the request and never verifies it.

Rationale

Webhook providers sign each delivery so that the receiver can tell it came from them: GitHub sends X-Hub-Signature-256, Stripe Stripe-Signature, Slack X-Slack-Signature, Shopify X-Shopify-Hmac-Sha256, Twilio X-Twilio-Signature. The signature is an HMAC of the raw request body computed with a secret shared with the provider, or a public-key signature over it.

A handler that reads that header and then acts on the payload without checking the signature accepts forged deliveries from anyone who knows the URL: a fake "payment succeeded" event, a fake push that triggers a deployment, a fake status change. Checking that the header is present, or logging it, verifies nothing.

The rule reports a handler that reads a signature header (any header whose name contains signature or hmac and not timestamp, by the default headerPattern) unless it verifies it: a verifier (an HMAC computation, a constant-time comparison or a provider’s verifier) is given the signature, a value computed from it, or the request itself, in the handler or in a function of the same file the handler passes them to. Comparing the header with a value computed elsewhere also counts as a verification. An HMAC that signs something else, such as a call forwarded downstream, verifies nothing, and neither does comparing the signature with another value the caller sends, such as a query parameter.

Verification done once for many routes counts, within the same module (the nearest directory with a build manifest), when the rule can see both that the component verifies and that it applies to the handler: a servlet filter or Spring interceptor mapped on the handler’s path, an Express middleware given to the route or used before it on the same app or router, a Python decorator on the view, a Flask before_request on the same blueprint or a Django middleware listed in MIDDLEWARE, a Go middleware wrapping the handler where it is registered, an ASP.NET filter on the action or its controller or a middleware mounted on its path. The component must read the signature header itself and verify it, as a handler would: one that only checks that the header is present does not count, nor does one that only computes an HMAC (to sign the responses, for instance), nor one mounted on another path, nor one that reads the request path to decide by itself which requests to verify: whether it covers the handler is not known, so the handler is still reported.

When the signature is passed, together with the payload, to a function that is not visible from the handler (a service in another file or module), the verification may happen there: the rule reports that case at low severity (or at the rule’s own severity, when a policy sets it lower), so that the callee can be reviewed. A check that the header is present keeps that lower severity; any other unverified use of the value in the handler, such as echoing it in the response, does not. A header that is only logged is not reported.

In this Express route the signature header is read, checked for presence and then ignored:

const express = require('express');

const app = express();

app.post('/webhooks/github', express.json(), (req, res) => {
  const signature = req.headers['x-hub-signature-256']; // FLAW
  if (!signature) {
    return res.status(400).end();
  }
  res.json({ received: req.body.action });
});

Anyone who can reach /webhooks/github can post a forged event with any value in x-hub-signature-256.

Remediation

Verify every delivery before acting on it:

  • Compute the HMAC of the raw request body (the bytes received, not a re-serialized object) with the shared secret and the algorithm the provider documents, usually HMAC-SHA256.

  • Compare it with the received signature in constant time (MessageDigest.isEqual, hmac.compare_digest, crypto.timingSafeEqual), never with == or equals, and reject the request when they differ.

  • Prefer the provider’s own verifier when there is one (Webhook.constructEvent in Stripe, Webhook.verify in Svix, RequestValidator.validate in Twilio): it also checks the timestamp that protects against replays.

  • Keep the shared secret out of the source code, in an environment variable or a secrets manager.

Compute the HMAC of the raw body and compare it with crypto.timingSafeEqual:

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

const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = Buffer.from(req.headers['x-hub-signature-256'] || '');
  const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex'));
  if (signature.length !== expected.length || !crypto.timingSafeEqual(signature, expected)) {
    return res.status(403).end();
  }
  res.json({ received: JSON.parse(req.body).action });
});

With Stripe, stripe.webhooks.constructEvent(rawBody, signatureHeader, endpointSecret) does the same and also rejects stale timestamps.

The rule recognizes the header read from req.headers[…​] (Node, Express, Koa), through get(), header() or getHeader() on the request (Express’s req, Koa’s ctx, Hono’s c.req), and through the Fetch API’s request.headers.get() (Next.js route handlers, workers), in JavaScript and TypeScript. A get() on anything else, such as searchParams or a configuration object, is not a header read.

Limitations

  • Verification is tied to the signature, or to the request, inside the handler only. A handler that computes an HMAC of the request body without comparing it with the signature can still be taken as verified, and so is a handler that gives the signature or the request to a function of the same file that calls a verifier, whatever that function does with the result.

  • A verifier given the request through a variable of its own (headers = request.headers, then verify(body, headers)) is not tied to it, so the handler is reported.

  • A central component counts only when it reads the signature header itself, or in a method of its own class. One that delegates the read to a function elsewhere, or hands the whole request to a provider verifier without reading the header (go-github’s ValidatePayload(r, secret) in a middleware), is not recognized: the handlers it runs before are still reported.

  • The request is recognized by its name (req, request, ctx, context) or as a headers object: a header read from a request named otherwise is not seen.

Configuration

The rule has the following configurable parameters:

  • headerPattern: the regular expression a header name must match to be a signature header, matched case-insensitively after replacing with - and removing Django’s HTTP prefix. Defaults to ^(?!.timestamp).(signature|hmac).*$.

  • verifiers: the calls that verify a signature: an HMAC computation, a constant-time comparison or a provider verifier, as a bare function name or qualified with the type or module it belongs to. Defaults to crypto.createHmac, crypto.timingSafeEqual, crypto.verify, crypto.createVerify, crypto.subtle.verify, Stripe webhooks.constructEvent, Svix Webhook.verify, Octokit Webhooks.verify, Twilio validateRequest, discord-interactions verifyKey and TweetNaCl sign.detached.verify. A bare name matches a function imported from a package only, never a function of the project of the same name.

  • loggers: the names of the calls that only log a value. A signature only ever passed to them is not reported.

References

© 2026 Xygeni. All rights reserved