Skip to content

fix(webhooks): accept raw request bytes for signature verification#1578

Open
nicknisi wants to merge 7 commits intomainfrom
fix/webhook-signature-raw-bytes
Open

fix(webhooks): accept raw request bytes for signature verification#1578
nicknisi wants to merge 7 commits intomainfrom
fix/webhook-signature-raw-bytes

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 1, 2026

Summary

  • Widens constructEvent and verifyHeader payload type to accept string | Uint8Array | ArrayBuffer in addition to parsed objects
  • Raw-bytes paths HMAC the exact bytes passed in; object path preserves existing JSON.stringify behavior for back-compat
  • constructEvent parses after verification on raw-bytes paths
  • Shared decodePayloadToString helper ensures signing and parsing always use identical decoding
  • Uses realm-agnostic binary detection (ArrayBuffer.isView / Object.prototype.toString.call) instead of instanceof checks, so payloads from Workers, iframes, or VM contexts are handled correctly

Test plan

  • Existing webhook tests pass (legacy object path)
  • New tests for raw string, Buffer, Uint8Array, and ArrayBuffer payloads
  • Divergence regression tests (whitespace, unicode escapes, key reordering, BOM injection)

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[x] Yes

WorkOS Docs webhook snippets need updating to pass raw request bodies instead of JSON.parse-ing before constructEvent (separate docs PR).

Link to Devin session: https://app.devin.ai/sessions/1170d117f26f431bbc866077728682f6
Requested by: @nicknisi

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Signature verification and construction now accept a typed WebhookPayload (string, bytes, or object). A new decode utility converts payloads to a deterministic UTF‑8 string; signatures are computed over ${timestamp}.${signable} using that string. Verified payloads are parsed before event/action deserialization; tests exercise byte-exact and failing mutations.

Changes

Cohort / File(s) Summary
Crypto utilities
src/common/crypto/decode-payload.ts
Added WebhookPayload type and decodePayloadToString(payload) to produce a consistent UTF‑8 string for string, ArrayBuffer, Uint8Array, or object payloads (uses TextDecoder with ignoreBOM; objects → JSON.stringify).
Signature provider
src/common/crypto/signature-provider.ts
Updated verifyHeader and computeSignature signatures to accept WebhookPayload. computeSignature now derives the signable string via decodePayloadToString(payload) and computes HMAC over ${timestamp}.${signable} instead of always JSON.stringify-ing.
Webhook implementation
src/webhooks/webhooks.ts
Webhooks.constructEvent now accepts WebhookPayload. After header verification, payloads are handled via parseVerifiedPayload: non-byte objects are returned as parsed events; byte/string inputs are decoded with decodePayloadToString and JSON.parse-ed into events.
Actions
src/actions/actions.ts
Actions.constructAction parameter changed to WebhookPayload. signResponse ensures response payloads are converted to Record<string, unknown> for signature computation. Construct/deserialization decodes byte/string payloads with decodePayloadToString and parses JSON before deserializing.
Tests
src/webhooks/webhooks.spec.ts
Added tests computing HMAC over raw byte sequences and validating constructEvent with raw JSON string, Buffer/Uint8Array/ArrayBuffer. Added negative cases (whitespace changes, unicode-escape, key reordering, UTF-8 BOM) that must fail verification; kept back-compat test for pre-parsed object payload.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(webhooks): accept raw request bytes for signature verification' directly and clearly describes the main change: expanding webhook signature verification to accept raw request bytes (string, Uint8Array, ArrayBuffer) in addition to parsed objects.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and covers the major changes: payload type expansion, HMAC verification logic, test plan, and documentation requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/webhook-signature-raw-bytes

Comment @coderabbitai help to get the list of available commands and usage tips.

Widens constructEvent/verifyHeader payload to string | Uint8Array |
ArrayBuffer | object. Raw-bytes paths HMAC the exact bytes the server
signed; object path keeps the legacy JSON.stringify behavior for
back-compat. constructEvent now parses after verification on the raw
paths, matching the pattern used by Stripe, GitHub, and Svix.

Hardening only — the object path remains unsafe to on-the-wire
mutations that round-trip through JSON.parse/JSON.stringify to the
same canonical form (whitespace, key order, unicode escapes,
duplicate keys). Callers should migrate to passing the raw body.
@nicknisi nicknisi force-pushed the fix/webhook-signature-raw-bytes branch from 0efe598 to d16dbdf Compare May 1, 2026 04:12
@nicknisi nicknisi marked this pull request as ready for review May 1, 2026 17:22
@nicknisi nicknisi requested review from a team as code owners May 1, 2026 17:22
@nicknisi nicknisi requested review from cmatheson and gjtorikian May 1, 2026 17:22
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 1, 2026

