API-ключи, audit-логи и OAuth 2.0: безопасность как часть UX
Безопасность в API обычно подаётся как список требований: «храните ключи в env, не коммитьте, не передавайте в URL». Правильно, но не работает — разработчик читает и забывает через два дня. Мы зашли с другой стороны: сделали так, чтобы безопасный сценарий был ещё и самым удобным. Разбираю три механизма, которые реально работают в проде — хранение ключей, audit-лог и выбор между OAuth и API-key.
Коротко
- API-ключ — это бессрочный пароль без 2FA и без попыточного лимита: кто его получил, делает всё в рамках тарифа. Половину утечек платформа закрывает на своей стороне — только header (не query), bcrypt-хеш вместо ключа, last4 в кабинете,
scopesна уровне ключа, ротация черезrevoked_at. - Audit-лог в кабинете — не галочка для compliance, а инструмент самообслуживания интегратора:
request_id, коды, длительности, webhook delivery. В BOTIX пишется через Redis-очередь, overhead на запрос — меньше 1мс, retention 7 дней. - OAuth 2.0 vs API-key — не «новое против старого», а ответ на вопрос «кто действует от чьего имени»: свой бэкенд → API-ключ, стороннее приложение → OAuth 2.0 (PKCE,
state, отзыв установки в один клик).
Первый аудит безопасности прошёл через два месяца после v1.0. Три замечания: ключ можно было передать через query-string ?api_key=... (попал в nginx access.log в открытом виде); audit-лог писался в основную БД синхронно, +30–50мс к ответу; ротация ключей без даунтайма не была документирована — клиенты перевыпускали ключ, ловили 401, тушили пожар. Все три закрыли в v1.1. Параллельно поняли: security в SaaS-API нельзя продавать отдельно от удобства. Если безопасный путь сложнее небезопасного — выбирают небезопасный.
Хранение API-ключей: что может закрыть платформа
API-ключ — это аналог пароля, написанного в открытом виде. У него нет ни срока действия по умолчанию, ни второго фактора, ни попыточного лимита. Любой, кто получил ключ, делает всё, что доступно тарифу проекта: читает контакты, шлёт сообщения, меняет сценарии, удаляет данные. Компрометация одного ключа — это не «инцидент в одной фиче», а потенциально полный slave-доступ к проекту на платформе.
При этом большинство утечек — не изощрённый взлом, а стандартные антипаттерны в коде интегратора. Их немного, и каждый встречается регулярно даже в зрелых командах:
- Ключ в репозитории —
$key = 'btx_live_XXXX…'прямо в конфиге, потом репозиторий уезжает в open-source или утекает через бэкап. Секрет-сканеры (GitHub Secret Scanning) ловят паттернbtx_live_…и шлют уведомление владельцу, но между публикацией и отзывом проходят часы. - Ключ в
.envбез шифрования —.envлежит на диске в plaintext; RCE, бэкап-копия или увольнённый админ читают все ключи разом..env— приемлемое место для *переменной*, но не для *хранения*. - Один ключ на всю команду — master-ключ в Slack, при увольнениях не ротируется; бывший сотрудник может вернуться и навредить.
- Ключ в логах —
error_log(print_r($headers, true)), и в ELK/Loki/Datadog лежит полныйAuthorization: Bearer btx_live_…. - Ключ в URL —
?api_key=…размазывается по access.log, прокси иReferer. - Ключ без ротации — выпущен два года назад, пережил два рефакторинга и одно «подозрение, но не точно».
- Ключ с избыточными правами — сервис делает только
POST /messages, а ключ умеет всё.
Платформа закрывает половину этого на своей стороне. Ключ принимается только в заголовке Authorization: Bearer XXXXXXXX. Query-string запрещён жёстко в app/Middleware/PublicApiAuth.php:
public function handle(Request $request, Closure $next): Response
{
// Категорически не принимаем ключ через query
if ($request->query('api_key')) {
return $this->error('API_KEY_IN_QUERY_FORBIDDEN', 400, [
'reason' => 'API key must be passed in Authorization header, '
. 'not in URL — URLs leak through logs and Referer.',
'docs' => 'https://developers.botix.pro/best-practices',
]);
}
$header = $request->header('Authorization');
if (!$header || !str_starts_with($header, 'Bearer ')) {
return $this->error('UNAUTHORIZED', 401);
}
$token = substr($header, 7);
$key = ApiKey::findActiveByToken($token);
if (!$key || $key->revoked_at !== null) {
return $this->error('INVALID_API_KEY', 401);
}
$request->attributes->set('api_key_id', $key->id);
$request->attributes->set('project_id', $key->project_id);
$request->attributes->set('scopes', json_decode($key->scopes, true));
return $next($request);
}В кабинете на app.botix.pro/developers ключ показывается один раз — в момент генерации, с пометкой «скопируйте сейчас, больше не покажется». После этого видны только последние 4 символа: btx_live_…a4f2. В базе хранится bcrypt-хеш ключа, не сам ключ. Если БД утечёт — восстановить рабочие ключи невозможно. Ключ потерян — кнопка «Перевыпустить» рядом, новая пара за клик.
Scopes на уровне ключа. По умолчанию ключ может всё в рамках тарифа. На практике сервис, отправляющий сообщения, не нуждается в правах удалять контакты. Поле scopes — JSON в btx_api_keys и btx_oauth_access_tokens. При создании ключа указывается список scope: messages:write, contacts:read, analytics:read. Middleware проверяет соответствие при каждом запросе:
public function assertScope(string $required): void
{
$scopes = $this->request->attributes->get('scopes', []);
if (!in_array($required, $scopes) && !in_array('*', $scopes)) {
throw new ApiException('INSUFFICIENT_SCOPE', 403, [
'required' => $required,
'granted' => $scopes,
]);
}
}На каждый изолированный кусок логики — отдельный ключ с минимальным scope. Если ключ скомпрометирован — ущерб ограничен POST /messages, а не «весь проект».
Ротация без даунтайма. Поле revoked_at в btx_api_keys. Поток: создать новый ключ, прокинуть в код, проверить работу, отозвать старый через revoked_at = NOW(). Все три шага независимые. Никаких grace-period'ов: пока старый не отозван, оба работают; в момент отзыва старый перестаёт. Свитч в коде — атомарный, отзыв старого — отложенный.
Нормальная схема на стороне интегратора закрывает вторую половину: источник правды — secret manager (Vault, AWS Secrets Manager, Yandex Lockbox, в простом случае pass поверх gpg), при деплое ключ инжектится в ENV (docker run -e BOTIX_API_KEY=... или Kubernetes Secret), SDK читает его из ENV и падает на старте с понятной ошибкой, если переменной нет. Примеры инициализации на PHP/Python/Node, читающие из ENV, — на developers.botix.pro/best-practices. Открытые SDK BOTIX маскируют значение Authorization перед логированием автоматически (Bearer ***).
Чек-лист для код-ревью, каждая проверка на минуту: .env в .gitignore; в исходниках нет строк с префиксом btx_live_; конфиг читает ключ через getenv(); SDK инициализируется один раз на старте процесса, не на каждом запросе; в логах headers фильтруются; URL не содержит ключа; на каждый сервис — отдельный ключ, имя сервиса в поле name таблицы btx_api_keys. Ниже этого уровня гигиены работать с публичным API в проде нельзя.
Audit-лог как UX-feature
Журнал API-запросов нужен не для compliance, а интегратору, чтобы самому отлаживать код, не дёргая поддержку. Сцена всегда одинаковая: команда встраивает API, что-то не срослось — контакт не создался, webhook не пришёл; открывают свои логи, видят, что запрос отправлен; дальше нужно понять — дошёл ли до API, что ответил сервер, и если ошибка, то какая. Без публичного журнала единственный путь — тикет «у нас что-то не работает, посмотрите со своей стороны». Один такой запрос растягивает диагностику с пятнадцати секунд (взгляд в журнал) до полудня (тикет, переписка, эскалация), и всё это время на стороне интегратора стоит прод.
Журнал на app.botix.pro/developers, доступ — только владельцу проекта, не агентам и не операторам: он содержит чувствительные для безопасности метаданные. Что в нём:
- Время запроса с миллисекундами в UTC
- HTTP-метод, путь, query-параметры (без секретов)
- Код ответа, размер ответа, длительность обработки на стороне сервера
- Уникальный
request_id— тот же, что в заголовкеX-Request-Idу клиента - IP отправителя, last4 API-ключа (без самого ключа)
- Версия API,
Idempotency-Keyесли был
Этого набора хватает на 90% задач отладки. Интегратор пишет «не создался контакт, вот UUID» — поставщик одной командой находит запрос и одной строкой отвечает, какой был код и почему.
Чем это отличается от server-side логов. Серверный лог — это лог инфраструктуры: запросы всех клиентов, внутренние вызовы, периодические задачи, пересечение данных разных тенантов. Журнал в кабинете — вид, отфильтрованный по project_id, с удалёнными чувствительными полями и удобной фильтрацией. В BOTIX доступны фильтры по HTTP-методу, по статусу (2xx, 4xx, 5xx по отдельности), по пути с подстановкой через звёздочку и поиск по request_id.
Что НЕ показываем — полные тела запросов и ответов. Это типичный источник утечек ПД через интерфейс. Тела хранятся только во внутренних логах, доступ — только через SA-консоль и только по письменному запросу владельца проекта.
Webhook delivery — отдельная вкладка. Когда endpoint не получает событий, причин несколько: платформа не сгенерировала событие; сгенерировала, но не положила в очередь; положила, но получила timeout; получила 5xx и поставила в retry; вернула 2xx, но интегратор перепутал тело. Для входящего журнала места этим случаям нет — это исходящая доставка. Поэтому вынесена отдельно; на каждую попытку видны: event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа (первые 4 KB, чтобы прочитать сообщение об ошибке), номер попытки, время следующего retry. Интегратор видит, что вернул 500 на седьмое событие подряд, и понимает — ошибка на его стороне, без тикета «вы не присылаете webhook'и».
Журнал как сигнал, а не только отладка. Над журналом — KPI-блок с четырьмя числами: запросов за 24 часа, доля 2xx, 4xx, 5xx. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog или Grafana; если поставщик её отдаёт — с интегратора снимается отдельная задача.
Что сломалось. Audit-лог писал в основную БД синхронно — 30–50мс overhead. Переделали через очередь:
// PublicApiAuth::handle, after response
public function terminate(Request $request, Response $response): void
{
$this->redis->rPush('api_log_queue', json_encode([
'request_id' => $request->attributes->get('request_id'),
'project_id' => $request->attributes->get('project_id'),
'api_key_id' => $request->attributes->get('api_key_id'),
'method' => $request->getMethod(),
'path' => $request->getPathInfo(),
'status_code' => $response->getStatusCode(),
'duration_ms' => round((microtime(true) - LARAVEL_START) * 1000),
'ip' => $request->ip(),
'created_at' => date('c'),
]));
}Отдельный воркер php artisan api-log:drain каждые 200мс читает из очереди и батчит INSERT'ы. Overhead на запрос — меньше 1мс. В кабинете запись появляется с задержкой 1–2 секунды. Retention — 7 дней.
Retention и compliance. Для SOC 2, ISO 27001, 152-ФЗ, GDPR журнал на 7 дней не годится — энтерпрайз требует минимум 90 дней, потому что инцидент может всплыть через два месяца. Тема упирается в стоимость хранения: при 10 RPS на проект 7 дней — это 5–6 миллионов записей, 90 дней — 70–80 миллионов. Индустриальное решение типовое: горячий журнал на 7–30 дней в основной БД с быстрой фильтрацией плюс архив на нужный срок в холодном хранилище (S3) по запросу. На текущей фазе BOTIX держит только горячий участок на 7 дней; когда появится первый клиент с требованием compliance-retention — под него включим архивный режим, пометка появится на developers.botix.pro/best-practices. Строить холодное хранилище до появления такого клиента смысла нет. Отдельный нюанс — выгрузка архива содержит ПД (как минимум IP), поэтому кнопки «Скачать журнал за год» не будет: нужен контролируемый flow с подтверждением владельца, записью подтверждения в audit-trail и одноразовым скачиванием. Как этот же контракт ошибок и лимитов выглядит со стороны наблюдаемости — в разборе Observability API: контракт ошибок, лимиты, журнал; чек-лист идемпотентности и rate-limit рядом с журналом — в Idempotency, rate-limit и audit-лог.
OAuth 2.0 vs API-key: где какой использовать
Это не дилемма «современное против устаревшего». Это два инструмента для двух сценариев, и подмена одного другим почти всегда ведёт к утечке доступа. Главный вопрос — «кто действует от чьего имени». От ответа зависит выбор механизма.
Сценарий 1. Компания купила SaaS, у неё свой бэкенд, она хочет, чтобы её бэкенд общался с API. Бэкенд принадлежит ей, ключ принадлежит ей, действия выполняются от её имени. Это интеграция собственного приложения. API-ключ — правильный инструмент.
Сценарий 2. Появилось третье приложение — сервис-партнёр. Он хочет работать с данными клиента, который пользуется SaaS. Клиент даёт разрешение. Сервис не знает API-ключ клиента и не должен его знать. Это интеграция стороннего приложения. OAuth 2.0 — правильный инструмент.
Когда сценарии путают, появляются две типичные ошибки. Первая — клиент копирует production-ключ и вставляет в форму «введите API-ключ» в стороннем сервисе. С этого момента сервис имеет полный доступ; если он нечестный — клиент не может ограничить, потому что у API-ключа нет области действия; если честный — он хранит ключ в своей БД, и утечёт БД, утечёт и ключ. Вторая — сервис-партнёр просит API-ключ как часть онбординга. Работает, пока клиент пользуется сервисом; когда он его удаляет, ключ остаётся в чужой БД и продолжает работать. Чтобы реально отозвать, нужно зайти в кабинет основного SaaS и удалить ключ вручную — чего на практике никто не делает. OAuth решает обе: сторонний сервис никогда не получает долгоживущий ключ, а клиент отзывает установку в один клик, и доступ исчезает немедленно.
Почему API-ключ остаётся правильным для собственного бэкенда. OAuth — это не «безопаснее», а другая модель доверия: короткоживущие токены, отзыв, scope. Всё это нужно, когда доступом владеет третья сторона. У собственного бэкенда нет проблемы «кто-то сольёт мой ключ» — он сам владелец. Добавление OAuth-flow здесь — только усложнение: вместо одного заголовка Authorization: Bearer XXXXXXXX появляется танец из получения access token, обновления через refresh token, обработки 401 на истечении и повторного запроса. Индустриальный паттерн: API-ключи — для собственных интеграций, OAuth — для third-party, и в зрелом API оба сосуществуют.
В BOTIX оба механизма на одних и тех же эндпоинтах api.botix.pro/public/v1/*. Различия только в заголовках:
- API-ключ:
Authorization: Bearer XXXXXXXX - OAuth access token:
Authorization: Bearer ya29.a0...+X-OAuth-App-Id: app_01HQRX...
Эндпоинты обрабатывают оба одинаково. Различие в учёте: запросы по OAuth логируются с привязкой к oauth_app_id (отдельная колонка в btx_api_log), и клиент видит, какое именно приложение делало запрос.
Минимальный набор для Authorization Code flow, без срезок:
- PKCE с
code_verifierиcode_challenge(закрывает authorization code interception) - Параметр
stateслучайный и проверяемый (без него — CSRF на callback) - Авторизационный код живёт 5–10 минут, используется одноразово
- Access token — 1 час. Refresh token — 30–90 дней, ротируется при каждом обмене (старый инвалидируется)
- Владельцу кабинета доступен список активных installations с отзывом в один клик
Каждая срезка (нет PKCE, нет state, длинный expiry) превращает OAuth в дырявое решето, которое формально называется OAuth, а фактически даёт меньше защиты, чем добротно сделанный API-ключ. В BOTIX все пять пунктов реализованы. Документация — developers.botix.pro/oauth. Регистрация приложения — app.botix.pro/settings/oauth-apps (для разработчика). Подключённые приложения и их отзыв — app.botix.pro/settings/connected-apps (для пользователя). Две страницы намеренно разнесены: разработчик регистрирует, пользователь даёт согласие.
Scopes на согласии. API-ключ всегда даёт полный доступ; у OAuth-токена есть scope — заявленный набор разрешений, который пользователь видит на странице согласия и решает, разрешать или нет. Это принцип наименьших привилегий плюс прозрачность: если приложение умеет только рассылать сообщения, оно не должно иметь возможности удалять контакты, а пользователь видит объём запроса и может отказаться, если он непропорционален задаче. Список scope в BOTIX небольшой и сознательно не дробится мелко — каждый scope соответствует понятной группе действий; удаление scope из стабильного списка — breaking change. Тот же принцип минимальных прав со стороны выдачи ключей — в разборе про HMAC-подпись и идемпотентность webhook и очередь доставки webhook.
Когда переходить. Одно приложение, интегрируемое с BOTIX, — API-ключ навсегда, OAuth не нужен. Маркетплейс приложений — OAuth с самого начала, компромиссов нет: пускать сторонние приложения по API-ключам значит в первый серьёзный инцидент уничтожить репутацию. Граница между случаями — момент появления первого стороннего приложения. До него OAuth — преждевременная оптимизация, в этот момент — необходимость.
Где остался компромисс
Automatic key rotation по расписанию — всё ещё ручная операция. Поддерживаем «два активных ключа на проект», но cron, который раз в полгода создаёт новый ключ и помечает старый к отзыву через 30 дней, не сделали. Клиент сам помнит и нажимает «Перевыпустить». В roadmap на v1.2: флаг auto_rotation_enabled в btx_api_keys, cron раз в сутки проверяет ключи с created_at < NOW() - INTERVAL 180 DAY, создаёт новый, шлёт email владельцу, через 30 дней — revoked_at = NOW() для старого. Откладывали, потому что без массовых клиентов не выглядит проблемой.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Idempotency-Key, rate-limit и audit-лог публичного API
Observability публичного API: ошибки, лимиты, журнал
Три грабли в webhook-receiver: HMAC, replay, audit-лог
HMAC-подписи, X-RateLimit и защита от replay: три уровня защиты API и edge cases из прода
