Skip to content

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

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

fix(webhooks): accept raw request bytes for signature verification#1578
nicknisi wants to merge 9 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 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, verifyHeader, and constructAction to accept raw bytes (string | Uint8Array | ArrayBuffer) in addition to pre-parsed objects, and switches HMAC computation to operate over the exact received bytes rather than a JSON.stringify round-trip, closing JSON-normalisation bypass vectors (whitespace injection, key reordering, unicode escapes).

  • decode-payload.ts is a new shared module providing WebhookPayload, realm-agnostic isBinaryPayload, and decodePayloadToString, used by both the HMAC path and the post-verification JSON.parse path to guarantee identical byte sequences.
  • signature-provider.ts replaces the hard-coded JSON.stringify(payload) with decodePayloadToString, making the signed string equal to the wire bytes for raw inputs.
  • webhooks.ts / actions.ts add a parse-after-verify step so callers who pass raw bytes still get a fully-typed Event / ActionContext back.

Confidence Score: 5/5

The change is safe to merge; the core HMAC path and parse-after-verify pattern are correct, and the mutation-resistance tests all validate the intended behaviour.

The new decodePayloadToString helper is used consistently across both the signing and parsing paths, so the signed bytes and the parsed bytes are always identical. The isBinaryPayload realm-agnostic detection works correctly across Node Buffer, Uint8Array, and bare ArrayBuffer. Back-compat for the legacy object path is preserved via the existing JSON.stringify fallback. The only finding is a type-cast imprecision (ArrayBufferView vs Uint8Array) that has no runtime consequence.

No files require special attention.

Important Files Changed

Filename Overview
src/common/crypto/decode-payload.ts New shared helper module; logic is correct but isBinaryPayload returns true for all ArrayBufferView subtypes (DataView, Int32Array, …) which are then blindly cast to Uint8Array — harmless at runtime since TextDecoder accepts any BufferSource, but the cast assumes only Uint8Array.
src/common/crypto/signature-provider.ts Cleanly replaced JSON.stringify with decodePayloadToString; remaining timestamp: any param is pre-existing and unrelated to this diff.
src/webhooks/webhooks.ts parseVerifiedPayload helper is correct and parses only after verification; object/binary branching is logically equivalent to the actions.ts inline version.
src/actions/actions.ts Previously-flagged missing parse step is now properly addressed; inline branching logic is functionally identical to parseVerifiedPayload in webhooks.ts.
src/webhooks/webhooks.spec.ts Comprehensive new test suite covering string, Buffer, Uint8Array, ArrayBuffer, BOM injection, whitespace, unicode-escape, and key-reorder mutation vectors.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant constructEvent
    participant verifyHeader
    participant decodePayloadToString
    participant computeSignature
    participant parseVerifiedPayload
    participant deserializeEvent

    Caller->>constructEvent: payload (string | Uint8Array | ArrayBuffer | object)
    constructEvent->>verifyHeader: {payload, sigHeader, secret}
    verifyHeader->>decodePayloadToString: payload
    Note over decodePayloadToString: string → return as-is<br/>binary → TextDecoder(ignoreBOM:true)<br/>object → JSON.stringify
    decodePayloadToString-->>verifyHeader: signable string
    verifyHeader->>computeSignature: timestamp + signable string
    computeSignature-->>verifyHeader: HMAC hex
    verifyHeader-->>constructEvent: verified ✓ (or throws)
    constructEvent->>parseVerifiedPayload: payload
    Note over parseVerifiedPayload: object & not binary → cast<br/>else → decodePayloadToString + JSON.parse
    parseVerifiedPayload-->>constructEvent: EventResponse
    constructEvent->>deserializeEvent: EventResponse
    deserializeEvent-->>Caller: Event
Loading

Reviews (8): Last reviewed commit: "chore: formatting" | 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>
@identity-wael
Copy link
Copy Markdown

Independent reproduction from production (us) — confirming this is the right fix.

Hit this today while wiring @workos-inc/node@7.46.0's webhooks.constructEvent({ payload: rawBody, sigHeader, secret }) against a freshly-created webhook endpoint. Symptom: every delivery rejected with Signature hash does not match the expected signature hash for payload.

Diagnostic confirmed the SDK is HMAC'ing different bytes than WorkOS signs:

  • Manual createHmac('sha256', secret).update(${t}.${rawBody}).digest('hex') → matches the v1= value in the WorkOS-Signature header byte-for-byte.
  • SDK call with the same rawBody string → mismatched HMAC.

Root cause is the unconditional payload = JSON.stringify(payload) at signature-provider.js line 47, which double-encodes when payload is already the raw request body string. The SDK's own spec masks this because it imports a JS object via require('./fixtures/...') and then computes the test signature with JSON.stringify(payload) — so the test fixture round-trips through the same broken logic.

Quick repro (any Node):

const { createHmac } = require('crypto');
const secret = 's', t = '1777940637677';
const rawBody = '{"id":"event_01","data":{"name":"foo"}}';

const correct = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const sdkLike = createHmac('sha256', secret).update(`${t}.${JSON.stringify(rawBody)}`).digest('hex');
console.log({ correct, sdkLike, match: correct === sdkLike }); // match: false

Working around it on our end with a manual verifier (parse t=, v1=, HMAC over ${t}.${rawBody}, crypto.timingSafeEqual). Will switch back to the SDK once #1578 ships in a release. Happy to test against a pre-release if useful.

Also worth noting: the WorkOS-Signature header's t= value carries millisecond epoch (13 digits). The current parseInt(timestamp, 10) < Date.now() - tolerance check at verifyHeader happens to work because both sides are ms — but tolerance = 180000 (ms) does the right thing. Just flagging in case it surfaces during the #1578 review since #1579 went after the same area.

nicknisi added 2 commits May 6, 2026 12:04
Record<string, unknown> rejects typed interfaces without an index
signature, breaking existing callers that pass strongly-typed parsed
objects to constructAction/verifyHeader. Use `object` instead so any
non-primitive passes without requiring a cast.
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.

3 participants