Back to Blog

Secure Webhook Receiving: Verify Signatures, Stop Replays, Handle Retries

September 19, 2026 10 min read
Secure Webhook Receiving: Verify Signatures, Stop Replays, Handle Retries
Last updated:

Editorial slot: September 18, 2026. Published on September 19, 2026 after review.

A webhook receiver should treat every incoming request as untrusted until it has verified the provider's signature against the exact bytes received. Then it should reject stale requests, record a delivery identity before any side effect, and process the event asynchronously. That order is the practical answer to spoofed webhooks, replay attempts, retries, and duplicate deliveries.

TLS is necessary for transport, but it does not prove that the application request came from the expected provider. An endpoint can still receive a fabricated POST from anywhere that can reach it. A valid signature binds the provider's signing secret to the provider-defined input, usually the original body and sometimes a timestamp or other headers. It establishes origin and integrity under that provider's protocol. It does not establish that the event is appropriate for every business action your application might take.

Four evidence stages for Raw request, Verify signature, Check freshness, Record event ID

Keep the original request bytes

Read and retain the request body before JSON parsing, character conversion, reserialization, decompression changes, or framework middleware changes it. Verify using that retained byte sequence and the exact headers required by the provider. Parse JSON only after verification succeeds.

This is a common source of both false alarms and unsafe workarounds. A developer sees verification fail, parses JSON, serializes it again, and tries to sign the new text. That creates a different message. Whitespace, key order, escaping, Unicode handling, and line endings can all change the signed input. Stripe explicitly requires the raw body and says manipulation makes verification fail; Slack similarly says to obtain the raw body before deserialization. Stripe webhook documentation Slack request-verification documentation

Route the webhook endpoint before generic body-parsing middleware, or configure that route to expose a raw buffer. Make one deliberate copy only if later components need it. Protect logs too: a raw body can contain personal data, tokens, or business records. Log a request correlation value, provider delivery ID, verification outcome, and a carefully selected error code rather than the full payload by default.

Validate the provider protocol exactly

Do not invent a universal HMAC recipe. Each provider defines header names, signature versions, encoding, signing input, algorithms, and sometimes multiple signatures. Use its official library where it fits, or implement and test the documented protocol exactly.

Parse a signature header by the provider's rule. A header can contain several schemes. Ignore an extra scheme only where the protocol permits it, require at least one matching signature for a supported scheme, and reject a request when none matches. Stripe test events can include a valid v1 signature beside deliberately invalid v0, so a receiver must verify v1 rather than reject the entire header because v0 is present. Stripe signature-verification guidance

For example, GitHub describes X-Hub-Signature-256 as an HMAC SHA-256 digest over the payload with the webhook secret, and advises a constant-time comparison rather than a normal equality operator. It also advises UTF-8 handling where an implementation specifies an encoding. GitHub webhook validation documentation A constant-time comparison is a small, appropriate control after the expected signature has been computed; it is not a substitute for verifying the correct bytes, algorithm, and secret.

Reject malformed required headers, missing secrets, invalid encodings, unsupported required protocols, and failed comparisons before business logic. Do not reject a supported signature that validates merely because the same header contains an extra scheme the provider permits receivers to ignore. Return a provider-appropriate response without revealing whether a secret or field was nearly valid. Keep detailed diagnostics in protected logs.

Make time part of replay defense

A captured valid request can be sent again. If a provider signs a timestamp as part of its signing input, verify the signature first and then apply a bounded freshness window against a synchronized server clock. Reject timestamps that are malformed, too far in the past, or implausibly far in the future. The precise window is a provider and availability decision, not a magic number.

Stripe describes a timestamp included in its signature header and a five-minute default tolerance in its libraries. It warns that a tolerance of zero disables the recency check and notes that retries receive a new timestamp and signature. Slack's verification guide also uses a five-minute example and says the signed timestamp protects against replay attacks. Stripe replay guidance Slack replay guidance

A timestamp window limits how long a captured request remains useful. It does not make processing exactly once. A valid request may arrive more than once in the window, and legitimate retries can arrive later with a new signed timestamp. Clock monitoring matters because clock drift can reject good events or admit old ones longer than intended.

Not every webhook protocol signs a timestamp. In that case, an arbitrary delivery header is not complete replay protection. Determine which fields are in authenticated input. GitHub documents HMAC over the payload, so a delivery header outside that input could be changed while the valid body signature remains unchanged. Treat an unsigned delivery header as correlation metadata only. Bind deduplication to authenticated content or authoritative provider state, and use durable business idempotency. This is an implementation inference from GitHub's documented signed input. GitHub webhook validation documentation

Idempotency is the control for duplicate effects

Design the receiver for at-least-once delivery. Network failures, timeouts, provider retries, queue retries, and operator replays can all result in the same business event reaching the handler more than once. Do not use arrival time or a provider's event creation time as the duplicate key. Events can be out of order, and separate events can share a time value.

Use a provider-defined immutable event or delivery identity when one exists, after determining whether it is bound to authenticated content. Store a scoped receipt with a uniqueness constraint, then commit durable received state and a work item before returning a successful acknowledgement. A conflicting record is not automatically complete: resume or recover received and processing work; acknowledge without new work only when prior work is completed or safely terminal. Stripe recommends recording processed event IDs and says endpoints can receive the same event more than once. Stripe duplicate-event guidance

Some providers can emit distinct event envelopes about the same underlying object. In that case, an event ID alone may not satisfy the business rule. Define a second domain key only when the provider's event semantics justify it, such as (provider object ID, event type, supported version); do not suppress legitimate state changes merely because they mention the same object. When order matters, retrieve current state from the authoritative API or make the business transition conditional on an expected state.

