<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: КРИТИЧНА OWASP 2025 A02 · Security Misconfiguration

Секрети й .envSecrets and .env

Пароль бази, APP_KEY і ключі сторонніх API живуть у .env. Він лишається секретом, поки його не віддасть вебсервер, не закомітять у git, не покаже сторінка помилки чи не запише лог.

11 хв читання Junior → Senior
01 · ЩО ЦЕ

Де секрети живуть і як вони витікають

Laravel читає .env на старті через phpdotenv, файли в config/ беруть звідти значення через env(), а код читає config('services.stripe.secret'). Перший спосіб втратити все: вебсервер дивиться не в public/, а в корінь проєкту. Тоді GET /.env віддає файл цілком, а /.git/config підказує, що можна витягти й історію. У Laravel веб-корінь лише public/, і в ньому немає нічого секретного.

шифрує все, що Laravel шифрує: cookies, Crypt, касти encrypted, і підписує посилання URL::signedRoute. З ним можна розшифрувати й підробити cookie чи підписане посилання. Паролів він не стосується: їх хешує bcrypt. Звичайна заміна ключа розлогінить усіх і зробить зашифровані колонки нечитабельними, тому ротують через : новий ключ шифрує, старі ще розшифровують, поки дані не перешифровано.

СЛОВНИКAPP_KEY. Ключ застосунку Laravel: 32 випадкові байти в base64. Ним шифрують cookies, Crypt і касти encrypted, ним підписують signed URL. Генерується php artisan key:generate.
СЛОВНИКAPP_PREVIOUS_KEYS. Список старих ключів через кому. Encrypter пробує їх, коли поточний ключ не підходить, а signed URL перевіряються і ними. Дає змінити APP_KEY без масового розлогіну.

Після php artisan Laravel бере конфігурацію з bootstrap/cache/config.php і .env не читає. env() поза config/ тоді повертає null для всього, що було лише в .env, і код тихо працює без ключа. Правило просте: env() тільки у файлах config/, усюди інше config(). Сам кешований файл містить усі секрети відкритим текстом, тож він живе там, де .env, і в git не потрапляє.

СЛОВНИКconfig:cache. Команда, що збирає всі файли config/ в один PHP-масив у bootstrap/cache/config.php. Прискорює кожен запит, але після неї .env не завантажується.

Секрет витікає і там, де його ніхто не друкував навмисно. =true на продакшені показує сторінку помилки з кодом, заголовками запиту разом із cookies, тілом запиту як є, включно з паролем, і SQL з підставленими значеннями. Log::info(..., $request->all()) пише паролі й токени в лог, а помилка HTTP-клієнта несе URL разом з ?api_key=. Атрибут з PHP 8.2 ховає значення аргументу в стек-трейсі.

СЛОВНИКAPP_DEBUG. Режим налагодження. true показує детальну сторінку винятку з кодом і даними запиту, false лише загальну сторінку 500. На продакшені завжди false.
СЛОВНИК#[\SensitiveParameter]. Атрибут параметра з PHP 8.2. У стек-трейсі винятку значення такого аргументу замінюється обʼєктом SensitiveParameterValue, тож пароль не потрапить у лог чи на сторінку помилки.

Секрет, який потрапив у git, лишається в історії, клонах, форках і кешах CI. Видалити коміт мало: ключ треба спершу відкликати чи змінити в провайдера, а вже потім чистити історію через git filter-repo. Щоб такого не було, ставлять сканер секретів: gitleaks у CI чи push protection на GitHub, а секрети деплою зберігають у secrets CI. Laravel ще вміє : php artisan env:encrypt створює .env.encrypted, який можна комітити, а ключ до нього лежить лише на сервері.

СЛОВНИКenv:encrypt. Команда Laravel, що шифрує .env (за замовчуванням AES-256-CBC) у .env.encrypted і друкує ключ. На сервері файл розшифровує env:decrypt з --key або змінною LARAVEL_ENV_ENCRYPTION_KEY.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від коміту до чужого рахунку

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
git add .pushключ з комітуваш ключРозробник:.envІсторія gitСканер секретів{ }API провайдера
Розробник:.envІсторія gitСканер секретів{ }API провайдера
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Розробник створює .env.
  • .env використовує Історія git (push).
  • Дані йдуть від Історія git до Сканер секретів.
  • Сканер секретів надсилає подію ваш ключ, її отримує API провайдера.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Секрет з оточення, у трейсі й у лозі його немає

PHP 8.5
1<?php
2
3// Було: ключ прямо в коді, отже вже в історії git.
4$apiKey = 'prod-key-123';
5
6function connect(string $user, string $password): void
7{
8 throw new RuntimeException('Не вдалося підключитися до бази');
9}
10
11// Увесь запит з паролем іде в лог, а пароль бази в стек-трейс.
12error_log(json_encode($_POST));
13connect('app', getenv('DB_PASSWORD'));
1<?php
2
31// Значення для прикладу. У застосунку воно приходить з оточення: getenv() чи config().
4$password = 'db-pass-123';
5
6// php.ini для продакшену прибирає аргументи з трейсу зовсім. Тут вмикаємо їх, щоб було видно різницю.
7ini_set('zend.exception_ignore_args', '0');
8
9function plain(string $user, string $password): void
10{
11 throw new RuntimeException('Не вдалося підключитися до бази');
12}
13
142function connect(string $user, #[\SensitiveParameter] string $password): void
15{
16 throw new RuntimeException('Не вдалося підключитися до бази');
17}
18
193foreach (['plain', 'connect'] as $function) {
20 try {
21 $function('app', $password);
22 } catch (RuntimeException $e) {
23 $leaks = str_contains($e->getTraceAsString(), $password);
24 echo $function, '(): пароль у стек-трейсі ', $leaks ? 'є' : 'немає', "\n";
25 }
26}
27
284function redact(array $context): array
29{
30 foreach ($context as $key => $value) {
31 if (is_array($value)) {
32 $context[$key] = redact($value);
33 } elseif (preg_match('/pass|token|secret|authorization/i', (string) $key) === 1) {
34 $context[$key] = '[приховано]';
35 }
36 }
37
38 return $context;
39}
40
41$request = ['email' => 'olena@example.com', 'password' => 'hunter2', 'headers' => ['Authorization' => 'Bearer abc']];
42echo 'У лог: ', json_encode(redact($request), JSON_UNESCAPED_UNICODE), "\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–7

    Справжнє значення живе в .env на сервері чи в secrets CI і в git не потрапляє. zend.exception_ignore_args = On у php.ini для продакшену теж ховає аргументи, але покладатися лише на конфігурацію сервера не варто.

  2. 2 рядки 14–17

    #[\SensitiveParameter] з PHP 8.2: у стек-трейсі замість значення буде Object(SensitiveParameterValue). Звичайна функція plain() показує пароль відкритим текстом.

  3. 3 рядки 19–26

    Порівнюємо дві функції з однаковим кодом. Стек-трейс потрапляє в лог і на сторінку помилки, тож різниця тут саме та, що побачить чужа людина.

  4. 4 рядки 28–39

    Контекст для логу проходить через фільтр за назвами ключів, включно з вкладеними масивами. У Laravel простіше не класти зайве: $request->except('password') або only() з потрібними полями.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

Laravel і Symfony бережуть секрети, крім цих місць

LARAVEL · ENV()
$key = env('STRIPE_SECRET');

Чому це дірка: Після config:cache .env не читається, і env() поза config/ поверне null. Код не впаде, а піде до API без ключа. Читайте config('services.stripe.secret'), а env() лишайте файлам config/.

LARAVEL · APP_DEBUG
APP_DEBUG=true

Чому це дірка: На продакшені сторінка помилки покаже заголовки запиту з cookies, тіло запиту як є, включно з полем password, і SQL зі значеннями. Тримайте APP_DEBUG=false і дивіться помилки в логах.

NGINX · ВЕБ-КОРІНЬ
root /var/www/app;

Чому це дірка: Вебсервер дивиться в корінь проєкту, і /.env та /.git/config віддаються як файли. Коренем має бути /var/www/app/public.

GIT
git rm --cached .env && git commit

Чому це дірка: Файл зник з наступних комітів, але лишився в попередніх і в кожному клоні. Спершу відкличте й замініть кожен ключ, потім чистіть історію і додайте .env у .gitignore.

LARAVEL · APP_KEY
php artisan key:generate --force

Чому це дірка: Новий ключ без APP_PREVIOUS_KEYS розлогінить усіх, зламає підписані посилання й зробить касти encrypted нечитабельними. Старий ключ спершу переносять у APP_PREVIOUS_KEYS.

LARAVEL · LOG
Log::info('login', $request->all());

Чому це дірка: У лог іде пароль відкритим текстом, а логи читає більше людей і сервісів, ніж базу. Логуйте лише потрібні поля: $request->only('email').

GITHUB ACTIONS
run: echo "${{ secrets.DEPLOY_KEY }}" | base64

Чому це дірка: GitHub маскує в логах лише точне значення секрету. Закодоване чи розрізане воно друкується як є. Не виводьте секрети взагалі, навіть для налагодження.

SYMFONY · SECRETS
php bin/console secrets:set STRIPE_SECRET

Чому це дірка: Сховище секретів Symfony шифрує значення в config/secrets/prod, і цю теку можна комітити. Файл prod.decrypt.private.php комітити не можна: з ним розшифрується все.

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут витікає секрет

Натисніть на рядки, у яких бачите проблему, і перевірте. Тут чотири проблеми.

app/Services/PaymentGateway.php натисніть на підозрілі рядки
Тут усе гаразд: у рядку немає секрету.
Тут усе гаразд: у рядку немає секрету.
Пропущено. Так, проблема. Ключ у коді живе в історії git назавжди, і його бачить кожен з доступом до репозиторію. Місце ключа в .env і config/services.php.
Тут усе гаразд: у рядку немає секрету.
Тут усе гаразд: у рядку немає секрету.
Тут усе гаразд: у рядку немає секрету.
Пропущено. І тут. Після config:cache env() поза config/ поверне null, і платіж піде без секрету. Читайте config('services.pay.secret').
Пропущено. Ключ у query string осідає в логах проксі й провайдера, а Guzzle додає URL у текст помилки зʼєднання. Передавайте ключ заголовком.
Тут усе гаразд: у рядку немає секрету.
Пропущено. Секрет і URL з ключем ідуть у лог відкритим текстом. Логуйте суму й статус, а не облікові дані.
Тут усе гаразд: у рядку немає секрету.
Тут усе гаразд: withToken кладе секрет у заголовок Authorization, а не в адресу.
Тут усе гаразд: у рядку немає секрету.
Тут усе гаразд: у рядку немає секрету.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Секрети в безпеці
  • .env і файли з секретами в .gitignore і .dockerignore, у репозиторії лише .env.example з порожніми значеннями.
  • env() викликається тільки у файлах config/, код читає config(), на продакшені виконано config:cache.
  • Вебсервер дивиться в public/, на продакшені APP_DEBUG=false.
  • У логах і текстах помилок немає паролів, токенів і ключів: логуються лише потрібні поля, параметри з паролями позначено #[\SensitiveParameter].
  • Ключі API передаються заголовком, а не в query string.
  • Витік ключа означає відкликати й замінити його в провайдера, APP_KEY ротується через APP_PREVIOUS_KEYS.
  • CI сканує коміти на секрети, а секрети деплою зберігаються в secrets CI і ніде не друкуються.
07 · ЯК У PHP UKRAINE

Як цей сайт тримає секрети

У репозиторії немає жодного справжнього секрету. .gitignore не пускає в git .env, .env.backup, .env.production, auth.json і deploy/.env.docker, а .dockerignore не кладе .env, .env.* і deploy/.env.docker в образ. Розробник бере .env.example, де всі ключі порожні. Продакшен-оточення збирає deploy/make-env.sh з шаблону й окремого файлу секретів і записує результат з правами 600. Шаблон deploy/.env.docker.example має APP_DEBUG=false і рядок «Never commit the real file». Код читає секрети лише через config(), а env() стоїть у файлах config/, як у фрагменті нижче.

php app.php фрагмент
1'cipher' => 'AES-256-CBC',
2
3'key' => env('APP_KEY'),← ключ лише з оточення
4
5'previous_keys' => [← старі ключі на час ротації
6 ...array_filter(
7 explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
8 ),
9],
.gitignore.env, .env.backup, .env.production, /auth.json, /deploy/.env.docker і /storage/*.key ніколи не потрапляють у коміт.
.env.exampleСписок усього, що потрібно застосунку, з порожніми значеннями: APP_KEY, ключі входу через Google, LinkedIn і GitHub, доступи до R2.
deploy/make-env.shЗбирає deploy/.env.docker із шаблону й файлу секретів (APP_KEY, DB_PASSWORD, CONTENTOPS_TOKEN) і ставить на результат chmod 600.
deploy/.env.docker.exampleШаблон продакшен-оточення без значень секретів: APP_ENV=production, APP_DEBUG=false, LOG_LEVEL=warning.
08 · СЛОВНИК
APP_KEY
Ключ застосунку Laravel: 32 випадкові байти в base64. Ним шифрують cookies, Crypt і касти encrypted, ним підписують signed URL. Генерується php artisan key:generate.
APP_PREVIOUS_KEYS
Список старих ключів через кому. Encrypter пробує їх, коли поточний ключ не підходить, а signed URL перевіряються і ними. Дає змінити APP_KEY без масового розлогіну.
config:cache
Команда, що збирає всі файли config/ в один PHP-масив у bootstrap/cache/config.php. Прискорює кожен запит, але після неї .env не завантажується.
APP_DEBUG
Режим налагодження. true показує детальну сторінку винятку з кодом і даними запиту, false лише загальну сторінку 500. На продакшені завжди false.
#[\SensitiveParameter]
Атрибут параметра з PHP 8.2. У стек-трейсі винятку значення такого аргументу замінюється обʼєктом SensitiveParameterValue, тож пароль не потрапить у лог чи на сторінку помилки.
env:encrypt
Команда Laravel, що шифрує .env (за замовчуванням AES-256-CBC) у .env.encrypted і друкує ключ. На сервері файл розшифровує env:decrypt з --key або змінною LARAVEL_ENV_ENCRYPTION_KEY.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

Історія git живе у кожному клоні, форку, архіві й кеші CI, а доступ до приватного репозиторію мають підрядники, токени й інтеграції. Видалення файлу пізнішим комітом нічого не прибирає. Комітять .env.example з порожніми значеннями, а справжні секрети живуть на сервері чи в secrets CI.

Код викликає env() поза файлами config/. Після config:cache .env не завантажується, і env() повертає null для значень, яких немає в справжньому оточенні процесу. Треба перенести env() у config/services.php і читати config('services.pay.secret').

З ключем можна розшифрувати й підробити cookies, дані з Crypt і касти encrypted, створити валідне підписане посилання. Паролі під bcrypt не постраждають. Новий ключ ставлять в APP_KEY, старий переносять у APP_PREVIOUS_KEYS: Laravel шифрує новим і пробує старі при розшифруванні. Потім перешифровують дані й прибирають старий ключ, бо доки він у списку, підроблене ним ще приймається.

Справжні значення лише в оточенні процесу чи в менеджері секретів (Vault, AWS Secrets Manager, secrets CI), у репозиторії .env.example. Якщо команді зручніше версіонувати оточення, env:encrypt дає .env.production.encrypted у git, а ключ розшифрування живе лише в CI і на сервері як LARAVEL_ENV_ENCRYPTION_KEY. Окремі ключі для кожного середовища, мінімальні права, сканер секретів у CI і план ротації для кожного ключа, перевірений до витоку, а не після.