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 Secret Rotation: Changing Keys Without Stopping Acceptance

Practical guide: accept K1 and K2 only within a limited window, bind the signature to key_id and timestamp, verify HMAC via compare_digest, and disable the old key based on seven test results.

Comments 0

Webhook verification mechanism with two keys for controlled secret rotation
In this article

Webhook secret rotation is safer when performed through a limited overlap window: the new key K2 becomes active at time t1, the old key K1 stops being accepted at t2, and the verifier selects the key by key_id while separately validating the signature timestamp. Continuously accepting both secrets is not allowed: otherwise, the old key is formally replaced but still effectively grants access.

Below is a sample HMAC-SHA256 model for a webhook receiver. I verified it on October 23, 2026, using CPython 3.12.14 without network access or external packages. This is not a ready-made scheme for a specific service or 1C-Bitrix module: header formats, retry logic, and acceptable time windows must be taken from the sender's contract.

What HMAC Confirms

HMAC binds the message to a shared secret and allows integrity and authenticity verification within that secret. The construction is described in RFC 2104. The standard itself recommends periodically updating keys but does not specify a universal overlap duration.

The signature does not encrypt the body, does not replace TLS, and does not prove that a business event must be applied again. First, verify the signature and its timestamp, then check the event identifier, order status, and idempotency rules.

Input contract for the sample model

Field

Meaning

What we reject

key_id

Identifier of the configured K1 or K2 secret

Unknown identifier

timestamp

Time included in the signed string

Too old or noticeably in the future

signature

HMAC-SHA256 in hex

Incorrect length or mismatch

raw_body

Original body bytes

Modified bytes

The signature string in the model consists of an ASCII timestamp, a dot, and the raw bytes of the body. The transformed JSON no longer works: even without changing the meaning, the order of keys or spaces alters the bytes.

signed = str(timestamp).encode("ascii") + b"." + body

Two keys must have different windows

K1 is valid until t2; K2 is valid starting from t1. Between t1 and t2, both keys are acceptable: this is the overlap window for the recipient's deployment, the sender's switch, and the rollback verification. After t2, the K1 signature is rejected even if it is mathematically correct.

Interval

K1

K2

Purpose

t0 ≤ t < t1

Accept

Reject

Before switch

t1 ≤ t < t2

Accept

Accept

Controlled overlap

t2 ≤ t < t3

Reject

Accept

Old key disabled

Training rotation timeline: K1 is valid until t2, K2 is valid starting from t1, with both keys acceptable between t1 and t2
Training model: K1 and K2 overlap only between t1 and t2; after t2, the old key is no longer accepted.

The HTTP Message Signatures standard defines metadata fields keyid, created, and expires. This serves as a useful reference for separating the key identifier from the signature timestamp, but it does not guarantee that any arbitrary webhook implements RFC 9421; the actual format is defined by the provider.

The verifier selects the key rather than iterating through history

def verify(*, key_id, signature_hex, timestamp, body, received_at, keys): key = keys.get(key_id) if key is None: return "unknown_key" signed_at = datetime.fromtimestamp(timestamp, tz=timezone.utc) if signed_at > received_at + timedelta(seconds=30): return "timestamp_in_future" if received_at - signed_at > timedelta(minutes=5): return "stale_timestamp" if not (key.not_before <= received_at < key.not_after): return "key_outside_window" expected = sign(key.secret, timestamp, body) if not hmac.compare_digest(expected, signature_hex): return "bad_signature" return "accepted"

For comparison, hmac.compare_digest() was used: Python documentation recommends it over the equality operator to reduce the risk of timing analysis. The secret and signature are not logged; only the key_id, result, and request identifier are sufficient.

Time validation occurs before HMAC. This ensures an expired message does not receive a "signature valid" status or enter business processing. The 30-second future allowance and maximum 5-minute limit are parameters of the training model, not universal values.

Seven reproducible checks

The following Python 3.12 command runs the complete test in memory: it uses no files or network requests, and the keys are for this example only.

python3 - <<'PY' from dataclasses import dataclass from datetime import datetime, timedelta, timezone import hashlib import hmac @dataclass(frozen=True) class Key: secret: bytes not_before: datetime not_after: datetime def sign(secret, timestamp, body): signed = str(timestamp).encode("ascii") + b"." + body return hmac.new(secret, signed, hashlib.sha256).hexdigest() def verify(*, key_id, signature_hex, timestamp, body, received_at, keys): key = keys.get(key_id) if key is None: return "unknown_key" signed_at = datetime.fromtimestamp(timestamp, tz=timezone.utc) if signed_at > received_at + timedelta(seconds=30): return "timestamp_in_future" if received_at - signed_at > timedelta(minutes=5): return "stale_timestamp" if not (key.not_before <= received_at < key.not_after): return "key_outside_window" expected = sign(key.secret, timestamp, body) if not hmac.compare_digest(expected, signature_hex): return "bad_signature" return "accepted" t0 = datetime(2026, 10, 23, 7, 0, tzinfo=timezone.utc) t1 = t0 + timedelta(minutes=10) t2 = t0 + timedelta(minutes=20) t3 = t0 + timedelta(minutes=30) keys = { "K1": Key(b"1" * 32, t0, t2), "K2": Key(b"2" * 32, t1, t3), } body = b'{"event_id":"lab-1","order_id":"lab-order"}' before = t0 + timedelta(minutes=5) during = t0 + timedelta(minutes=15) after = t0 + timedelta(minutes=21) def check(name, key_id, signed_at, received_at, expected, *, changed=False): timestamp = int(signed_at.timestamp()) signing_key = keys["K2"] if key_id == "K2" else keys["K1"] signature = sign(signing_key.secret, timestamp, body) result = verify( key_id=key_id, signature_hex=signature, timestamp=timestamp, body=body + b" " if changed else body, received_at=received_at, keys=keys, ) if result != expected: raise AssertionError((name, expected, result)) print(f"{name}: {result}") check("old_before_overlap", "K1", before, before, "accepted") check("old_during_overlap", "K1", during, during, "accepted") check("new_during_overlap", "K2", during, during, "accepted") check("old_after_cutoff", "K1", after, after, "key_outside_window") check("tampered_body", "K1", during, during, "bad_signature", changed=True) check("stale_message", "K1", during - timedelta(minutes=6), during, "stale_timestamp") check("unknown_key", "K3", during, during, "unknown_key") print("all 7 checks passed") PY

old_before_overlap: accepted old_during_overlap: accepted new_during_overlap: accepted old_after_cutoff: key_outside_window tampered_body: bad_signature stale_message: stale_timestamp unknown_key: unknown_key all 7 checks passed

Scenario

Expectation

Observation

K1 before overlap

Accept

accepted

K1 during overlap

Accept

accepted

K2 in overlap

Accept

accepted

K1 after t2

Reject

key_outside_window

Modified body

Reject

bad_signature

Message older than 5 minutes

Reject

stale_timestamp

Unknown key_id

Reject

unknown_key

The script exited with code 0 and compared all seven lines against the expected list. It confirms only the key selection logic, the window, and the HMAC on synthetic data. The network, proxy, secret storage, specific provider, and parallel processing were not tested here.

Rotation order without heroics

  1. Record the signature format, duration of repeated deliveries, allowable clock skew, and the method to specify key_id for a specific sender.

  2. Create K2 in the standard secrets store. Do not copy it to a ticket, code, container image, or log.

  3. Deploy the receiver with K1 and K2, but allow K2 only from t1, and K1 only until t2.

  4. At t1, switch the sender to K2 and check the accepted metrics by key_id, bad_signature, unknown_key, and timestamp latency without persisting the signature.

  5. Before t2, perform a controlled rollback of the sender to K1 and back, if the contract and environment allow safe verification.

  6. At t2, stop accepting K1. After the agreed diagnostic period, remove the old secret from the active configuration and verify that reverting to the old version does not re-enable it.

How to choose the overlap window

  • The window must cover the standard deployment and the documented rollback procedure.

  • If a supplier repeats old deliveries beyond the window, decide in advance how they will be classified after t2; a silent extension of K1 cancels rotation.

  • If key_id is absent, use only the explicitly defined small set of current keys and remove K1 by time. Do not iterate through the secret archive.

  • Check clock synchronization and record signed_at and received_at without the body and secrets.

  • In case of suspected compromise, the usual window may be unacceptable: disabling the key and analyzing the consequences is more important than seamless operation.

Signature and event replay are separate checks

The article «Webhook signature mismatch: check the original JSON bytes» details why HMAC is calculated on the original body. Here the result is different: a controlled transition between two secrets.

After accepting the signature, business-level deduplication remains. The guide «A repeated webhook must not create a second order» shows UNIQUE constraints, transactions, and retry logic after failure. A valid signature does not replace these measures.

Readiness criteria

Rotation is ready when the team can demonstrate four facts: K2 is accepted after t1, K1 is accepted only until t2, modified or expired messages are rejected, and rolling back to an old application version does not include K1 after the cutoff. A single successful webhook within the overlap window proves too little.

Discussion 0

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

No comments yet. Start the discussion.