Skip to content

fix(webhooks): use timingSafeEqual for signature comparison - #43

Open
tsushanth wants to merge 1 commit into
team-plain:mainfrom
tsushanth:fix/webhook-timing-safe-comparison
Open

fix(webhooks): use timingSafeEqual for signature comparison#43
tsushanth wants to merge 1 commit into
team-plain:mainfrom
tsushanth:fix/webhook-timing-safe-comparison

Conversation

@tsushanth

@tsushanth tsushanth commented Jul 21, 2026

Copy link
Copy Markdown

What

Replace the plain string equality check in verifyWebhookSignature with crypto.timingSafeEqual.

Before

if (signature !== expectedSignature) {  }

After

if (signature.length !== expectedSignature.length) { /* early exit */ }

const signaturesMatch = crypto.timingSafeEqual(
  Buffer.from(signature, "utf8"),
  Buffer.from(expectedSignature, "utf8"),
);
if (!signaturesMatch) {  }

Why

JavaScript string comparison with !== short-circuits at the first differing character, so response latency leaks how many leading characters of the expected signature matched. An attacker who can send many requests and measure response time can brute-force the expected HMAC value one hex character at a time — effectively recovering the signing secret offline.

crypto.timingSafeEqual compares two equal-length Buffers in constant time regardless of content. This is the standard fix used by Express, Stripe's SDK, and the Node.js docs example for HMAC verification.

The preceding length check is safe: the expected signature is always exactly 64 hex characters (SHA-256 digest), so a differing length reveals nothing about the key.

References


Note

Low Risk
Small, localized hardening in webhook verification with unchanged error behavior for callers.

Overview
Webhook signature verification in verifyWebhookSignature no longer uses !== on the hex HMAC strings. It now rejects signatures whose length does not match the fixed SHA-256 hex digest length, then compares the provided and expected values with crypto.timingSafeEqual on UTF-8 buffers.

Invalid signatures still return the same PlainWebhookSignatureVerificationError message; only the comparison path changes to avoid timing leaks from short-circuiting string equality.

Reviewed by Cursor Bugbot for commit 85faa10. Bugbot is set up for automated code reviews on this repo. Configure here.

Replace the string equality check (signature !== expectedSignature) with
crypto.timingSafeEqual so that the comparison runs in constant time.
A naive !== comparison on hex strings leaks secret key material through
timing differences: an attacker who can send many requests and measure
response latency can recover the expected signature one character at a
time (a classic timing side-channel).

Node's crypto.timingSafeEqual compares two equal-length Buffers in
O(n) constant time regardless of where they first differ. A preceding
length check is safe because the expected signature is always exactly
64 hex characters (SHA-256), so differing lengths reveal nothing about
the key.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 85faa10. Configure here.

const signaturesMatch = crypto.timingSafeEqual(
Buffer.from(signature, "utf8"),
Buffer.from(expectedSignature, "utf8"),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe timing-safe length check

Medium Severity

The length guard compares JavaScript string .length, but crypto.timingSafeEqual compares UTF-8 buffer byte lengths. A signature with the same character count as the expected hex digest can still encode to a different byte length (e.g. non-ASCII characters), causing timingSafeEqual to throw. Callers rely on a Result return and typically do not catch exceptions here, so verification can crash instead of returning an invalid-signature error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 85faa10. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants