A webhook endpoint is a public URL that changes data in your systems, and the only thing standing between it and a forged request is usually one shared secret. Payment, source control, messaging and SaaS providers push events to receivers that fulfil orders, trigger builds, grant access or update records, so an unverified or leaked webhook secret lets anyone on the internet drive those actions. This guide covers how webhook senders and receivers authenticate each other, the signature schemes used by GitHub, Stripe, Slack and Twilio and the vendor-neutral Standard Webhooks specification, how to store and rotate signing secrets without dropping events, and what to log and measure. It is written for application security engineers, platform teams and the owners of integrations who hold these secrets. The Stripe-specific API key side is covered in the sibling Stripe API Keys Security Guide.
Key takeaways
- A webhook secret is a non-human credential. It authenticates a machine (the sender) to another machine (your receiver), so it needs an owner, an inventory entry, a storage location and a rotation schedule like any other secret.
- Verify the signature over the raw body, every time. Every major provider signs the exact bytes it sent with HMAC-SHA256 or an asymmetric key; parsing or re-serialising the JSON before verification breaks the check, and skipping it lets an attacker forge events.
- Signatures without timestamps and IDs still allow replay. Check the signed timestamp against a tolerance (five minutes is the common default) and deduplicate on the delivery or event ID, because a captured valid request can otherwise be resent.
- Rotation needs an overlap window. Stripe and Slack keep the previous secret valid for up to 24 hours and Standard Webhooks signs with old and new keys in parallel; design receivers to accept multiple secrets so rotation is routine rather than an outage.
- Some providers reuse the account credential as the signing key. Twilio signs with the account auth token, so a leaked signing key is also a leaked API key, and rotating one rotates the other. Know which model each provider uses before you plan incident response.
How identity and access work in webhooks
A webhook inverts the usual API call: the provider is the client and your application is the server. Your receiver must answer two identity questions on every request: did this really come from the provider, and is it a fresh, unmodified message? TLS on your endpoint authenticates you to the provider, not the provider to you, so sender authentication has to come from something else. Providers use four mechanisms, often in combination.
- Shared-secret HMAC signature: the provider computes an HMAC over the payload (and usually a timestamp) with a per-endpoint secret and sends it in a header. This is the dominant model.
- Asymmetric signature: the provider signs with a private key and publishes the public key, so the receiver holds nothing secret. Standard Webhooks defines this as the
v1ascheme using ed25519. - Mutual TLS: the provider presents a client certificate at the TLS layer. Slack offers this as an alternative to signature checking, presenting a certificate with the SAN
platform-tls-client.slack.comthat your TLS terminator must match exactly. See the mTLS Guide. - Network origin: GitHub publishes its delivery addresses through the
GET /metaAPI and Stripe publishes a list of sending IPs. Allowlisting reduces noise but is a supporting control: it does not prove message integrity and the ranges change.
Provider signature schemes compared
| Provider or spec | Header | Algorithm and signed content | Replay protection | Signing key |
|---|---|---|---|---|
| GitHub | X-Hub-Signature-256 (value prefixed sha256=) | HMAC-SHA256 hex digest of the raw payload. Legacy X-Hub-Signature uses HMAC-SHA1 and exists only for compatibility. | No signed timestamp; use the unique X-GitHub-Delivery ID (a redelivery keeps the same value). | Per-webhook secret you choose |
| Stripe | Stripe-Signature (t= timestamp, v1= signatures) | HMAC-SHA256 over timestamp.payload. Only v1 is a valid live scheme; ignore other schemes to prevent downgrade. | Signed timestamp, library default tolerance 5 minutes; dedupe on event ID. | Per-endpoint whsec_ secret, different for test and live mode |
| Slack | X-Slack-Signature (v0=), X-Slack-Request-Timestamp | HMAC-SHA256 over v0:timestamp:body | Reject if timestamp differs from local time by more than 5 minutes | App signing secret (replaces the deprecated verification token) |
| Twilio | X-Twilio-Signature | HMAC-SHA1 over the full URL plus POST parameters sorted alphabetically; JSON bodies are covered by a bodySHA256 query parameter | Not timestamp-based; rely on idempotent handling | The account auth token (also an API credential) |
| Standard Webhooks | webhook-id, webhook-timestamp, webhook-signature | v1: HMAC-SHA256 over msg_id.timestamp.payload; v1a: ed25519 | Signed timestamp with a receiver-chosen window; webhook-id as idempotency key | whsec_ secret of 24 to 64 random bytes, base64; or whsk_/whpk_ key pair |
Shared secret or asymmetric key
Use HMAC when the provider only offers it, which is most of the time. Prefer an asymmetric scheme when you are the sender and many customers will verify your events: with ed25519 the receiver stores only a public key, so a breach of a receiver cannot be used to forge events to anyone else, and your own key store is the only place a signing key lives. Consider mTLS when the receiver sits behind infrastructure that already terminates client certificates and you want verification out of application code, accepting that it moves the trust decision to the TLS layer and its header forwarding.
Admin roles and privileged access
Whoever can configure a webhook can redirect events, read the signing secret and, on many platforms, trigger redeliveries. Treat webhook administration as privileged access on both sides of the integration.
- Provider-side configuration rights: in GitHub, repository and organisation admins can create and edit webhooks and set secrets; in Stripe, users who can reach the Webhooks tab in Workbench can reveal and roll endpoint secrets. Review who holds those roles and remove standing access where a deployment pipeline can do the job. See the GitHub and GitHub Actions Security Guide.
- Secret reveal is a sensitive event: a person who copies a signing secret from a dashboard can forge events indefinitely until it is rotated. Limit the number of people who ever see the value, and prefer flows where the secret goes straight from the provider API into a secrets manager.
- Endpoint URL changes: changing the payload URL silently sends business data (payment events, repository contents, chat messages) to a new destination. Alert on URL changes and require change approval.
- Receiver-side access: the people and pipelines that can read the receiver's secret store entry, or change its verification code, can disable the control. Code review for the verification path and least-privilege policies on the secret path (NIST SP 800-53 AC-6) matter as much as the provider console.
Integrations, API tokens and service accounts
Webhook secrets rarely travel alone. A typical receiver also holds an outbound API key to fetch the full object (Stripe thin events require a follow-up API call, for example), and sometimes credentials for downstream systems it updates. Map those relationships before you decide how to store and rotate anything.
| Credential | Who holds it | Impact if leaked | Where to store | Rotation trigger |
|---|---|---|---|---|
| Webhook signing secret (HMAC) | Provider and receiver | Forged events accepted as genuine: orders fulfilled, access granted, builds triggered | Secrets manager, injected at runtime; never in code or the payload URL | Scheduled, staff change, suspected exposure, repository leak |
| Asymmetric signing private key | Sender only | Forgery to every receiver that trusts the public key | KMS or HSM-backed key store | Scheduled, key compromise |
| Auth token used as signing key (Twilio model) | Provider and receiver | Forged webhooks and full API access to the account | Secrets manager with tight read scope | Any exposure: rotate as an API credential incident |
| Outbound API key used by the receiver | Receiver | Direct API abuse, data access, refunds or payouts | Secrets manager, restricted key scopes | Per provider key policy; see the API Key Management Guide |
- One secret per endpoint and environment: Stripe already issues different secrets for test and live mode and per endpoint; follow the same pattern for every provider so a leaked staging secret cannot forge production events.
- Keep the payload URL free of secrets: GitHub's guidance is explicit that API keys and credentials do not belong in the payload URL. URLs end up in proxy logs, provider delivery logs and screenshots.
- Treat the receiver as a service account: give the verification service its own identity in the secrets manager, scoped to the webhook secrets it needs. See the Secrets Management Guide and the Cloud Secrets Managers Guide.
- When you are the sender: customer-supplied webhook URLs are a server-side request forgery surface. The Standard Webhooks specification recommends sending through a filtering proxy that blocks internal addresses and running webhook workers in a subnet with no access to internal services; the OWASP SSRF cheat sheet adds disabling redirects, validating resolved IPs to defeat DNS rebinding and blocking cloud metadata endpoints such as 169.254.169.254. See the SSRF Prevention Guide.
Verifying deliveries correctly
Use the provider's official library where one exists; otherwise implement these steps in order.
- Capture the raw body before any framework parsing. Stripe states that any manipulation of the raw body causes verification to fail, and GitHub warns that proxies or load balancers that alter the payload or headers break the check. Disable body-rewriting middleware on the webhook route.
- Select the right header and scheme. Use
X-Hub-Signature-256, not the SHA-1 header, for GitHub; accept onlyv1for Stripe; reject requests where the signature header is missing rather than treating absence as "unsigned but allowed". - Rebuild the signed string exactly. Concatenate the timestamp, ID and body in the provider's order and separator (
.for Stripe and Standard Webhooks,:for Slack). - Compute the HMAC and compare in constant time. GitHub, Stripe and Slack all require a constant-time comparison such as Node.js
crypto.timingSafeEqualor Pythonhmac.compare_digest, never==. - Accept any one of several valid signatures. During rotation Stripe and Standard Webhooks send one signature per active secret; verification should succeed if any of them matches a secret you hold.
- Check freshness. Reject timestamps outside the tolerance. Stripe warns that a tolerance of 0 disables the recency check entirely, and recommends NTP so server clock drift does not cause false rejections.
- Deduplicate. Record processed event or delivery IDs (
webhook-id, Stripe event ID,X-GitHub-Delivery) and skip repeats. Stripe does not guarantee ordering and itscreatedfield has one-second resolution, so do not use timestamps to detect duplicates. - Acknowledge fast, process asynchronously. GitHub ends the connection if there is no 2xx within 10 seconds; queue the verified event and return immediately, then validate the event type and action before acting on it.
A valid signature proves origin, not authorisation: check that the referenced account or repository belongs to the tenant the endpoint serves, and for high-impact actions such as payouts or access grants re-fetch the object from the provider API rather than trusting payload fields, the pattern Stripe's thin events are built around.
Rotating webhook secrets without downtime
| Provider | Rotation mechanism | Overlap window | Receiver requirement |
|---|---|---|---|
| Stripe | Roll secret in the Webhooks tab of Workbench | Expire immediately or delay up to 24 hours; one signature per active secret during overlap | Accept a list of secrets; remove the old one after expiry |
| Slack | Regenerate signing secret in the app settings | Slack documents a 24-hour overlap for the client secret; plan the signing secret change as a coordinated deploy | Deploy the new secret promptly after regeneration |
| GitHub | Edit the webhook and set a new secret | No documented overlap: the new secret applies to subsequent deliveries | Accept old and new secrets briefly, then redeliver any failed deliveries |
A safe rotation sequence: (1) add the new secret to the receiver's accepted list and deploy; (2) roll the secret at the provider, using its overlap option where available; (3) watch verification failure rates and the provider's delivery log; (4) remove the old secret from the receiver; (5) redeliver anything that failed. Rollback is simply re-adding the previous secret while the provider overlap is still open, which is why step 1 comes first. Automate this as a runbook; see the Secrets Rotation Runbook Template.
Logging and detection
- Verification outcomes: log every rejection with reason (missing header, bad signature, stale timestamp, duplicate ID), source IP, endpoint and provider. A burst of bad signatures from a non-provider address is probing; a sudden rise from provider addresses usually means a secret mismatch after rotation or a body-altering proxy.
- Never log secrets or signatures: log a key identifier so you can tell which secret matched.
- Provider audit trails: collect GitHub organisation audit log events for webhook creation and changes, and Stripe endpoint changes and secret rolls, into the SIEM. Alert on new endpoints, URL changes and secret reveals outside a change window.
- Secret exposure: scan repositories, CI logs and container images for
whsec_prefixes and provider secret formats. See the Git Secret Scanning Guide.
Metrics that show the control works
- Verification coverage: percentage of webhook routes that enforce signature, timestamp and deduplication checks (target 100%).
- Secret age: days since last rotation per endpoint, against your policy (for example 90 or 180 days).
- Rejected request rate: rejections per thousand deliveries, split by reason, with an alert threshold.
- Owner coverage: percentage of webhook endpoints with a named owner and inventory record.
- Time to rotate after exposure: hours from a detected leak to the old secret being invalid.
Hardening baseline
- Inventory first: list every inbound webhook (provider, endpoint URL, secret location, owner, actions triggered) and every outbound webhook you send. The Service Account Inventory Template can hold these records.
- Pilot verification in log-only mode: for endpoints that do not verify today, compute and log the verification result without rejecting for one to two weeks, fix false failures (usually raw body handling), then enforce. Roll back by switching the route back to log-only, not by removing the code.
- Move secrets into a manager: replace hard-coded and environment-file secrets with runtime retrieval; generate high-entropy secrets (Standard Webhooks specifies 24 to 64 random bytes) where you choose them.
- Enforce transport: HTTPS only with valid certificates. Stripe accepts TLS 1.2 and 1.3 only, and Twilio will not connect to self-signed certificates; keep GitHub's SSL verification enabled.
- Subscribe to the minimum events: both GitHub and Stripe recommend it.
- Add network filtering as a second layer: provider IP allowlists at the edge, refreshed automatically from
GET /metaor the provider list. - Schedule rotation: automate it with the overlap sequence above and record each rotation.
Small vs large organisations: a small team with a handful of integrations gets most of the value from official verification libraries, a secrets manager and a yearly rotation. A large organisation with hundreds of endpoints should centralise receipt behind a webhook gateway that verifies, deduplicates and queues events for internal consumers, so verification logic lives in one reviewed place and internal services never see provider secrets.
Framework mapping
| Concern | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | ATT&CK v20 |
|---|---|---|---|
| Authenticating the sending service | IA-9 Service Identification and Authentication | PR.AA-03 | T1078 Valid Accounts (forged or stolen credentials) |
| Secret generation, storage and rotation | IA-5 Authenticator Management, SC-12 Cryptographic Key Establishment and Management | PR.AA-01 | T1552.001 Credentials In Files |
| Integrity and freshness of each message | SC-8 Transmission Confidentiality and Integrity, SC-23 Session Authenticity | PR.DS-02 | T1557 Adversary-in-the-Middle |
| Validating payload and referenced objects | SI-10 Information Input Validation, AC-3 Access Enforcement | PR.AA-05 | |
| Logging verification failures and config changes | AU-2 Event Logging, AU-12 Audit Record Generation | DE.CM-09 |
In ISO/IEC 27001:2022 Annex A the closest controls are 5.17 Authentication information, 8.24 Use of cryptography and 8.15 Logging.
Common misconfigurations
- Verification behind a feature flag that is off: code paths such as "if a secret is configured, verify" (the pattern in Stripe's own sample) silently accept everything when the environment variable is missing. Fail closed when the secret cannot be loaded.
- Comparing with string equality: a non-constant-time comparison leaks timing information about the expected signature. Every provider document reviewed here calls for constant-time comparison.
- Tolerance set to zero or very large: setting Stripe's tolerance to 0 disables the freshness check, and a tolerance of hours makes replay practical. Keep it near five minutes and fix clock drift with NTP instead.
- Accepting legacy schemes: verifying GitHub's SHA-1 header or Stripe's
v0test signature in production reopens downgrade paths. - Secrets committed with the handler: signing secrets copied into source, sample configs or CI variables printed in logs. Credentials published by mistake can stay valid for a long time: in the Home Depot Token Exposure 2025 a GitHub token published online stayed live for more than a year. A webhook secret leaked the same way keeps working until someone rotates it.
- One shared secret across environments and endpoints: a single value reused everywhere means one leak forges events to every receiver and rotation needs a coordinated change across all of them.
Practitioner checklist
- Inventory every inbound and outbound webhook with owner, secret location and triggered actions.
- Verify signatures over the raw body with the provider library or a constant-time HMAC check on every route.
- Enforce a timestamp tolerance of about five minutes and deduplicate on the event or delivery ID.
- Store each signing secret in a secrets manager, one secret per endpoint and environment, never in the payload URL.
- Build receivers that accept multiple secrets and rotate on a schedule using the provider overlap window.
- Fail closed if the secret cannot be loaded; reject unsigned or legacy-scheme requests.
- Log verification failures by reason and alert on webhook URL changes and secret reveals.
- Re-fetch objects from the provider API before high-impact actions and check tenant ownership.
- When sending webhooks, route through an SSRF-filtering proxy and consider ed25519 signatures.
Standards and references
- Standard Webhooks: Specification
- GitHub Docs: Validating webhook deliveries
- GitHub Docs: Best practices for using webhooks
- Stripe Docs: Receive Stripe events in your webhook endpoint
- Slack Developer Docs: Verifying requests from Slack
- Twilio Docs: Webhooks security
- OWASP: Server-Side Request Forgery Prevention Cheat Sheet
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: Secrets Management Guide · Stripe API Keys Security Guide · SaaS API Token Security Guide · API Key Management Guide · Secrets Rotation Runbook Template