Webhook Security Signatures and Secrets
Level: advanced · ~14 min read · Intent: informational
Key takeaways
- A webhook endpoint should treat every inbound POST as untrusted until the signature, event type, replay window, and duplicate-delivery rules pass.
- Signature verification must use the raw request body. JSON parsing, whitespace changes, or encoding changes before verification can make valid signatures fail.
- HMAC verification proves payload integrity and shared-secret possession, but replay protection, idempotency, event filtering, HTTPS, and secret rotation still matter.
- The safest receiver path is verify first, reject invalid deliveries, store a delivery record, acknowledge quickly, and process business work from a queue.
References
FAQ
- What is a webhook secret?
- A webhook secret is a shared value known by the sender and receiver. The sender uses it to compute a signature, and the receiver uses the same secret to verify that the delivery is authentic and unchanged.
- Why does webhook signature verification need the raw body?
- The signature is calculated over the exact bytes or text sent by the provider. If your framework parses JSON, reorders keys, changes whitespace, or changes encoding first, your computed signature may not match.
- Is HTTPS enough for webhook security?
- No. HTTPS protects transport, but it does not prove that the request came from the expected provider. Use HTTPS plus provider-specific signature verification.
- How do I prevent webhook replay attacks?
- Use provider timestamps or delivery IDs where available, enforce a freshness window, record processed delivery IDs, make handlers idempotent, and reject duplicate or stale deliveries.
A webhook URL is public infrastructure, not a secret tunnel. Anyone who can find or guess the endpoint can send an HTTPS POST. The receiver has to prove the request came from the provider it trusts before it changes data, sends email, creates tickets, ships orders, deploys code, or charges a customer.
That proof usually comes from a shared secret and a signature. But webhook security does not end at "the HMAC matched." A safe receiver also needs raw-body handling, replay protection, duplicate detection, narrow event subscriptions, fast acknowledgement, queue-based processing, logging, and a rotation plan.
This guide is for teams building or reviewing production webhook receivers in workflow automation, SaaS integrations, payment systems, CI/CD tooling, CRM syncs, ecommerce operations, and internal platforms.
The receiver should verify before it parses
The safest order is boring and strict:
- Receive the HTTPS request.
- Read the raw request body exactly as delivered.
- Read the provider's signature header.
- Verify the signature using the configured secret.
- Reject invalid deliveries before business logic runs.
- Check freshness, duplicate delivery ID, event type, and action.
- Store a delivery record.
- Acknowledge quickly.
- Process side effects from a queue or background worker.
The order matters. If you parse JSON before signature verification, some frameworks may change the body. Stripe's signature troubleshooting docs call this out directly: the request body used for verification must be the UTF-8 body string Stripe sent, without changes. Whitespace changes, key reordering, encoding changes, or JSON parsing before verification can break the check.
GitHub gives the same operational shape from another angle. Its webhook guidance says to validate deliveries before processing, use a webhook secret, subscribe only to needed events, check event type and action, and respond within 10 seconds. Stripe says webhook handlers should quickly return a successful status before complex logic that could time out.
What a signature proves, and what it does not
A signature usually proves two things:
- the sender had access to the shared secret
- the payload used to calculate the signature was not changed before verification
It does not prove:
- the event is fresh
- the event has not been replayed
- the receiver should process this event type
- the side effect is safe to run twice
- the secret is still well protected
- the payload is valid for your business rules
That is why a signature check is the entry gate, not the whole security model.
Provider signature formats differ
Do not write one generic webhook verifier and assume every provider works the same way. Signature headers, canonical strings, encodings, timestamp behavior, and duplicate identifiers differ.
| Provider | Common verification signal | Important detail |
|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC SHA-256 over the request body when a secret is configured. GitHub recommends SHA-256 over the older SHA-1 header. |
| Stripe | Stripe-Signature |
Verify with the raw body, signature header, and endpoint secret. Stripe libraries include timestamp tolerance behavior. |
| Shopify | X-Shopify-Hmac-Sha256 |
Shopify says deliveries include an HMAC signature, and a delivery ID can be used to detect duplicates. |
The receiver should have a provider-specific verifier. That makes the code easier to test and avoids mixing rules between providers.
A minimal HMAC verifier pattern
This is the core pattern for providers that sign the raw body with HMAC SHA-256. Provider-specific details still matter, so use official SDKs where available.
import { createHmac, timingSafeEqual } from "node:crypto";
type VerifyInput = {
rawBody: Buffer;
signatureHeader: string | undefined;
secret: string;
};
export function verifySha256Hmac({
rawBody,
signatureHeader,
secret,
}: VerifyInput): boolean {
if (!signatureHeader) return false;
const received = signatureHeader.startsWith("sha256=")
? signatureHeader.slice("sha256=".length)
: signatureHeader;
if (!/^[a-f0-9]{64}$/i.test(received)) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const receivedBuffer = Buffer.from(received, "hex");
return (
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer)
);
}
The important points:
- verify the raw body, not a parsed object
- reject missing or malformed signatures
- compare signatures in constant time
- keep provider-specific parsing outside the generic helper
- test failure cases, not only the happy path
For Stripe, use the official library unless you have a strong reason not to. Stripe's docs show verification through the provider library using the raw request body, Stripe-Signature header, and endpoint secret. That is safer than hand-rolling Stripe's signature parsing.
Raw body handling is the common failure
The most common production bug is not weak cryptography. It is body handling.
Frameworks often parse JSON automatically. That is useful for normal API routes and risky for signature verification. If the framework consumes the body stream before your verifier sees it, or turns JSON into an object and back into text, the signature can fail even when the delivery is valid.
Review these questions for every receiver:
| Question | Why it matters |
|---|---|
| Does the webhook route bypass global JSON parsing? | Signature verification needs the original body |
| Is the raw body available as bytes or the exact provider-required string? | HMAC input must match provider input |
| Is the route order correct in Express, Next.js, serverless, or gateway config? | Middleware order can mutate the body first |
| Are tests covering whitespace, key order, and invalid signatures? | Avoids false confidence from one sample payload |
| Does logging avoid printing raw sensitive payloads? | Webhook payloads can contain customer and operational data |
Stripe's signature troubleshooting page explicitly notes that common framework configurations can parse or mutate the data before verification, and that this leads to failed verification. Treat raw body access as part of receiver design, not a last-minute bug fix.
Replay protection needs more than HMAC
A valid signature can still be replayed if an attacker or broken system resends a previously valid request. The payload and signature still match.
Provider support differs:
- Stripe signatures include timestamp information, and Stripe's libraries use a default tolerance window.
- GitHub provides
X-GitHub-Delivery, a unique delivery identifier, and notes that redelivery uses the same value as the original delivery. - Shopify says webhook deliveries include a delivery ID you can use to detect duplicates.
Use whatever the provider gives you, then add receiver-side idempotency.
At minimum, store:
| Field | Why |
|---|---|
| Provider | Avoids ID collision across providers |
| Delivery ID or event ID | Detects duplicates |
| Event type and action | Supports filtering and support review |
| Received time | Supports freshness and incident timelines |
| Signature verification result | Shows rejected traffic without processing it |
| Processing status | Lets support retry safely |
| Idempotency key | Prevents duplicate side effects |
If the provider includes a timestamp in the signed material, enforce a freshness window that fits the provider and your clock reliability. Stripe recommends accurate server clocks because timestamp checks depend on time. Do not invent one universal window for every provider without checking its docs.
Idempotency is part of webhook security
Security and reliability meet at duplicate deliveries. A duplicate valid event should not create duplicate damage.
Examples:
- payment webhook creates the same order twice
- lead webhook sends two welcome emails
- ticket webhook creates duplicate support tickets
- repository webhook triggers duplicate deployments
- customer update webhook overwrites newer data with older state
Make handlers idempotent:
- record delivery IDs before side effects
- use database constraints for event IDs or business keys
- make downstream calls with idempotency keys when supported
- process state transitions only when the transition is still valid
- treat unknown duplicates as review cases, not as fresh work
The error handling patterns guide and retries and duplicate events guide cover the reliability side of this pattern.
Secrets belong in secret storage, not URLs
GitHub warns against putting sensitive information in webhook payload URLs, including API keys and authentication credentials. URLs are too easy to leak through logs, dashboards, screenshots, support tickets, browser history, proxies, and analytics tools.
Use a real webhook secret:
- store it in a secrets manager or protected environment variable
- restrict who can read it
- never commit it to code
- rotate it after exposure or team turnover
- support overlapping old and new secrets during rotation when possible
- log secret identifiers, not secret values
If a provider supports multiple active secrets or endpoint-specific secrets, use that to rotate without downtime. If it does not, plan a maintenance window or temporary dual-receiver path.
HTTPS and allowlists are supporting controls
HTTPS is required for production webhook receivers. Stripe requires publicly accessible HTTPS URLs for registered webhook endpoints, and GitHub recommends HTTPS with SSL verification enabled.
IP allowlists can help, but do not treat them as a replacement for signatures. GitHub notes that its IP addresses can change and recommends periodically updating allowlists if you use them. Many SaaS providers publish delivery IP ranges, but ranges change and can be awkward behind proxies, gateways, and serverless ingress.
Use layers:
- HTTPS for transport
- signature verification for authenticity and integrity
- timestamp or delivery ID checks for replay and duplicate handling
- event filtering for scope
- authorization and tenant checks inside business logic
- logging and alerting for operations
One weak layer should not collapse the whole receiver.
Narrow the event subscription
Subscribe only to events the receiver actually handles.
GitHub makes this a best practice because fewer events reduce server work. It also reduces security and reliability surface area. Every extra event type is another payload shape, another action field, another test path, and another chance for a handler to process something it should ignore.
For each provider endpoint, document:
| Decision | Example |
|---|---|
| Event types | payment_intent.succeeded, not every payment event |
| Actions | opened and reopened, not every issue action |
| Tenants/accounts | Which account or organization can send to this endpoint |
| Receiver owner | The team that handles failures |
| Side effects | What the event is allowed to change |
If an event is not on the allowlist, return a safe response and do not run business logic. Depending on the provider, a 2xx for an ignored but valid event may be better than a retry loop. Decide that per provider and document it.
A safe receiver checklist
Use this before launching a webhook endpoint:
- Endpoint is HTTPS-only in production.
- Secret is configured per provider or endpoint.
- Secret is stored outside code and logs.
- Raw body is available before parsing.
- Signature verification runs before business logic.
- Invalid signatures are rejected and logged safely.
- Timestamp or freshness checks are implemented where provider supports them.
- Delivery ID or event ID is recorded.
- Duplicate delivery behavior is idempotent.
- Event type and action are allowlisted.
- Handler returns quickly and queues heavy work.
- Failure states are visible in an exception queue or dashboard.
- Redelivery or replay procedures are tested.
- Secret rotation procedure is documented.
- Sample valid, invalid, stale, duplicate, and malformed deliveries are tested.
If the endpoint cannot pass this list, it is not ready for production automation.
What to test
Do not test only the successful provider sample. Test the cases that hurt receivers in production:
| Test case | Expected result |
|---|---|
| Valid signature and known event | Delivery accepted and queued |
| Missing signature | Rejected before parsing side effects |
| Wrong secret | Rejected |
| Body changed after signing | Rejected |
| Old timestamp where provider supports timestamps | Rejected or quarantined |
| Duplicate delivery ID | Not processed twice |
| Unknown event type | Ignored safely |
| Known event with unexpected action | Ignored or quarantined |
| Slow downstream dependency | Receiver still acknowledges on time after queuing |
| Secret rotated | Old and new behavior follows the rotation plan |
The receiver should fail closed on authenticity and fail controlled on operations. That means forged requests do not run, while valid but unprocessable events land somewhere support can see.
Practical next step
Pick one production webhook receiver and trace the first 500 milliseconds of its request path. Find the exact moment it reads the raw body, verifies the signature, checks event type, records the delivery ID, and acknowledges the provider.
If you cannot point to each of those steps, the receiver is relying on luck. Fix the receiver path before adding more automations on top of it.
About the author
Elysiate publishes practical guides and privacy-first tools for data workflows, developer tooling, SEO, and product engineering.