Skip to content

docs: add webhook signature verification guide#1004

Open
alexluong wants to merge 2 commits into
mainfrom
docs/webhook-verification
Open

docs: add webhook signature verification guide#1004
alexluong wants to merge 2 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


const SIGNATURE_HEADER = "x-outpost-signature";

function verifySignature(rawBody, signatureHeader, secret) {

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.

Edge case: with express.raw({ type: "application/json" }), a request sent without a matching Content-Type leaves req.body as an empty object (not a Buffer), and crypto.createHmac(...).update({}) throws an unhandled exception instead of returning 401. A one-line guard makes the sample robust:

Suggested change
function verifySignature(rawBody, signatureHeader, secret) {
function verifySignature(rawBody, signatureHeader, secret) {
if (!Buffer.isBuffer(rawBody)) return false;

Generated by Claude Code


## Secret rotation

When a signing secret is rotated, both the old and new secrets remain valid for a rotation window. During that window the signature header contains two comma-separated signatures — the current secret's first, the previous secret's second:

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.

"a rotation window" — worth saying how long, or linking to where it's defined. The window is governed by previous_secret_invalid_at (documented in the credentials table on the webhook destination page), with a 24-hour fallback when unset (internal/destregistry/providers/destwebhook/signature.go, GenerateSignatures). A link would let readers find the actual duration for their deployment.


Generated by Claude Code


## 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

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

@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.

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