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

Керування 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-символів | 8 | 64 біти | 1,8 × 10¹⁹ | ~13 років |
| 32 hex або 22 base64url символи | 16 | 128 біт | 3,4 × 10³⁸ | ~2,5 × 10²⁰ років |
| 64 hex або 43 base64url символи | 32 | 256 біт | 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 із 5 | stripe-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 файлів пояснює, що робити, коли такий файл усе ж потрапив у коміт.
Інші типові місця:
- Фронтенд-код. Будь-який ключ у клієнтському JavaScript публічний. Браузер має звертатися до вашого бекенду, а ключ тримає бекенд.
- Логи. Логери запитів і трекери помилок записують заголовки, тож маскуйте
AuthorizationіX-API-Keyна рівні логування. - Чати й тікети. Будь-який ключ, вставлений у 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, щоб побачити). Для довгоживучого ключа випадкове непрозоре значення з пошуком на сервері відкликати простіше.