SecuredKit

Опубліковано: · Оновлено: · автор:

Керування API-ключами: найкращі практики 2026

Як генерувати, позначати префіксом, хешувати, обмежувати й ротувати API-ключі. Перевірено в Node 22: що ловить gitleaks і чому SHA-256 тут кращий за bcrypt.

безпека API-ключів: сховище секретів, ротація та токени з обмеженими правами

Керування API-ключами зводиться до п'яти речей: згенерувати щонайменше 128 бітів із CSPRNG, дати ключу префікс, який розпізнають сканери секретів, зберігати лише його хеш, обмежити права до потрібного мінімуму й ротувати з вікном перекриття. А далі тримати ключ подалі від git, фронтенд-бандлів і логів, бо саме там ключі витікають найчастіше.

Одразу скажу, на чому це ґрунтується. Я не будував систему API-ключів для власного API і не мав інциденту з витоком ключа. Тут стандартна схема, перевірена в межах цієї статті: поради щодо префіксів я протестував на gitleaks 8.28.0, а поради щодо зберігання підкріпив замірами в Node 22.

Що робить API-ключ стійким до вгадування

API-ключ — це bearer-токен: кому він належить, той і довірений, без другого фактора й без запиту пароля. Атакувальник може ключ вгадати, вивести зі слабкого генератора або викрасти. Вгадування — задача з математики:

H = L × log₂(R)

H — ентропія в бітах, L — кількість символів, R — розмір набору символів. 32-символьний hex-ключ (R = 16) несе 32 × 4 = 128 біт. У base64url кожен символ несе 6 бітів, але 16 випадкових байтів кодуються у 22 символи, які містять рівно 128 біт (останній символ несе лише 2). Рахуйте випадкові байти на вході, а не символи на виході.

Припустімо, ключ витік як дайджест SHA-256, а в атакувальника одна RTX 4090, що перебирає близько 22 мільярдів хешів SHA-256 за секунду (hashcat 6.2.6). Середній час, тобто половина простору ключів:

КлючВипадкових байтівЕнтропіяКомбінаційСередній офлайн-злам (1× RTX 4090)
16 hex-символів864 біти1,8 × 10¹⁹~13 років
32 hex або 22 base64url символи16128 біт3,4 × 10³⁸~2,5 × 10²⁰ років
64 hex або 43 base64url символи32256 біт1,2 × 10⁷⁷~8 × 10⁵⁸ років

Сотня орендованих карт перетворює рядок із 64 бітами приблизно на сім тижнів, тож 64 біти відпадають. На 128 бітах тій самій фермі знадобиться понад 10¹⁸ років, і лишається тільки крадіжка. За замовчуванням я видавав би 32 байти, бо додаткова довжина нічого не коштує.

Генеруйте ключі з CSPRNG

Усе це має сенс, лише якщо біти справжні. Math.random() не підходить: у V8 це xorshift128+, і чотирьох його виходів досить, щоб відновити стан, що я перевірив у статті про CSPRNG. У Node беріть crypto.randomBytes(32), у Python — secrets.token_urlsafe(32) або secrets.choice по алфавіту. Обидва читають із CSPRNG операційної системи.

Для разового ключа Генератор секретних ключів робить те саме через crypto.getRandomValues() прямо у вкладці браузера, і ключ нікуди зі сторінки не йде. Алфавітно-цифровий формат на 256 біт дає 43 символи, і це знадобиться в наступному розділі.

Дайте ключам префікс, який упізнають сканери

Префікс на кшталт sk_live_ одразу показує тип і середовище ключа, а сканерам секретів дає змогу знаходити витоки майже без хибних спрацювань. GitHub перейшов на таку схему у 2021 році: ghp_ для персональних токенів доступу, підкреслення, бо це не символ Base64, і контрольна сума CRC32 в останніх 6 символах, щоб сканер міг відсіяти випадкові схожі рядки офлайн (розбір від GitHub).

Щоб побачити, що це дає на практиці, я згенерував ключі різних форматів через crypto.randomBytes і прогнав по них gitleaks dir (8.28.0, стандартні правила):

Що було у файліЗнайдено?Правило
const k = "<64 hex chars>"Ні
API_KEY=<64 hex chars> у .envТакgeneric-api-key
const config = { token: "<43 base64url chars>" }Такgeneric-api-key
ghp_ + 36 алфавітно-цифрових символівТакgithub-pat
sk_live_ + 43 алфавітно-цифрові символи (5 файлів)Так, 5 із 5stripe-access-token
sk_live_ + 43 символи base64url (6 файлів)Ні, 0 із 6
skt_ + 43 символи base64url, власний префіксНі
те саме з власним правилом на 5 рядківТакmy-api-key

Дві речі мене здивували. Голий hex-рядок без промовистої назви змінної проходить непоміченим, тобто виявлення ключів без префікса залежить від того, як розробник назвав змінну. А запозичений відомий префікс допомагає, лише якщо тіло ключа збігається з алфавітом, якого чекає сканер: у кожному тілі base64url, яке я перевірив, траплявся - або _, і правило Stripe переставало спрацьовувати.

Мій висновок: беріть власний префікс, алфавітно-цифрове тіло (алфавітно-цифровий вихід Генератора секретних ключів або base62 у коді) і додайте для нього правило сканера. Для gitleaks воно коротке:

[extend]
useDefault = true

[[rules]]
id = "my-api-key"
description = "My API key (skt_ prefix)"
regex = '''\bskt_[A-Za-z0-9_-]{43}\b'''
keywords = ["skt_"]

Перші 4–8 символів після префікса правлять і за несекретний ідентифікатор для пошуку: користувачі розрізняють ключі в дашборді, а ви можете шукати їх у логах, не тримаючи сам секрет. Випадкова частина має лишатися на рівні 128 біт або більше понад те, що ви показуєте.

Зберігайте хеш ключа, а не сам ключ

Сервер не повинен уміти показати ключ ще раз. Покажіть його користувачу один раз, а зберігайте лише дайджест. Швидкого хешу тут досить, і саме цей момент більшість гайдів проскакує: bcrypt і Argon2 існують, щоб сповільнити підбір паролів, які придумують люди й у яких мало ентропії, а в 256 випадкових бітах підбирати нічого. Якщо хочете, щоб викрадена таблиця не дозволяла навіть перевірити, чи є в ній відомий ключ, беріть HMAC-SHA-256 із секретом на сервері замість звичайного SHA-256.

Увесь цикл видачі й перевірки вміщується в кілька рядків Node:

import crypto from 'node:crypto'

function issue() {
  const key = `skt_${base62(43)}`                 // 43 chars × log₂(62) ≈ 256 bits
  const hash = crypto.createHash('sha256').update(key).digest()
  return { key, row: { prefix: key.slice(0, 12), hash } }   // show key once, save row
}

function verify(incoming, row) {                  // row looked up by prefix
  const h = crypto.createHash('sha256').update(incoming).digest()
  return crypto.timingSafeEqual(h, row.hash)
}

У Node 22.23 така перевірка зайняла 1,05 мкс у середньому на 200 000 запусків. Той самий ключ, перевірений через bcryptjs 3.0.3, забрав 69 мс при cost 10 і 275 мс при cost 12. bcryptjs написаний на чистому JavaScript і повільніший за нативну бібліотеку, але навіть нативний bcrypt додає десятки мілісекунд до кожного автентифікованого запиту, і змусити ваш процесор робити цю роботу може будь-хто, хто здатен надсилати вам запити. Для API-ключів повільна KDF безпеки не додає, а коштує затримки й дає легкий шлях до DoS.

timingSafeEqual порівнює два дайджести, не виходячи раніше на першому ж відмінному байті. Дайджести, які видає ваш код, зручно звіряти в Генераторі хешів, поки ви будуєте цей цикл.

Обмежуйте кожен ключ мінімально потрібними правами

Ключ, який може все, — саме той, що варто вкрасти. Дайте кожному ключу права на читання чи запис окремо для ресурсів, щоб ключ віджета аналітики не міг видаляти користувачів. Розводьте ключі test і live префіксом і даними, тоді витік тестового ключа нічого не коштує. Прив'язуйте ключі для взаємодії сервер–сервер до відомих вихідних IP, якщо інфраструктура це дозволяє, і ставте ліміт запитів на кожен ключ, щоб викрадений не вичерпав квоту, поки моніторинг помітить.

