Подпись webhook верна: как остановить повтор корректного запроса
Разбираем replay-атаку на корректно подписанный webhook: подписываем timestamp и nonce, проверяем временное окно и показываем, почему локальная память воркера теряет защиту после перезапуска.

В этой статье
Корректная HMAC-подпись подтверждает целостность запроса, но сама по себе не мешает злоумышленнику повторно отправить уже перехваченный пакет. Для защиты нужны три связанные проверки: подписанный момент создания запроса, короткое окно допустимого возраста и подписанный одноразовый идентификатор nonce, который принимающая сторона сохраняет атомарно. Подпись доказывает, что письмо настоящее. Она не доказывает, что почтальон не принёс его второй раз.
Что именно защищает от повтора
В RFC 9421 о подписях HTTP-сообщений параметры created и expires задают временные границы, а nonce может обеспечить уникальность запроса. Документ отдельно предупреждает: проверяющая сторона должна ограничивать возраст подписи и отслеживать уникальные nonce, иначе корректно подписанное сообщение можно воспроизвести повторно.
У Slack в инструкции по проверке входящих запросов timestamp входит в подписываемую строку, а запрос старше пяти минут предлагается отклонять. Это пример конкретного протокола поставщика, а не универсальное значение для любой интеграции: своё окно выбирают по задержкам доставки, повторам и требованиям риска.
Timestamp ограничивает время для атаки, но не исключает повтор внутри окна. Nonce закрывает этот пробел, если он тоже покрыт подписью и сохраняется в общем хранилище. Бизнес-идемпотентность по event_id всё равно остаётся нужна: проверка повтора защищает транспортный вход, а идемпотентность не даёт повторно провести уже принятое событие.
Безопасная учебная модель
Модель проверена 3 ноября 2026 года на CPython 3.12.14, только со стандартной библиотекой. Она не обращается к сети, файлам или базе данных и не изображает реализацию RFC 9421 либо протокола Slack. Время задано целыми секундами, поэтому результат полностью воспроизводим.
Секрет — учебная константа training-secret; в рабочем коде секрет берут из защищённого хранилища и не выводят в журнал.
Тело запроса неизменно: event_id evt-77 и остаток stock 8.
Текущее время NOW равно 1000, окно MAX_AGE — 300 секунд.
В подпись входят created, nonce и исходные байты body в фиксированном порядке.
Набор seen_nonces намеренно хранится только в памяти процесса: это позволяет увидеть сбой после перезапуска.
Проверка сначала отбрасывает слишком старое или будущее время, затем сравнивает HMAC через compare_digest и только после успешной проверки подписи резервирует nonce. Иначе неподписанные запросы могли бы заполнять хранилище чужими значениями.
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,
))
Команда запуска в отдельном учебном каталоге:
python3 webhook_replay_lab.py
Фактический вывод двух последовательных запусков совпал; код завершения в обоих случаях — 0:
first=accepted
same_request=replayed
old_but_correctly_signed=stale
changed_body=bad_signature
after_volatile_store_restart=accepted
Сценарий | Ожидаемая проверка | Наблюдаемый результат |
|---|---|---|
Первый корректный запрос | Принять | accepted |
Тот же запрос и nonce | Отклонить повтор | replayed |
Корректная подпись, возраст 400 секунд | Отклонить по возрасту | stale |
Изменено тело, оставлена прежняя подпись | Отклонить подпись | bad_signature |
Новый процесс без сохранённых nonce | Показать потерю защиты | accepted |
Порядок проверки на принимающей стороне
Разобрать заголовки и проверить формат created и nonce, не доверяя им до проверки подписи.
Отклонить created из далёкого будущего и запрос старше выбранного окна; часы серверов должны быть синхронизированы.
Вычислить подпись по тем же исходным байтам и тем же покрытым полям, затем сравнить значения безопасной функцией.
После успешной проверки подписи атомарно вставить nonce в общее хранилище с уникальным ограничением. Конфликт означает повтор.
Сразу после успешной атомарной вставки nonce передать событие бизнес-обработчику. Запись nonce сохранять как минимум до конца допустимого окна запроса; удалять её по TTL лишь после истечения окна, не дожидаясь удаления для запуска обработки.
Тут есть подвох: обычная последовательность «проверить наличие, потом вставить» допускает гонку между двумя воркерами. Оба успеют увидеть пустое место. Нужна одна атомарная операция — например, вставка строки с уникальным индексом либо эквивалентная команда общего кэша. Конкретный механизм зависит от уже используемой инфраструктуры; Python-множество типа set в этой однопроцессной модели не доказывает атомарность общего хранилища.
Почему память одного процесса не подходит
Последняя строка теста создаёт новый ReplayGuard. Подпись, время и nonce остались прежними, но новый объект ничего не знает о первом приёме и возвращает accepted. То же произойдёт при перезапуске PHP-процесса или при попадании повтора на другой воркер, если список nonce локальный.
Практический критерий: два параллельных воркера и процесс после перезапуска должны видеть одну и ту же запись nonce в течение всего окна. Проверять это следует на изолированном стенде с используемой базой или кэшем; приведённая модель показывает требование, но не подменяет такое испытание.
Граница с идемпотентностью события
Механизм | Какой повтор останавливает | Ключ |
|---|---|---|
Replay protection | Повтор того же подписанного HTTP-запроса в допустимом окне | Подписанный nonce |
Идемпотентность | Повторную бизнес-операцию после принятого события | event_id или operation_id |
Версионная проверка | Устаревшее изменение после более нового состояния | Версия или ETag |
Идентификатор event_id нельзя считать полноценной заменой подписанного nonce. Поставщик может легально повторно подписать то же событие с новым nonce после сетевого сбоя. Вход пройдёт защиту от воспроизведения, а бизнес-обработчик должен узнать event_id и вернуть прежний результат без второго списания, заказа или изменения остатка.
Что проверить в интеграции с 1С-Битрикс
Для 1С-Битрикс: Управление сайтом этот шаблон относится к собственному обработчику входящих callback или webhook, а не к штатному обмену с 1С как универсальной функции продукта. До внедрения нужно записать в описание интеграции редакцию и версию сайта, модуль интеграции, направление данных, формат SKU и полей, права обработчика и документированный протокол внешней системы. Пример выше не меняет каталог и не подтверждает совместимость с конкретным модулем.
Проверьте, какие поля поставщик действительно включает в подпись и присылает ли timestamp и nonce.
Согласуйте окно с максимальной легитимной задержкой и политикой повторной доставки.
Используйте общее атомарное хранилище для всех PHP-процессов и узлов.
После транспортной проверки применяйте отдельную идемпотентность по идентификатору события или операции.
В журнал пишите причину отказа и технический идентификатор, но не секрет, полную подпись и персональные данные тела.
Критерий готовности
Защита работает, если первый корректный запрос принимается, его точная копия отклоняется даже на другом воркере, устаревшая корректная подпись не проходит, изменение тела ломает подпись, а перезапуск процесса не очищает историю nonce раньше выбранного окна. Если последний пункт не выполнен, подпись остаётся честной, а повтор — всё ещё возможным.
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
Parse headers and verify the format of created and nonce without trusting them until the signature is validated.
Reject created timestamps from the distant future and requests older than the selected window; server clocks must be synchronized.
Calculate the signature using the same source bytes and the same covered fields, then compare the values using a secure function.
After a successful signature check, atomically insert the nonce into the shared store with a unique constraint. A conflict indicates a replay.
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.





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