Skip to content

Webhook signatures don't match Standard Webhooks spec's key derivation, breaking verification in spec-compliant/third-party libraries #13519

Description

@stilla

Summary

Webhook deliveries fail HMAC signature verification for any consumer that implements the Standard Webhooks spec's key-derivation step exactly (strip whsec_ prefix, base64-decode the remainder to get the raw HMAC key). This is not sandbox-specific and not caused by secret rotation or stale copies; it reproduces on every single delivery.

Root cause

In _webhook_event_send (server/polar/webhook/tasks.py):

b64secret = base64.b64encode(event.webhook_endpoint.secret.encode("utf-8")).decode("utf-8")
wh = StandardWebhook(b64secret)
signature = wh.sign(str(event.id), ts, event.payload)

The full literal secret (including the whsec_/polar_whs_ prefix) is base64-encoded before being handed to the standardwebhooks library. That library's constructor checks secret.startswith("whsec_") to decide whether to strip a prefix, then always base64.b64decode()s the result to get the raw key. Because we pre-encode the whole secret, the prefix check never matches, and the subsequent decode simply recovers the original literal bytes.

Net effect: the actual HMAC key used to sign deliveries is the literal UTF-8 bytes of the full secret string, prefix included (e.g. whsec_abc123...), not base64decode(secret[len("whsec_"):]) as the Standard Webhooks spec (and our own whsec_ prefix, added in #12712 explicitly for spec alignment) implies.

Our secrets are also not actual base64-encoded key material to begin with (generate_token() in server/polar/kit/crypto.py produces a plain alphanumeric token + checksum suffix), so a spec-compliant base64.b64decode() of the remainder produces a completely different, incorrect key.

Our own SDKs (sdk/python/polar/webhooks.py, sdk/typescript/src/webhooks.ts, added in #13256) happen to work because they use the literal secret bytes directly, with no prefix-stripping or decoding, matching the server's actual (accidental) behavior. Any generic Standard Webhooks-compliant library (svix, standardwebhooks, or a hand-rolled implementation per our own docs) will compute a different key and fail verification on 100% of deliveries.

Reproduction data (from a customer report, sandbox org, format=Raw/JSON)

webhook-id: 90769209-241e-4b4f-994f-6ea65d230f58
webhook-timestamp: 1785577257
body length: 4982 bytes
body SHA-256: cc09b4010eb860f4a3f3b56d4876e6efbbb74d5c3fc946e3f2940d2126eb1f0d
signature computed per spec (strip prefix, b64-decode remainder as key): SqSkePlSookG3PE0x4Eo4rgyrX7PFs4dSMG+YQ0OVcA=
signature we actually sent: v1,42cJwYRO5PTc/rcUaXTcsxYSBqc94pvoPoj2miSnk/0=

All other candidate explanations were ruled out: single endpoint (no secret ambiguity), secret re-copied fresh from settings after re-enable, decodes to a well-formed 32-byte value, dozens of distinct events across multiple types all fail identically. This matches the code-path analysis above, not a sandbox quirk, stale-secret, or disable/re-enable rotation issue (confirmed re-enabling an auto-disabled endpoint does not rotate the secret).

Suggested fix

Sign with the raw literal secret bytes directly (no base64.b64encode before constructing StandardWebhook), so the library's own prefix-strip + base64-decode logic operates on the real secret as intended, OR document clearly that Polar's webhook-signature key derivation is "use the literal secret string bytes" rather than the spec's "strip prefix, base64-decode remainder," and update docs/SDKs accordingly. The former is preferable since it's what the whsec_ prefix (added for spec alignment in #12712) is supposed to signal to integrators using generic Standard Webhooks tooling.

Impact

Any merchant/integrator verifying webhooks with a generic Standard Webhooks-compliant library instead of Polar's own SDK will see 100% of deliveries fail signature verification, in both sandbox and production environments.

Sent by @allison-polar from Sandbox webhook HMAC signature failure investigation.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions