Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article8 min readViews0

Webhook Signature Is Valid: How to Stop a Replay of a Correct Request

We analyze a replay attack on a correctly signed webhook: signing the timestamp and nonce, verifying the time window, and showing why worker local memory loses protection after a restart.

Comments 0

Two sealed envelopes at a mechanical gateway with clocks: one passed, the replay stopped by a transparent barrier
In this article

A correct HMAC signature confirms request integrity, but alone it does not prevent an attacker from resending an already intercepted packet. Protection requires three linked checks: a signed creation timestamp, a short window of acceptable age, and a signed one-time identifier nonce that the receiving side stores atomically. The signature proves the message is genuine. It does not prove the mail carrier did not deliver it a second time.

What Exactly Protects Against Replay

In RFC 9421 on HTTP Message Signatures, the created and expires parameters define time boundaries, while a nonce can ensure request uniqueness. The document explicitly warns: the verifying party must limit signature age and track unique nonces; otherwise, a correctly signed message can be replayed.

In Slack's documentation on validating incoming requests, the timestamp is included in the signed string, and requests older than five minutes are recommended to be rejected. This is an example of a specific provider protocol, not a universal value for any integration: each provider selects its own window based on delivery latency, retries, and risk requirements.

A timestamp limits the attack window but does not exclude a replay within that window. A nonce closes this gap if it is also signed and stored in a shared store. Business idempotency by event_id remains necessary: replay protection secures the transport input, while idempotency prevents reprocessing an already accepted event.

Secure Training Model

The model was verified on November 3, 2026, on CPython 3.12.14, using only the standard library. It does not access the network, files, or a database, nor does it depict an implementation of RFC 9421 or the Slack protocol. Time is specified in whole seconds, so the result is fully reproducible.

  • The secret is a training constant training-secret; in production code, the secret is retrieved from a secure store and never logged.

  • The request body remains constant: event_id evt-77 and stock remaining 8.

  • The current time NOW is 1000, and the MAX_AGE window is 300 seconds.

  • The signature includes created, nonce, and the raw bytes of the body in a fixed order.

  • The seen_nonces set is intentionally stored only in process memory: this allows observing a failure after a restart.

The check first discards timestamps that are too old or in the future, then compares the HMAC using compare_digest, and only after a successful signature verification reserves the nonce. Otherwise, unsigned requests could fill the storage with foreign values.

import hashlib import hmac SECRET = b"training-secret" BODY = b'{"event_id":"evt-77","stock":8}' NOW = 1_000 MAX_AGE = 300 def signature(created: int, nonce: str, body: bytes) -> str: signed = str(created).encode() + b"." + nonce.encode() + b"." + body return hmac.new(SECRET, signed, hashlib.sha256).hexdigest() class ReplayGuard: def __init__(self) -> None: self.seen_nonces: set[str] = set() def verify( self, *, now: int, created: int, nonce: str, body: bytes, supplied_signature: str, ) -> str: if created > now or now - created > MAX_AGE: return "stale" expected = signature(created, nonce, body) if not hmac.compare_digest(expected, supplied_signature): return "bad_signature" if nonce in self.seen_nonces: return "replayed" self.seen_nonces.add(nonce) return "accepted" created = 900 nonce = "n-001" valid_signature = signature(created, nonce, BODY) guard = ReplayGuard() print("first=" + guard.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, )) print("same_request=" + guard.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, )) old_created = 600 print("old_but_correctly_signed=" + guard.verify( now=NOW, created=old_created, nonce="n-old", body=BODY, supplied_signature=signature(old_created, "n-old", BODY), )) changed_body = b'{"event_id":"evt-77","stock":80}' print("changed_body=" + guard.verify( now=NOW, created=created, nonce="n-002", body=changed_body, supplied_signature=valid_signature, )) guard_after_restart = ReplayGuard() print("after_volatile_store_restart=" + guard_after_restart.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, ))

The startup command in a separate training directory:

python3 webhook_replay_lab.py

The actual output of two consecutive runs matched; the exit code was 0 in both cases:

first=accepted same_request=replayed old_but_correctly_signed=stale changed_body=bad_signature after_volatile_store_restart=accepted

Scenario

Expected validation

Observed result

First valid request

Accept

accepted

Same request and nonce

Reject replay

replayed

Valid signature, age 400 seconds

Reject by age

stale

Body changed, signature preserved

Reject signature

bad_signature

New process without saved nonces

Show loss of protection

accepted

Verification order on the receiving side

  1. Parse headers and verify the format of created and nonce without trusting them until the signature is validated.

  2. Reject created timestamps from the distant future and requests older than the selected window; server clocks must be synchronized.

  3. Calculate the signature using the same source bytes and the same covered fields, then compare the values using a secure function.

  4. After a successful signature check, atomically insert the nonce into the shared store with a unique constraint. A conflict indicates a replay.

  5. Immediately after the successful atomic insertion of the nonce, pass the event to the business handler. Retain the nonce record for at least the duration of the valid request window; remove it via TTL only after the window expires, without waiting for the record to be deleted before processing starts.

Here is the catch: the usual sequence of "check availability, then insert" allows a race condition between two workers. Both may succeed in seeing an empty slot. A single atomic operation is required—for example, inserting a row with a unique index or an equivalent command for a shared cache. The specific mechanism depends on the infrastructure already in use; a Python set in this single-process model does not prove atomicity for a shared store.

Why single-process memory is unsuitable

The last test line creates a new ReplayGuard. The signature, timestamp, and nonce remain unchanged, but the new object knows nothing about the first acceptance and returns 'accepted'. The same will happen if the PHP process restarts or if a replay lands on a different worker, provided the nonce list is local.

Practical criterion: two parallel workers and the process after a restart must see the same nonce record throughout the entire window. This should be verified on an isolated staging environment using the actual database or cache; the model presented shows the requirement but does not replace such testing.

Boundary with event idempotency

Mechanism

Which replay is blocked

Key

Replay protection

Replaying the same signed HTTP request within the allowed window

Signed nonce

Idempotency

Repeating a business operation after an accepted event

event_id or operation_id

Version check

Outdated change after a newer state

Version or ETag

The event_id cannot be considered a full replacement for a signed nonce. The provider may legally re-sign the same event with a new nonce after a network failure. The replay protection will pass, but the business handler must recognize the event_id and return the previous result without a second charge, order, or inventory adjustment.

What to check in the 1C-Bitrix integration

For 1C-Bitrix: Site Management, this template applies to a custom handler for incoming callbacks or webhooks, not to the standard 1C exchange as a universal product function. Before implementation, record the site edition and version, the integration module, data direction, SKU and field format, handler permissions, and the documented protocol of the external system in the integration description. The example above does not modify the catalog nor does it confirm compatibility with a specific module.

  • Check which fields the supplier actually includes in the signature and whether they send a timestamp and nonce.

  • Align the window with the maximum legitimate delay and the retry policy.

  • Use a shared atomic store for all PHP processes and nodes.

  • After transport verification, apply separate idempotency by event or operation identifier.

  • Log the reason for rejection and the technical identifier, but not the secret, full signature, or personal body data.

Readiness Criteria

Protection works if the first valid request is accepted, its exact copy is rejected even on a different worker, an outdated valid signature fails, body modification breaks the signature, and restarting the process does not clear the nonce history before the selected window expires. If the last point is not met, the signature remains valid, but replay remains possible.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.