Greptile Summary

This PR widens constructEvent and constructAction to accept string | Uint8Array | ArrayBuffer payloads in addition to pre-parsed objects, fixing signature verification to HMAC the exact bytes passed in rather than a re-stringified round-trip. A shared decodePayloadToString helper ensures the HMAC input and post-verification JSON.parse input are always derived from the same bytes, and the legacy object path is preserved for back-compat. Previous review concerns (| unknown type collapse, duplicated decode logic, missing parse step in constructAction) are all resolved.

Confidence Score: 5/5

Safe to merge; all findings are P2 style/edge-case concerns with no present runtime defects on normal payloads.

No P0 or P1 issues found. The BOM + JSON.parse concern is a degenerate edge case (server signing BOM-prefixed JSON is non-standard) and is P2. The branching style inconsistency between webhooks.ts and actions.ts is cosmetic. All previous P1 reviewer concerns are resolved.

src/common/crypto/decode-payload.ts — BOM handling in the post-verification parse path.

Important Files Changed

Filename Overview
src/common/crypto/decode-payload.ts New shared utility for payload decoding; realm-agnostic binary detection is well-reasoned. Minor concern: ignoreBOM: true preserves BOM in the HMAC path (intentional) but also passes it to JSON.parse, which rejects a leading U+FEFF under ECMAScript spec.
src/common/crypto/signature-provider.ts Cleanly replaced JSON.stringify(payload) with the shared decodePayloadToString helper; previous `
src/webhooks/webhooks.ts Added parseVerifiedPayload helper to correctly handle all three payload shapes post-verification; logic and back-compat path are correct.
src/actions/actions.ts Fixed missing parse step for raw-bytes payloads flagged in previous review; logic is correct but uses a different branching style than the parallel code in webhooks.ts.
src/webhooks/webhooks.spec.ts Comprehensive new test suite covering all raw-bytes shapes (string, Buffer, Uint8Array, ArrayBuffer), four byte-mutation regression cases (whitespace, unicode escapes, key reordering, BOM injection), and legacy object back-compat.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Caller: constructEvent / constructAction] --> B{payload type?}
    B -->|string| C[decodePayloadToString\nreturns string as-is]
    B -->|Uint8Array / ArrayBuffer| D[decodePayloadToString\nTextDecoder utf-8 ignoreBOM:true]
    B -->|Record object| E[decodePayloadToString\nJSON.stringify — back-compat]
    C --> F[computeSignature\nHMAC timestamp.signable]
    D --> F
    E --> F
    F --> G{secureCompare\nsig matches?}
    G -->|No| H[throw SignatureVerificationException]
    G -->|Yes| I{payload is raw bytes/string?}
    I -->|Yes| J[JSON.parse decoded string]
    I -->|No — object| K[cast directly to EventResponse / ActionPayload]
    J --> L[deserializeEvent / deserializeAction]
    K --> L
Loading

Reviews (6): Last reviewed commit: "fix(webhooks): use realm-agnostic binary..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/webhooks/webhooks.ts (1)

9-26: ⚡ Quick win

Unify payload decoding/signable conversion to a single shared helper.

Line 9-26 duplicates raw-byte decoding logic already present in src/common/crypto/signature-provider.ts (Line 98-114). Keeping two implementations risks future drift between “what gets signed” and “what gets parsed.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/webhooks/webhooks.ts` around lines 9 - 26, The parseVerifiedPayload
function duplicates raw-byte decoding logic from signature-provider.ts; extract
a single shared helper (e.g., decodePayloadToString or normalizeSignablePayload)
that handles string | Uint8Array | ArrayBuffer | Record<string, unknown> and
returns a decoded JSON string or parsed object consistently, then replace
parseVerifiedPayload with a thin wrapper that uses that helper and update
signature-provider.ts to call the same helper; ensure the helper preserves the
existing behavior for TextDecoder('utf-8') and returns/throws the same types
(EventResponse parsing remains in parseVerifiedPayload or the wrapper), and add
the helper to a common utility module and import it from both webhooks.ts and
signature-provider.ts.
src/webhooks/webhooks.spec.ts (1)

214-334: ⚡ Quick win

Add one direct ArrayBuffer test for parity with new runtime branches.

This suite validates string/Buffer/Uint8Array, but not ArrayBuffer, while both parseVerifiedPayload and toSignableString include explicit ArrayBuffer branches.

