Безопасное хранение API-ключей: практики, а не только советы

«Не коммить ключи в git» слышали все. Разбираем, что делать дальше: лимиты на ключ, ограничение моделей и план действий, если ключ всё же утёк.

4 мин · · Обновлено · безопасность

«Не коммить ключи» — совет, который знают все и всё равно иногда нарушают: копипаста в публичный gist для вопроса на форуме, забытый .env в архиве проекта, скриншот терминала с открытым токеном. Разница между утечкой-неприятностью и утечкой-катастрофой определяется не тем, что ключ попал наружу, а тем, что было настроено заранее.

Ключ — это bearer-токен, а не пароль с MFA

Формат fetch_..., заголовок один и тот же для обоих режимов работы:

Authorization: Bearer fetch_твой_ключ

Anthropic-клиенты иногда шлют его как x-api-key — шлюз принимает оба варианта, поэтому нет смысла держать разные ключи под разные заголовки. Важно другое: у bearer-токена нет второго фактора. Кто угодно с этой строкой может делать запросы от твоего имени, пока ты его не отзовёшь. Ключ показывается в кабинете один раз при создании — если не сохранил его в менеджере паролей сразу, второй раз посмотреть нельзя, только выпустить новый.

Лимит на ключ — это не опция для параноиков, а базовая настройка

На странице ключа в /app/api-keys задаются дневной и месячный лимит расхода в долларах и список моделей, которые этому ключу разрешено вызывать. Настраивай оба параметра сразу при создании ключа, а не после инцидента:

  • Ключ для дев-окружения — лимит вроде $2/день, доступ только к дешёвым моделям для тестов.
  • Ключ для продакшена — лимит по факту ожидаемой нагрузки плюс запас на всплеск, но не «безлимит».
  • Ключ для демо стороннему заказчику — жёсткий потолок, который физически не даст утечке вылиться в серьёзный счёт.

Если лимит превышен, запросы с этого ключа возвращают 429 с кодом key_daily_limit_reached или key_monthly_limit_reached — сервис не спишет больше выставленного порога, даже если запросов шлют тысячами. Если ключ вызывает модель не из разрешённого списка, ответ — 403 model_not_allowed_for_key. Это значит, что даже укравший ключ не сможет прогнать через него дорогую модель, если ты заранее не дал такого разрешения.

Где ключ живёт в коде

Переменные окружения, не константы в файле:

bash
# .env — обязательно в .gitignore
FETCH_API_KEY=fetch_твой_ключ
bash
import os
api_key = os.environ["FETCH_API_KEY"]

Для Claude Code — ANTHROPIC_AUTH_TOKEN (не ANTHROPIC_API_KEY, с ним клиент решит, что нужен логин через официальный аккаунт). Для инструментов на базе OpenAI SDK — OPENAI_API_KEY, независимо от того, какой провайдер реально стоит за адресом.

Перед первым коммитом стоит проверить историю репозитория, а не только текущее состояние файлов — ключ мог засветиться в старом коммите и остаться в истории даже после удаления из рабочей копии:

bash
git log -p --all | grep -i "fetch_"

Если что-то нашлось — считай ключ скомпрометированным независимо от того, публичный репозиторий или приватный: доступ мог быть у любого, кто когда-либо клонировал его.

Idempotency-Key защищает от повторного списания, не от кражи

Заголовок Idempotency-Key — это про надёжность, не про безопасность: повтор запроса с тем же значением за 24 часа вернёт тот же ответ без повторного списания. Полезно при сетевых обрывах и ретраях, но он не мешает третьей стороне с украденным ключом слать свои собственные запросы — это разные задачи, и подменять одно другим не стоит.

Мониторинг вместо надежды

Заголовки X-Zukko-Cost-Micros (стоимость запроса в микродолларах) и X-Fetch-Balance-Cents (остаток после списания — несмотря на название, тоже в микродолларах: 1 000 000 = $1) приходят в каждом обычном ответе; у потоковых (stream: true) их нет — там расход смотри в кабинете, /v1/usage или /v1/balance. Логируй их у себя — резкий скачок расхода без соответствующего роста трафика на твоей стороне обычно означает, что ключ используется не только тобой. Историю списаний с пагинацией (общую на весь аккаунт, по всем ключам) можно поднять отдельно:

bash
curl https://api.zukko.pro/v1/usage \
  -H "Authorization: Bearer fetch_твой_ключ"

Если ключ всё-таки утёк

Порядок действий, а не паника:

  1. Зайти в /app/api-keys и отозвать скомпрометированный ключ — доступ по нему обрывается сразу.
  2. Выпустить новый ключ, сразу выставить лимиты и список разрешённых моделей.
  3. Проверить /v1/usage за последние сутки на предмет запросов, которые не похожи на твои (история там общая на аккаунт, фильтра по ключу нет).
  4. Обновить переменные окружения везде, где использовался старый ключ, и передеплоить.

Дешевле выпустить новый ключ при малейшем сомнении, чем гадать, сохранился ли старый где-то ещё в правильном виде.

Разделяй ключи по назначению

Один ключ на весь стек — самый частый способ превратить маленькую утечку в большую. Отдельный ключ на каждый проект или окружение с собственным лимитом ограничивает ущерб рамками одного проекта, а не всего аккаунта. Если что-то идёт не так, отозвать один ключ проще, чем разбираться, какие сервисы сломает отзыв общего.

Логи приложения — не менее частый источник утечки, чем git

Ключ редко утекает эффектно — чаще он просто оседает там, где его не ждали. Если middleware логирует заголовки входящих запросов для отладки, Authorization: Bearer fetch_... или x-api-key может целиком уйти в систему сбора логов вроде Sentry или Datadog, доступ к которой обычно шире, чем к самому репозиторию: туда заглядывают саппорт, аналитики, иногда подрядчики. Правило простое — маскировать значения этих заголовков до записи в лог, а не после:

bash
SENSITIVE_HEADERS = {"authorization", "x-api-key"}

def sanitize_headers(headers: dict) -> dict:
    return {
        k: ("***" if k.lower() in SENSITIVE_HEADERS else v)
        for k, v in headers.items()
    }

Та же логика касается трассировок ошибок: если исключение выводит тело запроса целиком, а тело содержит ключ в заголовках объекта запроса, стектрейс в системе мониторинга ошибок оказывается таким же местом утечки, как случайный коммит.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.