Аутентификация по API-ключу для serverless-функций
Почему аутентификация по API-ключу должна жить на шлюзе — до вашего кода, — а не быть разбросанной по каждому serverless-хендлеру: ключи на маршруте против bearer-токенов, ротация без редеплоя, отдельные ключи под инструменты AI-агента и где по-прежнему живёт авторизация.
Аутентификация — из тех вещей, что оказываются либо везде, либо нигде. В serverless-коде она обычно оказывается везде: маленький вызов validateApiKey() в начале каждого хендлера, скопированный из прошлой функции и иногда забытый в следующей. Вот это «иногда забытый» и есть вся проблема целиком. Этот пост — о том, как вынести аутентификацию по API-ключу из кода хендлера наверх, на шлюз, где она отрабатывает до вашей функции, и о честных ограничениях того, что даёт (и чего не даёт) ключ на уровне маршрута.
Угол намеренно узкий: serverless api key auth, которую на Inquir Compute применяет шлюз. Разберём, почему проверка должна стоять перед вашим кодом, чем ключи на уровне маршрута отличаются от bearer-токенов, как работает ротация ключей без редеплоя, как выдавать отдельные ключи под инструменты AI-агента, как держать секреты вне хендлера и — часть, которую обычно пропускают, — в чём разница между аутентификацией (кто вызывает) и авторизацией (что ему можно). Будет реалистичный защищённый маршрут и клиентский вызов, а также раздел про ограничения, потому что вера в то, что ключ на маршруте — это полноценная система идентификации, ровно так и приводит к ложному чувству защищённости.
Почему аутентификация по API-ключу должна жить на шлюзе, а не в хендлере
Беда пофункциональной проверки не в том, что код сложный. Беда в том, что этот код включается вручную. Каждая новая функция требует, чтобы разработчик не забыл добавить проверку. Часть пропусков ловит ревью; остальное ловит прод. Когда у вас двадцать функций, а аудит безопасности находит три эндпоинта без auth-мидлвари, потому что кто-то забыл скопировать паттерн, — это не баг, это класс багов, который будет повторяться.
Проверка на шлюзе меняет умолчание. На Inquir Compute маршрут шлюза объявляет authType, и для только что созданного маршрута это поле по умолчанию равно api-key. Чтобы сделать эндпоинт публичным, нужно осознанно выставить none. Аутентификация становится свойством маршрута, а не строкой, которую кто-то должен не забыть написать. Мидлварь шлюза проверяет учётные данные до вызова функции; если ключ отсутствует или неверен, шлюз возвращает 401, и ваш хендлер вообще не запускается. Для отбитого запроса не поднимается контейнер и не тарифицируется вызов — за трафик, не дошедший до кода, вы не платите.
Стоит назвать и отказоустойчивое поведение по умолчанию: если сервис аутентификации не сконфигурирован, защищённые маршруты отклоняются, а не молча отдаются наружу — кривой деплой падает «закрытым», а не «открытым». Для всего, что охраняет данные, это правильное умолчание.
Практический итог: самый частый serverless-инцидент с авторизацией — «выкатил функцию, забыл мидлварь, эндпоинт теперь публично доступен» — конструктивно исключён. Нельзя забыть добавить проверку на маршрут, где проверка стоит по умолчанию, а её отключение — это явный и заметный на ревью authType: "none". Именно так и выглядит защита serverless-функций на границе, а не на каждом листе дерева.
Ключи на уровне маршрута: X-Api-Key против bearer-токена
Шлюз поддерживает три режима аутентификации на маршруте, и стоит быть точным насчёт того, что проверяет каждый:
none— публичный маршрут, учётные данные не нужны. Этот режим включается явно.api-key— вызывающий обязан передать валидный ключ в заголовкеX-Api-Key. Это межсервисные учётные данные, которые вы будете использовать для большинства машинных вызовов.bearer— вызывающий обязан передатьAuthorization: Bearer <token>, который шлюз проверяет как сессию пользователя платформы в рамках того же тенанта. При успехе хендлер получает принципалаuserId.
Именно третий пункт чаще всего понимают неправильно, поэтому прочитайте его дважды. На Inquir Compute bearer token serverless-проверка на шлюзе валидирует сессионный токен платформы — тот, что держит залогиненный пользователь консоли или вызывающий из того же воркспейса, — а не произвольный JWT из вашего собственного провайдера идентичности. Этот режим рассчитан на людей/консоль и тестирование, а не на роль универсального OIDC-валидатора для ваших конечных пользователей. Если нужно аутентифицировать пользователей вашего приложения, проверяйте их JWT внутри хендлера; режим bearer на шлюзе это не заменяет. К этому мы вернёмся в разделе про аутентификацию и авторизацию.
Для аутентификации на шлюзе по ключам заголовки, которые шлюз пропускает через CORS, включают и X-Api-Key, и Authorization, так что браузерные и серверные клиенты могут слать тот, который ждёт маршрут. Одно удобство с оговоркой: маршрут api-key без списка разрешённых ключей дополнительно примет сессионный bearer-токен того же тенанта как запасной вариант — исключительно чтобы консоль могла тестировать маршруты. Как только вы задаёте на маршруте allowedApiKeyIds, этот запасной путь отключается: маршрут заперт ровно на перечисленные ключи, и пользовательская сессия сквозь список не проскочит. Если маршрут только для машин — задайте список, и он таким и останется.
Защищаем serverless-функции: настоящий маршрут и клиентский вызов
Вот сквозная картина: два маршрута — один заперт на конкретный API-ключ, другой публичный health-check; хендлер, в котором нет ни строчки auth-кода; и клиентский вызов. Это serverless api key auth без единой самодельной проверки.
[
{
"method": "GET",
"path": "/customers/:customerId",
"routeTarget": "lambda",
"functionId": "fn_customer_data",
"authType": "api-key",
"allowedApiKeyIds": ["key_partner_reporting"],
"eventFormat": "simple",
"rateLimit": 120,
"corsEnabled": true,
"enabled": true
},
{
"method": "GET",
"path": "/health",
"routeTarget": "lambda",
"functionId": "fn_health",
"authType": "none",
"enabled": true
}
]
Защищённый маршрут выставляет authType: "api-key" и ограничивает доступ одним ключом через allowedApiKeyIds. Хендлер за ним намеренно скучный:
// api/customer-data.mjs — auth is enforced at the gateway, not here
export async function handler(event) {
// The gateway already validated X-Api-Key before this code ran.
// If we are here at all, the caller presented a valid, allowed key.
const customerId = event.pathParameters?.customerId;
if (!customerId) {
return { statusCode: 400, body: JSON.stringify({ error: 'customerId required' }) };
}
// Authentication (who) is done. Authorization (what) still lives here:
// only return records and fields this key's tier is allowed to see.
const customer = await db.customers.findById(customerId);
if (!customer) {
return { statusCode: 404, body: JSON.stringify({ error: 'not found' }) };
}
return { statusCode: 200, body: JSON.stringify({ customer }) };
}
Обратите внимание, чего в этом хендлере нет: ни разбора ключа, ни сравнения хешей, ни проверки «а есть ли вообще заголовок». Всё это сделал шлюз. Клиент передаёт ключ в заголовке:
# The gateway host is your workspace's default API (/gw/{tenant}/…) or a custom domain.
BASE="https://api.example.com/gw/acme"
# 1) Authenticated call — the key travels in the X-Api-Key header
curl -s "$BASE/customers/42" \
-H "X-Api-Key: $INQUIR_API_KEY"
# → 200 { "customer": { ... } }
# 2) Same route, no key — rejected at the gateway, the handler never runs
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42"
# → 401
Второй вызов — вся суть: он возвращает 401, ни разу не вызвав fn_customer_data. Отбитый трафик не стоит вам ничего и не касается кода.
Аутентификация против авторизации: кто вызывает против того, что ему можно
Именно это различие отделяет тех, кто уже обжёгся, от тех, кто вот-вот обожжётся. Аутентификация отвечает на вопрос кто (или что) вызывает: это валидные, разрешённые для маршрута учётные данные? Этим занимается шлюз. При успехе он передаёт хендлеру принципала — apiKeyId для вызова по ключу, userId для сессии, — так что вы знаете, с каким вызывающим имеете дело. Авторизация отвечает на вопрос что этому вызывающему можно: какие записи, какие поля, какие операции, чьи данные. Шлюз за вас на это не отвечает и не может — это ваша бизнес-логика.
Конкретно: общий ключ отчётности из примера выше аутентифицирован для обращения к GET /customers/:customerId. Но ему всё равно не должно быть можно прочитать клиента, принадлежащего другому аккаунту, или увидеть поля, зарезервированные под более высокий тариф. Это проверки авторизации, и живут они в хендлере — ровно поэтому хендлер и получает принципала. Аутентификация на шлюзе убирает бойлерплейт проверки учётных данных; она не убирает логику разрешений, и любую платформу, которая утверждает обратное, стоит воспринимать с подозрением.
Так же чисто комбинируются и две модели проверки. Используйте api-key на шлюзе для межсервисных маршрутов, а для пользовательских маршрутов валидируйте пользовательский JWT внутри хендлера. Разные маршруты могут использовать разные модели, а один маршрут может их наслаивать: проверка ключа на шлюзе доказывает, что запрос пришёл от вашего фронтенда или партнёра, плюс валидация JWT в хендлере доказывает, какой именно пользователь за ним стоит. Два разных вопроса, два разных слоя, без путаницы в том, кто за что отвечает.
Ротация API-ключей без редеплоя
Поскольку ключ живёт в конфигурации шлюза, а не в вашем исходнике, ротация API-ключей — это операция над конфигом, а не инженерный проект. На Inquir Compute есть штатная ротация, которая одним действием создаёт новый ключ и деактивирует старый. Сами ключи — это 32-байтные значения в base64url, показываемые ровно один раз при создании и хранимые только в виде SHA-256-хеша, а значит, никто, включая вас, не сможет прочитать ключ обратно позже. Сохраните его в момент создания и сразу положите в ваш процесс работы с секретами; кнопки «покажи ключ ещё раз» нет — так задумано.
Тогда ротация — это: выпустить новый ключ, навести allowedApiKeyIds маршрута на него, и старый ключ перестаёт работать в момент деактивации — без редеплоя функции, потому что функция изначально про ключ ничего не знала.
# After rotating, the route's allowedApiKeyIds points at the new key and the
# old key has been deactivated. No function code changed; nothing was redeployed.
# Old key — now rejected at the gateway
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42" \
-H "X-Api-Key: $OLD_KEY"
# → 401
# New key — works immediately
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42" \
-H "X-Api-Key: $NEW_KEY"
# → 200
Поскольку ключи привязаны к воркспейсу и ограничены по маршрутам, вы можете ротировать ключ одного партнёра, не трогая внутренних вызывающих, которые используют другой ключ на другом маршруте. Ключи также можно импортировать и обслуживать из CLI согласно документации по API-ключам, так что ротация встраивается в скриптуемые, аудируемые операции, а не в клик по консоли, который потом никто не восстановит. Если ключ скомпрометирован, реакция та же и быстрая: убрать скомпрометированный ключ, добавить новый, раздать через процесс работы с секретами. Сессии со старым ключом отваливаются немедленно.
Отдельные ключи под инструменты AI-агента
Если вы выставляете serverless-функции как инструменты для LLM-агента, ограничение по маршрутам превращается в стратегию сдерживания. Выдайте каждой группе инструментов собственный ключ и закрепите его через allowedApiKeyIds. Выигрыш — радиус поражения: prompt injection, залогированный заголовок, ключ, утёкший куда-то в трейс, — какой бы ни была причина, вы ротируете ключ этой группы инструментов, и больше ничего не двигается. Один общий ключ на все инструменты означает, что одна утечка вынуждает ротацию, бьющую сразу по всем вызывающим; отдельные ключи на инструмент делают утечку локальной.
Вторая половина того, чтобы делать это правильно, — держать секреты вне хендлера и вне пути модели. API-ключ, который авторизует эндпоинт инструмента, проверяется на шлюзе, поэтому ему незачем появляться в коде хендлера. Секреты, которые инструменту реально нужны дальше по цепочке — учётные данные БД, ключи сторонних API, — живут в пофункциональной конфигурации окружения, которая инъецируется только в момент вызова, редактируется в логах и трейсах по имени ключа и по паттерну (значения вида token, Bearer, api-key и подобные затираются) и никогда не передаётся модели. Модель видит входы и выходы инструмента; она не видит учётных данных, которыми инструмент делает свою работу.
Здесь важна ещё одна гарантия. Ключи с деплой-скоупом — те, которыми ваш CI выкатывает функции, — на шлюзе явно отклоняются ответом «запрещено». Ключ, выпущенный под деплой, нельзя переиграть против живого маршрута инструмента, так что утёкший в лог CI деплой-токен не оказывается заодно и отмычкой к вашим работающим эндпоинтам. Каждая функция выполняется в собственном изолированном контейнере, так что агент, вызывающий Python-инструмент и Node-инструмент, тоже не делит между ними процесс.
Честные ограничения: чем ключи на шлюзе являются и чем нет
Всё вышесказанное мало чего стоит, если неверно оценить границы. Поэтому — прямо:
- Это не полноценный провайдер идентичности.
api-key— это сервисные/машинные учётные данные, аbearer— сессия платформы. Нет OAuth/OIDC-флоу, нет социального логина, нет каталога пользователей, нет экранов согласия. Для идентичности ваших конечных пользователей валидируйте JWT из вашего IdP внутри хендлера. Проверка на шлюзе доказывает, что запросу можно обратиться к маршруту; она не моделирует, кто ваши пользователи. - Rate limiting — это best-effort, а не квота. Лимит на маршрут считается по IP источника, за минуту, и держится в памяти на каждом инстансе, а не в распределённом хранилище; превышение получает
429сRetry-After: 60. Воспринимайте его как гашение злоупотреблений, а не как биллинговую квоту или жёсткий контроль безопасности. - Это не менеджер секретов. Пофункциональная конфигурация окружения инъецируется при вызове и шифруется в покое только если задан ключ шифрования; версионирования и ссылок между функциями нет. Сами API-ключи хранятся в виде хеша, но с каждым plaintext-значением обращайтесь как с секретом.
- Лимиты выполнения по-прежнему в силе. Вынос аутентификации на шлюз не меняет поведение рантайма: у функций таймаут по умолчанию 5 секунд и максимум 15 минут, а горячие пулы контейнеров хоть и сокращают холодные старты, но не устраняют их. Первый вызов после простоя всё равно холодный.
- «Только для машин» означает список ключей. Помните, что маршрут
api-keyбезallowedApiKeyIdsдополнительно примет сессионный токен того же тенанта для тестирования из консоли. Если маршрут обязан быть только для машин — задайте список; это и ограничит маршрут конкретными ключами, и закроет запасной путь через сессию.
Вывод
Ход прост в формулировке и снимает целый класс инцидентов: сделайте аутентификацию свойством маршрута, а не строкой в каждом хендлере. На Inquir Compute маршрут шлюза по умолчанию api-key, отбивает плохие ключи ответом 401 до запуска вашего кода и до тарификации вызова, а при неверной конфигурации падает «закрытым» — так что «забыл мидлварь, и эндпоинт стал публичным» перестаёт быть возможным. Ротируйте ключи как операцию над конфигом без редеплоя, выдавайте отдельный ключ на партнёра и отдельный — на инструмент агента, чтобы держать радиус поражения маленьким, и храните нижележащие секреты в пофункциональном окружении, вне хендлера и вне пути модели.
А дальше уважайте границы. Аутентификация по ключу на шлюзе — это аутентификация, а не авторизация: логика разрешений остаётся в вашем хендлере, который по-прежнему получает принципала вызывающего. Это сервисные учётные данные, а не провайдер идентичности: идентичность конечного пользователя — это валидация JWT в хендлере. А её rate limiting — best-effort, а не квота. Поставьте проверку ключа на границу, оставьте авторизацию и секреты там, где им место, и эндпоинт, который раньше был в одном забытом if от публичности, просто закрыт по умолчанию.