docs: add webhook signature verification guide#1004
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion guide Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leggetter
left a comment
There was a problem hiding this comment.
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 templatev0={{.Signatures | join ","}}, prefixx-outpost-, secret templatewhsec_{{.RandomHex}}. - The secret is used verbatim as the HMAC key (
signature.go), so the "don't stripwhsec_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: |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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:
| 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: |
There was a problem hiding this comment.
"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. |
There was a problem hiding this comment.
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
| "title": "Migrate to Outpost" | ||
| }, | ||
| { | ||
| "slug": "guides/verify-webhook-signatures", |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Reviewed with Claude, reviewed the review, and then got Claude to post some comments.
Adds
guides/verify-webhook-signatures: how default-mode webhook signatures are constructed (hex HMAC-SHA256 over the raw body, secret used as-is including thewhsec_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