Багато вузьких ключів краще за один майстер-ключ із тієї ж причини, що й ролі IAM: один витік розкриває одну можливість.

Ротуйте за розкладом, відкликайте за підозри

Що довше живе ключ, то в більше логів, бекапів і ноутбуків він потрапляє. Жоден стандарт не задає інтервал для API-ключів, тож оберіть такий, який автоматизація виконає без участі людини. Квартальна ротація, яка справді відбувається, краща за щомісячну, яку пропускають.

Тип ключаТермін життяПолітика ротації
Короткоживучий токен доступуДо 1 годиниСпливає сам
Ключ для взаємодії сервер–серверМісяціАвтоматичний розклад із вікном перекриття
Ключ деплою в CI/CDМісяціЗа розкладом і коли йде людина з доступом
Довгоживучий ключ інтеграціїМісяціЗа розкладом і негайно за підозри на витік

Ротацію робить безболісною вікно перекриття: видайте новий ключ, приймайте обидва протягом 24–48 годин, переконайтеся, що трафік перейшов, і тоді відкличте старий. Коли ключ таки витік (публічний репозиторій, тікет, дамп після злому), відкликайте його впродовж години. Скрапери читають нові публічні коміти за лічені хвилини, тож вважайте, що ключ уже в когось є.

Тримайте ключі подалі від місць, де вони витікають

Більшість витоків — це ключ не в тому файлі. Випадок, з яким я стикався сам: API-ключ, записаний просто в код. Його перенесли в .env, прибрали з коду й замінили новоствореним ключем, бо старий уже побував у репозиторії. У моїх проєктах .env завжди потрапляє в .gitignore, а продакшн-значення зашифровані через Ansible Vault і передаються застосунку під час деплою. Стаття про захист .env файлів пояснює, що робити, коли такий файл усе ж потрапив у коміт.

Інші типові місця:

  1. Фронтенд-код. Будь-який ключ у клієнтському JavaScript публічний. Браузер має звертатися до вашого бекенду, а ключ тримає бекенд.
  2. Логи. Логери запитів і трекери помилок записують заголовки, тож маскуйте Authorization і X-API-Key на рівні логування.
  3. Чати й тікети. Будь-який ключ, вставлений у Slack чи Jira, вважайте розкритим і ротуйте.

Запустіть у CI ще й сканер (gitleaks або trufflehog) із власним правилом для формату ваших ключів, як показано вище. Для продакшну менеджер секретів на кшталт HashiCorp Vault, AWS Secrets Manager чи Doppler додає шифрування у стані спокою, журнали доступу та хуки для ротації, а застосунок читає секрети під час роботи, а не вшиває їх в образ.

Якщо проєктуєте це сьогодні: 43-символьне алфавітно-цифрове тіло з Генератора секретних ключів або crypto.randomBytes, власний префікс із правилом сканера, дайджест SHA-256 чи HMAC у базі даних і дата першої ротації, записана в тому самому pull request.

FAQ

Чим API-ключ відрізняється від токена доступу?

API-ключ зазвичай є довгоживучими статичними обліковими даними, які ви самі видаєте й відкликаєте. Токен доступу (OAuth, JWT) зазвичай короткоживучий, з обмеженими правами і видається через auth-флоу, тож викрадений токен сам спливе.

Чи можна використовувати JWT як API-ключ?

Можна, але ви втрачаєте дві речі: підписаний JWT лишається дійсним до завершення терміну, якщо ви не ведете список відкликаних, а його вміст лише закодований у base64url, тож будь-хто з токеном прочитає claims (вставте токен у Декодер JWT, щоб побачити). Для довгоживучого ключа випадкове непрозоре значення з пошуком на сервері відкликати простіше.

Спробуйте безкоштовний генератор паролів

Генеруйте надійні, безпечні паролі миттєво. 100% приватно, на стороні клієнта.

Відкрити генератор паролів