Idempotency-Key, HMAC и replay: три слоя защиты API
Любой публичный POST без `Idempotency-Key` создаёт дубль при сетевом сбое. Любой webhook без HMAC-подписи открыт для phishing, а с подписью, но без timestamp+nonce — для replay. TL;DR: это три разных класса проблем, и один механизм не подменяет другой. Разбираю, как закрыть все три дыры в Public API — с кодом `Verifier` из `sdk-php` и полным приёмником на Python.
Коротко
- Три механизма — три класса проблем.
Idempotency-Keyзащищает состояние от случайных дублей при retry. HMAC-подпись — от запросов с чужого сервера, узнавшего URL webhook.timestamp + event.id— от повторной отправки уже подписанного запроса. Подменить один другим нельзя. - Каждый слой имеет тонкость, на которой ошибаются. Ключ идемпотентности привязан к *логической операции*, не к HTTP-вызову. HMAC сравнивают в *постоянном времени* (
hash_equals, не==). timestamp должен быть *частью подписи*, иначе его подменят. - Production-приёмник включает все три в порядке возрастания стоимости: дешёвая проверка timestamp → HMAC → обращение в кеш обработанных
event.id. Ошибся в порядке — платишь ресурсами на каждом фейке.
Перед открытием публичного API мы прошли через дискуссию «достаточно ли HMAC на webhook и нужен ли Idempotency-Key на POST». Короткий ответ — нет, не достаточно, и да, нужен. Они закрывают три разных класса атак и сбоев, подменить один другим невозможно. Общий контур production-ready POST-контракта мы разбираем отдельно — Production-ready POST: idempotency, retry, rate-limit; здесь фокус на трёх слоях безопасности.
[ Клиент ] [ BOTIX ]
| |
1. POST /contacts | ---- Idempotency-Key ---> | ← защита от дублей при retry
| |
2. Webhook event | <--- HMAC-SHA256 -------- | ← защита от phishing
| |
3. Webhook event | <--- timestamp + event.id | ← защита от replayIdempotency защищает состояние от случайных дублей при сетевых ошибках. HMAC защищает от запросов с произвольного сервера, узнавшего URL webhook'а. Replay-защита — от пересылки уже подписанного запроса повторно.
Слой 1: Idempotency-Key на POST
Клиент отправляет POST, в канале сбой — TCP-сброс, таймаут балансировщика, обрыв на стороне провайдера. Клиент не получил ответа, но запрос дошёл и обработан. Клиент делает retry. На сервере — второй заказ, второе списание, второе исходящее сообщение. Дубль.
Идемпотентность — это свойство операции, при котором её повторное выполнение с теми же входными данными даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP (теоретически). Для POST — нет: POST по определению создаёт ресурс, и без защиты повторный вызов создаст второй. Индустриальный стандарт — Idempotency-Key, уникальный UUID в заголовке. У нас он обязателен на всех мутирующих эндпоинтах. Не передал — 400 IDEMPOTENCY_KEY_REQUIRED. Опциональная идемпотентность — это идемпотентность только для дисциплинированных; остальные забудут, и в проде появятся дубли. Лучше падать на отсутствии ключа, чем тихо производить мусор в БД.
parameters:
IdempotencyKeyHeader:
name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
example: 7a3c2d1e-9b8f-4a7e-8c5d-2e1f4a6b8c0d
description: |
UUIDv4, уникальный для каждой логической операции.
При retry той же операции — тот же ключ.
TTL хранилища 24 часа.Ответ при коллизии (тот же ключ, другое тело):
ErrorResponse:
code:
type: string
enum:
- idempotency_key_required
- idempotency_key_reuse_conflict
- idempotency_key_in_progress
# ... остальныеЧто хранится в Redis
Не только тело ответа, но HTTP-статус и заголовки. Иначе при первом ответе 409 Conflict и retry клиент получит 200 OK из кеша. Хранится полная репрезентация: status + headers + body. TTL — 24 часа: компромисс между «не потерять идемпотентность для долгих retry-стратегий» и «не раздувать хранилище». Меньше суток ставить опасно — retry может прийти через 6 часов, когда ответ уже забыт, и операция выполнится повторно.
public function handleIdempotent(string $key, array $request, callable $action): Response
{
// 1. Атомарный захват ключа
$stored = $this->redis->set(
"idem:{$key}",
json_encode(['state' => 'in_progress', 'hash' => $this->hashRequest($request)]),
['NX', 'EX' => 86400] // 24 часа
);
if (!$stored) {
$existing = json_decode($this->redis->get("idem:{$key}"), true);
if ($existing['hash'] !== $this->hashRequest($request)) {
return new Response(409, ['error' => ['code' => 'idempotency_key_reuse_conflict']]);
}
if ($existing['state'] === 'in_progress') {
return new Response(409, ['error' => ['code' => 'idempotency_key_in_progress']], ['Retry-After' => '5']);
}
return Response::fromArray($existing['response']);
}
// 2. Выполнение операции
$response = $action();
// 3. Сохранение результата
$this->redis->set(
"idem:{$key}",
json_encode([
'state' => 'done',
'hash' => $this->hashRequest($request),
'response' => $response->toArray(),
]),
['EX' => 86400]
);
return $response;
}Ключевое: атомарный захват через SET NX EX, хранение хеша запроса для проверки коллизии, состояние in_progress для параллельных retry. Последнее закрывает race condition: клиент отправил POST с ключом K, операция идёт 3 секунды, клиент по таймауту делает retry с тем же K. Вторая попытка видит in_progress и получает 409 IDEMPOTENCY_KEY_IN_PROGRESS с Retry-After: 5 — подождёт и получит ответ первой попытки из кеша, а не запустит дубль.
Где генерировать ключ
В SDK, не в пользовательском коде:
# from botix import Client (PyPI: pip install botix==1.1.0)
# botix-pro/sdk-python/src/botix/_http.py
import uuid
def post(self, path: str, body: dict, **kwargs) -> dict:
headers = kwargs.pop("headers", {})
headers.setdefault("Idempotency-Key", str(uuid.uuid4()))
return self._request("POST", path, body=body, headers=headers, **kwargs)«Логическая операция» — место, где интеграторы ошибаются. Ключ привязан к операции в логике приложения, не к HTTP-вызову. Цикл на 100 контактов — каждому свой ключ. Таймаут и retry одного — ключ тот же. Если генерировать новый UUID на каждую попытку, идемпотентность теряется, и мы возвращаемся к исходной проблеме дублей.
Что нельзя брать в качестве ключа
- Auto-increment ID из локальной БД — не уникален между средами, легко коллизит.
- Timestamp — два одновременных запроса от двух процессов получат одинаковое значение.
- Хеш от тела запроса — совпадёт для двух *разных* логических операций с одинаковыми параметрами (два повторных списания одной суммы — это две операции, не одна).
Используйте UUIDv4 или ULID. K должен быть уникальным *для логической операции*, а не для входных данных. Для GET ключ не нужен по семантике HTTP; для PUT/DELETE формально тоже, но если они триггерят побочные эффекты (email при смене статуса) — ключ имеет смысл и там.
Слой 2: HMAC-подпись webhook
URL webhook-эндпоинта публичен по определению — он должен быть доступен из интернета, чтобы платформа могла достучаться. Адрес попадает в .env.example, логи реверс-прокси, утечки через подрядчика, перебор поддоменов. Без подписи злоумышленник формирует фальшивый webhook:
{
"event": "payment.succeeded",
"amount": 100000,
"currency": "RUB",
"order_id": "ORD-42"
}Сервер клиента видит «нужное» событие, помечает заказ оплаченным, отгружает товар. Сценарий не теоретический — URL webhook-эндпоинтов утекают. HMAC закрывает эту дверь. Платформа и клиент имеют общий секрет (выдаётся один раз при создании подписки на webhook). Платформа считает signature = HMAC-SHA256(secret, raw_request_body) и кладёт в заголовок X-Botix-Signature. Клиент повторяет ту же операцию с тем же секретом и телом; совпало — запрос от платформы, не совпало — подделка. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего тела.
Почему == — это уязвимость
# Неправильно
if hmac.new(secret, body, sha256).hexdigest() == request.headers["X-BOTIX-Signature"]:
process_event(body)Оператор == останавливается на первом несовпадении байтов. Разница в наносекундах, но злоумышленник шлёт тысячи запросов с разными подписями, измеряет время ответа и постепенно подбирает каждый байт правильной подписи — timing-attack, медленный, но рабочий на длинных горизонтах. Правильное сравнение — в постоянном времени: в PHP hash_equals, в Python hmac.compare_digest, в Node crypto.timingSafeEqual, в Go hmac.Equal.
// github.com/BOTIX-pro/sdk-php/blob/v1.1.0/src/Webhook/Verifier.php
namespace BOTIX\SDK\Webhook;
class Verifier
{
public static function verify(
string $rawBody,
string $signatureHeader,
string $timestampHeader,
string $secret,
int $tolerance = 300 // секунд
): bool {
// 1. Окно валидности timestamp (±5 минут по умолчанию)
$now = time();
$ts = (int) $timestampHeader;
if (abs($now - $ts) > $tolerance) {
return false;
}
// 2. Канонический payload = timestamp + "." + body
$signedPayload = $timestampHeader . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// 3. Сравнение в постоянном времени
return hash_equals($expected, $signatureHeader);
}
}12 строк, три проверки в порядке возрастания стоимости: timestamp дешевле HMAC, HMAC дешевле обращения к кешу. Использование на стороне интегратора:
use BOTIX\SDK\Webhook\Verifier;
$rawBody = file_get_contents('php://input'); // КРИТИЧНО: raw, не json_decode
$sig = $_SERVER['HTTP_X_BOTIX_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_BOTIX_TIMESTAMP'] ?? '';
if (!Verifier::verify($rawBody, $sig, $ts, getenv('BOTIX_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
processEvent($event);Helper принимает сырой body, не распаршенный JSON. Перепарсить и сериализовать обратно — порядок ключей меняется, подпись не сойдётся. В Express — express.raw({type: 'application/json'}), в Laravel — $request->getContent(), в Flask — request.get_data().
Один вызов во всех трёх SDK
Чтобы интегратор не помнил про timing-attack и raw body, во всех трёх официальных SDK есть helper Webhook::verify, скрывающий детали:
// PHP
use Botix\SDK\Webhook;
if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
http_response_code(401);
exit;
}# Python
from botix import Webhook
if not Webhook.verify(secret, raw_body, signature_header):
return Response(status=401)// Node
const { Webhook } = require('@botix/sdk');
if (!Webhook.verify(secret, rawBody, signatureHeader)) {
return res.status(401).end();
}Внутри все три используют сравнение в постоянном времени. Если SDK у вас нет — проверьте, что в коде проверки стоит hash_equals / compare_digest / timingSafeEqual, а не ==. Это одна строка, которая отличает работающую защиту от иллюзии защиты.
Слой 3: replay-защита
HMAC отвечает только на вопрос «принадлежит ли сообщение владельцу секрета». Он не отвечает на «пришло сейчас или подобрано из старого лога». Атака replay: прокси, через который однажды прошёл webhook, сохранил подписанное тело и заголовки; злоумышленник берёт уже подписанный запрос и отправляет повторно. Подпись сходится, бизнес-логика выполняется второй раз. Если webhook обрабатывает оплату, бонусы или изменение прав — последствия серьёзные. HMAC здесь бессилен: это функция без понятия времени и без памяти, один и тот же вход всегда даёт один и тот же выход. Защита лежит рядом с подписью — подписанный timestamp и проверка уникальности на приёме. Оба ингредиента нужны одновременно.
3.1. Timestamp в подписи
К webhook добавляется timestamp — время отправки события, включённое в строку HMAC. Получатель сравнивает timestamp с текущим временем, окно ±5 минут (симметрично: из будущего так же опасно, как из прошлого). Этого хватает на сетевые задержки, retry-каскады и расхождение часов между серверами. Критично — timestamp должен быть частью подписи. Если подписывать только body, а timestamp передавать отдельным заголовком вне HMAC, злоумышленник подменит timestamp на свежий, и атака пройдёт.
signed_payload = timestamp + "." + raw_body
expected_sig = HMAC-SHA256(secret, signed_payload)Формат, аналогичный Stripe.
3.2. Idempotency приёма через event.id
Timestamp-окно отрезает replay через час; в пределах пяти минут replay возможен. Второй слой: получатель помнит, какие события обрабатывал. Каждое событие содержит event.id в формате evt_ + 24 символа, уникальный во всей платформе и не повторяющийся при retry. Если в сообщении есть уникальный идентификатор — а у осмысленного события он есть, — это и есть готовый nonce, который не выдумывают, а используют существующий. Достаточно хранить обработанные event.id за интервал в полтора-два раза больше окна свежести подписи — десять-пятнадцать минут. Это не журнал событий, а короткий кеш для отбрасывания дубликатов.
# Полная проверка webhook на стороне Python-получателя
import os, redis, hmac, hashlib
from botix import Webhook # PyPI: pip install botix==1.1.0
r = redis.from_url(os.environ["REDIS_URL"])
def handle_webhook(raw_body: bytes, sig: str, ts: str, event_id: str) -> tuple[int, str]:
# Слой 2 + 3.1: HMAC + timestamp
if not Webhook.verify(
payload=raw_body,
signature=sig,
timestamp=ts,
secret=XXXXXXXX["BOTIX_WEBHOOK_SECRET"],
tolerance=300,
):
return 401, "invalid signature or expired"
# Слой 3.2: idempotency приёма
key = f"webhook:processed:{event_id}"
if not r.set(key, "1", ex=900, nx=True):
return 200, "already processed"
process_event(raw_body)
return 200, "ok"Команда SET … NX EX 900 атомарна. Конкурентные запросы с одним event.id увидят занятый ключ и пройдут мимо. event.id в webhook и Idempotency-Key в Public API — два разных поля разной природы: в Public API ключ генерирует клиент при запросе *в сторону* API, в webhook инициатор обратный (поставщик), и nonce приходит от него. Но логика приёмки одинакова: «уже обрабатывал — не делай повторно».
3.3. Что обычно ломается на практике
Типичных провалов три:
- Забывают про timestamp вообще — и обнаруживают проблему, только когда её начинают эксплуатировать.
- Проверяют timestamp, но не включают его в подписываемую строку — злоумышленник подменяет timestamp на свежий, HMAC сходится, атака проходит.
- Проверяют
event.idчерезSELECT, потомINSERTбез транзакции и безON CONFLICT— при двух одновременных webhook обрабатывают сообщение дважды. АтомарныйSET NX EXиз примера выше это закрывает.
Раздел «Webhook signatures» на dev-портале описывает все три ошибки явно. SDK закрывает первые две; идемпотентность приёма — обязанность получателя, потому что только он знает, какое хранилище у него под кеш.
Ротация секрета webhook
Простая замена секрета оставляет окно: пока клиент не обновил секрет, ему приходят webhook со старой подписью — и эти события отклоняются, теряются. Правильный механизм — окно ротации: платформа поддерживает два активных секрета, webhook подписываются новым, проверка на клиенте принимает оба. У нас при перевыпуске старый сохраняется со статусом revoked_at = NOW() + INTERVAL 24 HOUR и по истечении физически удаляется. Это даёт разработчику сутки на спокойное обновление.
Чего HMAC не делает
- Не предотвращает replay сам по себе — только timestamp + nonce закрывают это (слой 3).
- Не шифрует тело — HMAC подтверждает целостность и происхождение, не конфиденциальность. Для секретного содержимого — HTTPS.
- Не подтверждает доставку — webhook может быть подписан, но не дойти из-за сетевого сбоя. Надёжность доставки — отдельная задача (retry, очереди, dead-letter); о rate-limit-заголовках и backoff — Rate-limit заголовки, backoff и защита от штормов.
Защита публичного API — три независимых механизма для трёх классов проблем. Подпись не заменяет timestamp, timestamp не заменяет idempotency. Production-приёмник включает все три в этом порядке: дешёвая проверка timestamp перед дорогой HMAC, и обе — перед обращением в кеш обработанных событий.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Production-ready POST: idempotency, retry и rate-limit
5 правил API-клиента, который не сломается в проде
Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
«Мелочи» публичного API: ошибки, cursor, rate-limit
Миграция клиентов с v1 на v2 публичного API без downtime