📌 Minimal test addition
+    it('verifies when payload is an ArrayBuffer matching the signed bytes', async () => {
+      const rawBody = JSON.stringify(mockWebhook);
+      const hash = signRaw(rawBody, timestamp, secret);
+      const sigHeader = `t=${timestamp}, v1=${hash}`;
+      const arrayBuffer = new TextEncoder().encode(rawBody).buffer;
+
+      const webhook = await workos.webhooks.constructEvent({
+        payload: arrayBuffer,
+        sigHeader,
+        secret,
+      });
+
+      expect(webhook.id).toEqual('wh_123');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/webhooks/webhooks.spec.ts` around lines 214 - 334, The test suite for
raw-byte payloads is missing an ArrayBuffer case which leaves the ArrayBuffer
branches in parseVerifiedPayload and toSignableString untested; add a new it
block similar to "verifies when payload is a Uint8Array matching the signed
bytes" that builds signedBytes = JSON.stringify(mockWebhook), computes hash via
signRaw(signedBytes, timestamp, secret), constructs an ArrayBuffer from the raw
string (e.g., via TextEncoder().encode(...).buffer or Buffer.from(...).buffer),
calls workos.webhooks.constructEvent with payload set to that ArrayBuffer and
the computed sigHeader/secret, and assert webhook.id === 'wh_123' to ensure the
ArrayBuffer path is exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/webhooks/webhooks.spec.ts`:
- Around line 214-334: The test suite for raw-byte payloads is missing an
ArrayBuffer case which leaves the ArrayBuffer branches in parseVerifiedPayload
and toSignableString untested; add a new it block similar to "verifies when
payload is a Uint8Array matching the signed bytes" that builds signedBytes =
JSON.stringify(mockWebhook), computes hash via signRaw(signedBytes, timestamp,
secret), constructs an ArrayBuffer from the raw string (e.g., via
TextEncoder().encode(...).buffer or Buffer.from(...).buffer), calls
workos.webhooks.constructEvent with payload set to that ArrayBuffer and the
computed sigHeader/secret, and assert webhook.id === 'wh_123' to ensure the
ArrayBuffer path is exercised.

In `@src/webhooks/webhooks.ts`:
- Around line 9-26: The parseVerifiedPayload function duplicates raw-byte
decoding logic from signature-provider.ts; extract a single shared helper (e.g.,
decodePayloadToString or normalizeSignablePayload) that handles string |
Uint8Array | ArrayBuffer | Record<string, unknown> and returns a decoded JSON
string or parsed object consistently, then replace parseVerifiedPayload with a
thin wrapper that uses that helper and update signature-provider.ts to call the
same helper; ensure the helper preserves the existing behavior for
TextDecoder('utf-8') and returns/throws the same types (EventResponse parsing
remains in parseVerifiedPayload or the wrapper), and add the helper to a common
utility module and import it from both webhooks.ts and signature-provider.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 84f1affd-d544-4f0c-ad15-3440f048dd9d

📥 Commits

Reviewing files that changed from the base of the PR and between c4793bc and d16dbdf.

📒 Files selected for processing (3)
  • src/common/crypto/signature-provider.ts
  • src/webhooks/webhooks.spec.ts
  • src/webhooks/webhooks.ts

Comment thread src/webhooks/webhooks.ts Outdated
TextDecoder strips a leading UTF-8 BOM (0xEF 0xBB 0xBF) by default.
An attacker could prepend BOM bytes to a Buffer/Uint8Array payload and
pass signature verification because the HMAC was computed on the
BOM-stripped string. Use { ignoreBOM: true } to preserve all bytes.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/webhooks/webhooks.spec.ts (1)

214-262: ⚡ Quick win

Please add an ArrayBuffer case here too.

Both parseVerifiedPayload in src/webhooks/webhooks.ts:20-25 and toSignableString in src/common/crypto/signature-provider.ts:112-115 have a separate ArrayBuffer branch, but this suite only exercises string, Buffer, and Uint8Array. One small positive test would close the gap on the last new public payload type.

Example test shape
+    it('verifies when payload is an ArrayBuffer matching the signed bytes', async () => {
+      const rawBody = JSON.stringify(mockWebhook);
+      const hash = signRaw(rawBody, timestamp, secret);
+      const sigHeader = `t=${timestamp}, v1=${hash}`;
+      const bytes = new TextEncoder().encode(rawBody);
+
+      const webhook = await workos.webhooks.constructEvent({
+        payload: bytes.buffer.slice(
+          bytes.byteOffset,
+          bytes.byteOffset + bytes.byteLength,
+        ),
+        sigHeader,
+        secret,
+      });
+
+      expect(webhook.id).toEqual('wh_123');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/webhooks/webhooks.spec.ts` around lines 214 - 262, Add a fourth spec that
mirrors the existing "Uint8Array" test but passes an ArrayBuffer payload to
workos.webhooks.constructEvent so the ArrayBuffer branch in parseVerifiedPayload
and toSignableString is covered; use the same rawBody =
JSON.stringify(mockWebhook), compute hash with signRaw(rawBody, timestamp,
secret), build sigHeader = `t=${timestamp}, v1=${hash}`, create bytes via new
TextEncoder().encode(rawBody).buffer (or bytes.buffer) and call constructEvent({
payload: arrayBuffer, sigHeader, secret }) and assert webhook.id === 'wh_123'.
src/webhooks/webhooks.ts (1)

9-28: ⚡ Quick win

Extract the raw-payload decoding into one shared helper.

parseVerifiedPayload now re-implements the same Uint8Array / ArrayBuffer / BOM handling as toSignableString in src/common/crypto/signature-provider.ts:98-118. Since these two paths define what gets verified and what gets parsed afterward, letting them drift will recreate signing/parsing mismatches. Please move the decode logic to a single shared helper and reuse it from both sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/webhooks/webhooks.ts` around lines 9 - 28, The payload decoding logic
duplicated between parseVerifiedPayload and toSignableString should be extracted
into a single helper (e.g., decodeRawPayload or normalizePayloadForSigning)
placed in the shared common/crypto module; implement the helper to accept string
| Uint8Array | ArrayBuffer | Record<string, unknown>, return either the original
string or a UTF-8 decoded string from Uint8Array/ArrayBuffer using TextDecoder
with ignoreBOM:true, and preserve passing through Record inputs; then replace
the decoding branches inside parseVerifiedPayload and the toSignableString
implementation in signature-provider.ts to call this helper so both verification
and parsing use the identical BOM-safe decoding path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/webhooks/webhooks.spec.ts`:
- Around line 214-262: Add a fourth spec that mirrors the existing "Uint8Array"
test but passes an ArrayBuffer payload to workos.webhooks.constructEvent so the
ArrayBuffer branch in parseVerifiedPayload and toSignableString is covered; use
the same rawBody = JSON.stringify(mockWebhook), compute hash with
signRaw(rawBody, timestamp, secret), build sigHeader = `t=${timestamp},
v1=${hash}`, create bytes via new TextEncoder().encode(rawBody).buffer (or
bytes.buffer) and call constructEvent({ payload: arrayBuffer, sigHeader, secret
}) and assert webhook.id === 'wh_123'.

In `@src/webhooks/webhooks.ts`:
- Around line 9-28: The payload decoding logic duplicated between
parseVerifiedPayload and toSignableString should be extracted into a single
helper (e.g., decodeRawPayload or normalizePayloadForSigning) placed in the
shared common/crypto module; implement the helper to accept string | Uint8Array
| ArrayBuffer | Record<string, unknown>, return either the original string or a
UTF-8 decoded string from Uint8Array/ArrayBuffer using TextDecoder with
ignoreBOM:true, and preserve passing through Record inputs; then replace the
decoding branches inside parseVerifiedPayload and the toSignableString
implementation in signature-provider.ts to call this helper so both verification
and parsing use the identical BOM-safe decoding path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4ad3e0ca-41d2-4f16-81ee-aaced90ffba5

📥 Commits

Reviewing files that changed from the base of the PR and between d16dbdf and a20bd27.

📒 Files selected for processing (3)
  • src/common/crypto/signature-provider.ts
  • src/webhooks/webhooks.spec.ts
  • src/webhooks/webhooks.ts

Addresses PR review feedback:
- Remove `| unknown` from verifyHeader/computeSignature unions so
  TypeScript actually enforces the payload type at call sites.
- Extract `decodePayloadToString` and `WebhookPayload` type into a
  shared module to eliminate duplicated decode logic between
  signature-provider.ts and webhooks.ts.
- Add missing ArrayBuffer test case.
- Fix actions.ts call sites surfaced by the tighter types.
greptile-apps[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

nicknisi added 2 commits May 1, 2026 16:15
constructAction now accepts WebhookPayload (string | Uint8Array |
ArrayBuffer | Record) but was casting directly to ActionPayload
without parsing. Raw-bytes payloads would pass verification but
silently fail deserialization. Parse via decodePayloadToString +
JSON.parse for non-object payloads, matching the webhook pattern.
coderabbitai[bot]

This comment was marked as resolved.

Replace instanceof Uint8Array/ArrayBuffer checks with
ArrayBuffer.isView() and Object.prototype.toString.call() to handle
payloads from different JS realms (Workers, iframes, VM contexts).

Extract shared isBinaryPayload() helper into decode-payload.ts and
use it consistently in webhooks.ts and actions.ts.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants