Ротация секрета webhook: как сменить ключ без остановки приёма
Практический guide: принимаем K1 и K2 только в ограниченном окне, связываем подпись с key_id и timestamp, проверяем HMAC через compare_digest и отключаем старый ключ по результатам семи тестов.

В этой статье
- What HMAC Confirms
- Input contract for the sample model
- Two keys must have different windows
- The verifier selects the key rather than iterating through history
- Seven reproducible checks
- Rotation order without heroics
- How to choose the overlap window
- Signature and event replay are separate checks
- Readiness criteria
Ротацию секрета webhook безопаснее проводить через ограниченное окно перекрытия: новый ключ K2 начинает действовать в момент t1, старый K1 перестаёт приниматься в t2, а проверяющий выбирает ключ по key_id и отдельно контролирует время подписи. Постоянно принимать оба секрета нельзя: тогда старый ключ формально заменён, но фактически всё ещё открывает вход.
Ниже — учебная модель HMAC-SHA256 для получателя webhook. Я проверил её 23 октября 2026 года на CPython 3.12.14 без сети и внешних пакетов. Это не готовая схема конкретного сервиса или модуля 1С-Битрикс: формат заголовков, повторные доставки и допустимое окно нужно взять из контракта отправителя.
Что подтверждает HMAC
HMAC связывает сообщение с общим секретом и позволяет проверить целостность и подлинность в пределах этого секрета. Конструкция описана в RFC 2104. Сам стандарт рекомендует периодически обновлять ключи, но не задаёт универсальную длительность перекрытия.
Подпись не шифрует тело, не отменяет TLS и не доказывает, что бизнес-событие нужно применить повторно. Сначала проверяют подпись и её время, затем — идентификатор события, состояние заказа и правила идемпотентности.
Входной контракт учебной модели
Поле | Что означает | Что отклоняем |
|---|---|---|
key_id | Идентификатор настроенного секрета K1 или K2 | Неизвестный идентификатор |
timestamp | Время, включённое в подписываемую строку | Слишком старое или заметно будущее |
signature | HMAC-SHA256 в hex | Неверная длина или несовпадение |
raw_body | Исходные байты тела | Изменённые байты |
Строка подписи в модели состоит из ASCII timestamp, точки и исходных байтов тела. Преобразованный JSON уже не подходит: даже без изменения смысла порядок ключей или пробелы меняют байты.
signed = str(timestamp).encode("ascii") + b"." + body
Два ключа должны иметь разные окна
K1 действует до t2, K2 — начиная с t1. Между t1 и t2 оба ключа допустимы: это окно перекрытия для выкладки получателя, переключения отправителя и проверяемого отката. После t2 подпись K1 отклоняется даже тогда, когда математически верна.
Интервал | K1 | K2 | Назначение |
|---|---|---|---|
t0 ≤ t < t1 | Принимать | Отклонять | До переключения |
t1 ≤ t < t2 | Принимать | Принимать | Контролируемое перекрытие |
t2 ≤ t < t3 | Отклонять | Принимать | Старый ключ выключен |

В стандарте HTTP Message Signatures предусмотрены метаданные keyid, created и expires. Это полезный ориентир для разделения идентификатора ключа и времени подписи, но он не означает, что произвольный webhook реализует RFC 9421: фактический формат задаёт поставщик.
Проверяющий выбирает ключ, а не перебирает историю
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"
Для сравнения использована hmac.compare_digest(): документация Python рекомендует её вместо оператора равенства, чтобы уменьшить риск анализа времени сравнения. Секрет и подпись в журнал не записываются; достаточно key_id, результата и идентификатора запроса.
Проверка времени сделана до HMAC. Так просроченное сообщение не получает статус «подпись верна» и не попадает в бизнес-обработку. Допуск 30 секунд в будущее и максимум 5 минут — параметры учебной модели, а не универсальные значения.
Семь воспроизводимых проверок
Следующая команда для Python 3.12 запускает полный стенд в памяти: файлы и сетевые обращения не используются; ключи учебные.
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
Сценарий | Ожидание | Наблюдение |
|---|---|---|
K1 до перекрытия | Принять | accepted |
K1 в перекрытии | Принять | accepted |
K2 в перекрытии | Принять | accepted |
K1 после t2 | Отклонить | key_outside_window |
Изменённое тело | Отклонить | bad_signature |
Сообщение старше 5 минут | Отклонить | stale_timestamp |
Неизвестный key_id | Отклонить | unknown_key |
Скрипт завершился с кодом 0 и сравнил все семь строк с ожидаемым списком. Он подтверждает только логику выбора ключа, окна и HMAC на синтетических данных. Сеть, прокси, хранилище секретов, конкретный поставщик и параллельная обработка здесь не проверялись.
Порядок ротации без героизма
Зафиксировать формат подписи, продолжительность повторных доставок, допустимое расхождение часов и способ указать key_id у конкретного отправителя.
Создать K2 в штатном хранилище секретов. Не копировать его в тикет, код, образ контейнера или журнал.
Развернуть получатель с K1 и K2, но разрешить K2 только с t1, а K1 — только до t2.
В t1 переключить отправителя на K2 и проверить метрики accepted по key_id, bad_signature, unknown_key и задержку timestamp без сохранения подписи.
До t2 выполнить контролируемый откат отправителя на K1 и обратно, если контракт и среда позволяют безопасную проверку.
В t2 прекратить принимать K1. После согласованного срока диагностики удалить старый секрет из активной конфигурации и проверить, что возврат старой версии не включит его снова.
Как выбрать окно перекрытия
Окно должно покрывать штатную выкладку и документированный способ отката.
Если поставщик повторяет старые доставки дольше окна, заранее решите, как они будут классифицированы после t2; молчаливое продление K1 отменяет ротацию.
Если key_id нет, используйте только явно заданный небольшой набор текущих ключей и удаляйте K1 по времени. Не перебирайте архив секретов.
Проверяйте синхронизацию часов и записывайте signed_at и received_at без тела и секретов.
При подозрении на компрометацию обычное окно может быть неприемлемо: отключение ключа и разбор последствий важнее бесшовности.
Подпись и повтор события — разные проверки
Статья «Подпись webhook не сходится: проверьте исходные байты JSON» подробно разбирает, почему HMAC считают по исходному телу. Здесь другой результат: управляемый переход между двумя секретами.
После принятия подписи остаётся бизнес-повтор. В guide «Повторный webhook не должен создать второй заказ» показаны UNIQUE-ограничение, транзакция и повтор после сбоя. Верная подпись не заменяет эти меры.
Критерий готовности
Ротация готова, когда команда может показать четыре факта: K2 принимается после t1, K1 принимается только до t2, изменённое или просроченное сообщение отклоняется, а возврат старой версии приложения не включает K1 после отсечки. Один удачный webhook в окне перекрытия доказывает слишком мало.
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 |

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
Record the signature format, duration of repeated deliveries, allowable clock skew, and the method to specify key_id for a specific sender.
Create K2 in the standard secrets store. Do not copy it to a ticket, code, container image, or log.
Deploy the receiver with K1 and K2, but allow K2 only from t1, and K1 only until t2.
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.
Before t2, perform a controlled rollback of the sender to K1 and back, if the contract and environment allow safe verification.
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.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.