A queue does not remove this requirement. It separates quick receipt from slower work, but queue delivery is commonly at least once. A worker needs recoverable received, processing, completed, and retryable-failure states, with an expiring lease or equivalent crash recovery. Commit local receipt, state transition, and a transactional-outbox message together where possible. That durable local handoff cannot atomically commit an external payment, email, provisioning request, or access change.

For an external effect, use a stable downstream idempotency key when that API supports one and persist it with the work item. If a worker crashes after the external system accepts an operation but before local completion is recorded, retry or query the downstream system with that same key, then reconcile local state. If no idempotency key or queryable result exists, document the remaining duplicate-risk limitation and use reconciliation, compensating controls, or a different workflow. A uniqueness constraint and an outbox alone do not guarantee one external effect.

Rotate signing keys with an overlap plan

A secret is a credential. Keep it in a secret manager or equivalent protected configuration, scope it to the endpoint, and do not put it in source control or ordinary request logs. GitHub specifically says to store a webhook secret securely and not hardcode or commit it. GitHub secret-storage guidance

Rotation is provider-specific. If a replacement secret is pre-provisioned, install it in the receiver before activating it at the sender. If the provider generates the replacement only when rotation begins, start that provider action, obtain the new secret through the approved channel, and deploy it promptly while documented overlap keeps the old secret usable. During overlap, accept a signature that validates against an authorized active secret and log only a nonsecret key label.

Retain the old secret only for documented overlap plus a narrowly justified in-flight allowance, if the protocol needs one. Do not keep it for the entire retry horizon simply because delivery can retry: Stripe says retries receive a new signature and timestamp. Remove it promptly after validation. If compromise is suspected, revoke or expire the secret immediately and use the incident process; do not wait for normal overlap.

Stripe provides a concrete example: rolling a signing secret can immediately expire the previous value or retain it for up to 24 hours, and Stripe generates a signature for each active secret in that interval. That behavior is provider-specific. Stripe signing-secret rotation guidance

Receiver decision Validation evidence Remediation when it fails
Raw body is preserved Official fixture verifies before JSON parsing Capture the raw body on this route
Signature parsing matches Valid fixture passes; altered bytes fail; Stripe v1 plus test v0 passes Parse permitted schemes and require one matching supported signature
Replay checks fit protocol Fresh signed fixture passes and stale one fails; no-timestamp tests identify authenticated fields Do not use an unsigned delivery header as replay proof
Receipt recovery is safe A duplicate during unfinished work resumes; completed work is not repeated Keep durable received, processing, and completed states with recovery
External effect is controlled Crash after sandbox or stub acceptance and before local completion reconciles with the same downstream key Query or retry with that key; document residual risk if unavailable
Rotation is safe Old and new fixtures pass only during documented overlap; old then fails Follow provider generation order; revoke immediately if compromised

Stop conditions and a safe response

Stop immediately before parsing for business use or creating work when the body cannot be read intact, a required header is absent, no valid supported signature is present, the signature fails, or a signed timestamp is outside policy. Where the protocol permits extra schemes, their presence is not a stop condition if a supported signature validates. Stop before a side effect when the event type is not allowlisted, scope is unknown, or business preconditions fail. An existing receipt is a decision point: resume unfinished work, acknowledge completed work, and reconcile uncertain external work rather than blindly skipping it.

A signature success should lead to a narrow allowlist of event types and schemas. Validate required fields, tenant mapping, and expected object state after authentication. For high-impact actions, obtain the authoritative object through the provider API when the protocol and latency model permit it. That protects against stale payload assumptions and keeps a webhook from becoming a blanket authorization channel.

Pre-production checklist

  • Capture the body as bytes before parsers and verify an official provider fixture.
  • Test missing, malformed, mismatched, and changed signatures; where applicable, test valid v1 beside ignorable test v0.
  • Test stale and future signed timestamps. Without a signed timestamp, document authenticated fields and prove an unsigned delivery header is not replay proof.
  • Submit a valid delivery twice and while prior work remains unfinished; prove recovery does not lose or repeat local work.
  • Simulate a crash after a sandboxed or stubbed external system accepts an effect and before local completion; reconcile using the same downstream idempotency key.
  • Test out-of-order events, retries, documented rotation overlap, old-key retirement, and immediate compromise revocation.
  • Alert on verification failures, stale-request rejects, duplicate receipts, and key-label distribution changes.

For broader endpoint review, see API security testing and Web security.

FAQ

Does HTTPS remove the need for webhook signatures?

No. HTTPS protects the connection in transit, while a webhook signature lets the receiver validate the provider-defined signed request. Keep both controls and follow the provider's documented verification method.

Should a valid signature let every event update our records?

No. A valid signature authenticates the delivery under the provider protocol. The receiver still needs an event allowlist, schema validation, tenant mapping, idempotency, and business-state checks.

Can we disable the timestamp tolerance because the signature is valid?

No. A signature can remain valid for a captured request. When a provider signs a timestamp, a nonzero freshness window is part of replay defense. Combine it with durable idempotency because legitimate retries and duplicates can still occur.

Vid Grosek

Vid Grosek

Ethical Hacker & Penetration Tester

I help Slovenian companies discover security vulnerabilities before attackers do. With 18+ years of experience in cybersecurity.

All Posts

Comments

No comments yet. Be the first!

Leave a Comment

Enjoyed this article?

Subscribe to the newsletter for monthly security insights.

Subscribe