Skip to content

docs: add webhook signature verification guide - #1004

Merged
alexluong merged 5 commits into
mainfrom
docs/webhook-verification
Aug 11, 2026
Merged

docs: add webhook signature verification guide#1004
alexluong merged 5 commits into
mainfrom
docs/webhook-verification

Conversation

@alexluong

Copy link
Copy Markdown
Collaborator

Adds guides/verify-webhook-signatures: how default-mode webhook signatures are constructed (hex HMAC-SHA256 over the raw body, secret used as-is including the whsec_ prefix), verification steps, and rotation-safe code examples in Node.js, Python, and Go. The only customization it accounts for is the header prefix; signature template/algorithm/encoding changes are noted as out of scope. Linked from the webhook destination page and added to the Guides nav.

The code examples were tested against real deliveries: fixtures generated through destwebhook's publisher (single secret, rotation, custom prefix, unicode body) all verify with the samples verbatim, and tampered-body/wrong-secret/stripped-whsec_ cases correctly fail.

Heads-up: the destination page's verification steps drop "reject old timestamps" — the default signature covers only the body, so the timestamp header isn't authenticated; the guide points to event-id dedupe instead.

🤖 Generated with Claude Code

alexluong and others added 2 commits July 23, 2026 19:30
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion guide

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@leggetter leggetter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary — accurate guide, five minor suggestions inline

I verified the guide's claims against the signing implementation and they all hold:

  • Defaults match internal/destregistry/providers/destwebhook/destwebhook.go: hex HMAC-SHA256, content template {{.Body}} (body only), header template v0={{.Signatures | join ","}}, prefix x-outpost-, secret template whsec_{{.RandomHex}}.
  • The secret is used verbatim as the HMAC key (signature.go), so the "don't strip whsec_ or base64-decode" warning is correct and valuable.
  • Rotation ordering (current secret's signature first) and the comma-joined multi-signature format match GenerateSignatures.
  • The timestamp header is RFC 3339 UTC, matching the example.
  • Removing "reject old timestamps" from the destination page is a genuine correctness fix — the default signed content is the body only, so the timestamp header is unauthenticated and freshness checks add no replay protection.

The code examples use constant-time comparison in all three languages, read the raw body before parsing, and handle multi-signature headers correctly.

The inline comments are all minor polish: a Python 3.10+ type-hint compatibility nit, a robustness guard for the Node example, linking the rotation-window duration, mentioning per-header name overrides in the Customization section, and an optional nav-placement thought. None are blockers.


Generated by Claude Code


SIGNATURE_HEADER = "x-outpost-signature"

def verify_signature(raw_body: bytes, signature_header: str | None, secret: str) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The str | None union syntax requires Python 3.10+. Users on 3.8/3.9 who paste this get a TypeError at import time. Consider Optional[str] (with from typing import Optional) or dropping the annotation to keep the sample maximally copy-pasteable.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both 3.8/3.9 have hit EOL by the end of last year. I assume folks can do their own translation if necessary? Not super opinionated tho, just a thought. What do you think?

Comment thread docs/content/guides/verify-webhook-signatures.mdoc
Comment thread docs/content/guides/verify-webhook-signatures.mdoc Outdated

## Customization

The scheme above is Outpost's default. Changing `DESTINATIONS_WEBHOOK_HEADER_PREFIX` only renames the headers — this guide stays accurate with the prefix substituted. Customizing the [signature templates, algorithm, or encoding](/docs/outpost/destinations/webhook#signatures) changes the scheme itself, and the resulting deliveries no longer match this guide.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor completeness point: besides the prefix, individual system header names can also be pinned to a custom name or disabled entirely (e.g. DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME — the destination page documents this for event-id). A brief mention that per-header overrides exist would cover deployments where prefix substitution alone doesn't explain the header names a reader sees.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reworded to be a bit more generic instead of listing all available configuration options.

89896d1

Comment thread docs/content/nav.json
"title": "Migrate to Outpost"
},
{
"slug": "guides/verify-webhook-signatures",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Placement is fine as-is, but this is arguably the first guide most event consumers need — worth considering a higher slot in the Guides list.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think the "Migrate to Outpost" is a more important guide. This is the 2nd one already. I personally think it makes sense this way.

@leggetter leggetter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed with Claude, reviewed the review, and then got Claude to post some comments.

@alexluong
alexluong force-pushed the docs/webhook-verification branch from 07f011a to af144c0 Compare August 11, 2026 07:09
The Customization section promised the guide "stays accurate with the
prefix substituted," which is wrong when an operator pins a header name
outright (DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_NAME and friends).

State the assumption instead of enumerating knobs: the guide covers
default-mode signatures and default header naming, prefix substitution is
the one supported deviation, and anything else is the operator's scheme to
document. Link now points at Operator Configuration rather than the
destination-level Configuration anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexluong
alexluong force-pushed the docs/webhook-verification branch from af144c0 to 89896d1 Compare August 11, 2026 07:11
alexluong and others added 2 commits August 11, 2026 14:19
verifySignature returns false for every other "cannot verify" case —
missing header, wrong prefix, no candidate match — but threw on a body
that wasn't bytes, so callers had to wrap it in try/catch to get a
trustworthy boolean.

Reachable through the sample as written: body-parser sets req.body = {}
before its content-type check, so a request that doesn't match
express.raw({ type: "application/json" }) reaches createHmac().update({})
and Express turns the throw into a 500 instead of a 401. Real deliveries
always match — destwebhook hardcodes Content-Type and reserves it from
custom_headers — so this only shows up on stray traffic, but the same
hole opens anywhere the body arrives undefined.

Accept strings as well as Buffers: crypto's update() handles both, and
rejecting strings would turn a valid body shape into a silent
verification failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"A rotation window" left readers no way to know how long they have to swap
the stored secret. Name the 24-hour default at first mention and point at
the destination page's Secret Rotation section, which already documents
the trigger, the sequence, and the field that sets the window.

Kept to a parenthetical: the receiving side accepts any matching candidate
regardless of the window length, so the duration is context rather than
something the verification code acts on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexluong
alexluong merged commit 6742cd9 into main Aug 11, 2026
10 checks passed
@alexluong
alexluong deleted the docs/webhook-verification branch August 11, 2026 18:58
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.

3 participants