Секреты — пароли, ключи API, токены и строки подключения — не пишут в код: приложение читает их из переменных окружения, локально — из файла .env, который закрыт .gitignore, а в репозитории лежит только шаблон .env.example. В CI и на хостинге значения хранятся в их хранилищах секретов, а в браузерный бандл не попадает ничего, кроме переменных с публичным префиксом вроде NEXT_PUBLIC_. Если ключ всё же утёк, его первым делом отзывают и перевыпускают, и только потом решают, нужно ли переписывать историю Git.
После материала вы сможете
- отделить конфиг от секретов и оформить
.env,.env.exampleи.gitignoreтак, чтобы секреты не попадали в Git - загрузить переменные в Node.js через
--env-fileи остановить приложение при старте, если обязательной переменной нет - объяснить, какие переменные Next.js и Vite встраивают в клиентский бандл, и проверить сборку на утечку
- передать секреты в GitHub Actions, на хостинг и в контейнер без утечки в образ и логи, найти ключ в истории через gitleaks и действовать по плану при утечке
Проверено на
Node.js 24 LTS (24.20)Next.js 16.4gitleaks 8.30.1git-filter-repo 2.47Docker Engine 29.6GitHub Actions
Сверено с документацией: 11 октября 2026 г.
Содержание
Что считается секретом
Секрет — любое значение, которое даёт доступ: пароль базы данных, ключ платёжного провайдера, токен бота, ключ подписи JWT, приватный SSH-ключ, строка подключения с логином и паролем. Конфиг — всё, что отличается между окружениями (разработка, staging, production), но доступа не даёт: порт, адрес сайта, уровень логов. Методология The Twelve-Factor App требует строго отделять конфиг от кода и хранить его в переменных окружения. Её проверка простая: можно ли прямо сейчас открыть код репозитория всем, не скомпрометировав ни одного доступа.
PORT=3000, APP_URL, LOG_LEVEL
- Секрет?
- нет — конфиг
- Где хранить
- переменные окружения; значения по умолчанию можно держать в коде
DATABASE_URL с паролем
- Секрет?
- да
- Где хранить
- переменные окружения или менеджер секретов; локально —
.env
ключ платёжного провайдера, токен бота
- Секрет?
- да
- Где хранить
- то же; отдельный ключ на каждое окружение
ключ карт для браузера (NEXT_PUBLIC_MAPS_KEY)
- Секрет?
- нет — он публичный по природе
- Где хранить
- в бандле; защищается ограничениями у провайдера: домены, лимиты
маршруты, связи модулей, флаги сборки
- Секрет?
- нет — внутренний конфиг
- Где хранить
- в коде: не меняется между окружениями
.env, .env.example и .gitignore
Локально переменные удобно держать в файле .env — по строке ИМЯ=значение. Сам .env в Git не попадает никогда. В репозиторий кладут шаблон .env.example: те же имена, но без настоящих значений и с комментариями, откуда их взять. Новый разработчик копирует шаблон в .env и заполняет — список нужных переменных всегда виден и обновляется вместе с кодом.
# Copy to .env and fill in. Never commit .env — only this template.
# --- Config (not secret: may differ between dev, staging and production) ---
PORT=3000
APP_URL=http://localhost:3000
LOG_LEVEL=info
# --- Secrets (get them from the team's password manager, never from chat) ---
# PostgreSQL connection string with login and password
DATABASE_URL=postgres://app:CHANGE_ME@localhost:5432/leads
# Payments: test key from the provider dashboard, test mode only
PAYMENTS_SECRET_KEY=sk_test_YOUR_KEY
# Anti-spam API key
SPAM_API_KEY=YOUR_API_KEYnode_modules/
# Local secrets: every .env file except the template
.env
.env.*
!.env.exampleПроверить, что правило работает, можно командой git check-ignore -v .env: она покажет строку .gitignore, которая исключает файл. Если .env уже попал в коммит, .gitignore не поможет — он действует только на неотслеживаемые файлы. Файл нужно убрать из индекса, оставив на диске:
echo ".env" >> .gitignore
git rm --cached .env
git commit -m "Stop tracking .env"Загрузка в Node.js: --env-file и проверка при старте
С Node.js 20.6 .env читается без пакета dotenv: флаг --env-file загружает файл в process.env, а с версий 24.10 и 22.21 он больше не экспериментальный. По документации, если переменная уже задана в окружении, побеждает окружение — так значения хостинга перекрывают локальный файл. Файлов можно передать несколько: следующий переопределяет предыдущий. Если файла нет, Node.js завершается с ошибкой; для необязательного файла есть --env-file-if-exists, а из кода — process.loadEnvFile().
{
"scripts": {
"dev": "node --env-file=.env --watch src/main.ts",
"start": "node src/main.ts"
}
}Читать process.env по всему коду — значит узнать об отсутствующей переменной в момент первого платежа. Надёжнее собрать конфиг в одном модуле и проверить обязательные переменные при старте: приложение без ключа не должно запускаться вовсе.
// Reads configuration once at startup and fails fast if something is missing.
const required = ['DATABASE_URL', 'PAYMENTS_SECRET_KEY', 'SPAM_API_KEY'] as const;
const missing = required.filter((name) => !process.env[name]);
if (missing.length > 0) {
// Name the variables, never print their values
console.error(`Missing environment variables: ${missing.join(', ')}`);
process.exit(1);
}
const paymentsKey = process.env.PAYMENTS_SECRET_KEY!;
if (process.env.NODE_ENV === 'production' && paymentsKey.startsWith('sk_test_')) {
console.error('PAYMENTS_SECRET_KEY: a test key in production');
process.exit(1);
}
export const config = {
port: Number(process.env.PORT ?? 3000),
appUrl: process.env.APP_URL ?? 'http://localhost:3000',
logLevel: process.env.LOG_LEVEL ?? 'info',
databaseUrl: process.env.DATABASE_URL!,
paymentsSecretKey: paymentsKey,
spamApiKey: process.env.SPAM_API_KEY!,
};
// For logs: show that a secret is set, but not the secret itself
export const mask = (value: string) => `${value.slice(0, 8)}… (${value.length} chars)`;import { config, mask } from './config.ts';
console.log(`port=${config.port} log=${config.logLevel}`);
console.log(`payments key: ${mask(config.paymentsSecretKey)}`);$ node --env-file=.env src/main.ts
port=3000 log=debug
payments key: sk_test_… (28 chars)
$ PORT=4000 node --env-file=.env src/main.ts
port=4000 log=debug
payments key: sk_test_… (28 chars)
$ node src/main.ts; echo "exit code: $?"
Missing environment variables: DATABASE_URL, PAYMENTS_SECRET_KEY, SPAM_API_KEY
exit code: 1
$ NODE_ENV=production node --env-file=.env src/main.ts; echo "exit code: $?"
PAYMENTS_SECRET_KEY: a test key in production
exit code: 1- Сообщение об ошибке называет переменные, но не печатает значения: логи читают больше людей, чем имеют доступ к секретам.
- Проверка
sk_test_в production ловит частую ошибку переноса: тестовый ключ платёжки на боевом сервере. - Тип
stringпосле проверки задан через!— сама проверка выше гарантирует, что значение есть. Для большого конфига удобнее схема валидации, например на Zod.
Фронтенд: что попадает в бандл
Всё, что попало в JavaScript для браузера, может прочитать любой посетитель: открыть DevTools достаточно. Поэтому сборщики по умолчанию не отдают переменные клиенту, а пропускают только помеченные префиксом. В Next.js это NEXT_PUBLIC_: по документации, при next build значение подставляется в бандл как строка. Переменная без префикса на клиенте остаётся пустой. В Vite роль префикса играет VITE_, и документация Vite прямо говорит: такие переменные не должны содержать ключей API.
# Public: goes into the browser bundle at build time
NEXT_PUBLIC_MAPS_KEY=pk_test_FAKE_maps_browser_key
# Secret: server only
PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36'use client';
export function MapWidget() {
// NEXT_PUBLIC_: the value is inlined into the browser bundle
const mapsKey = process.env.NEXT_PUBLIC_MAPS_KEY;
// No prefix: on the client Next.js leaves this empty
const secret = process.env.PAYMENTS_SECRET_KEY;
return (
<p>
maps key: {mapsKey}, payments key on the client: {secret || 'not available'}
</p>
);
}import 'server-only';
// Any import of this module from a Client Component fails the build
export async function createPayment(amount: number) {
const key = process.env.PAYMENTS_SECRET_KEY;
if (!key) throw new Error('PAYMENTS_SECRET_KEY is not set');
// A real app would call the payment provider's API with this key
return { amount, keyIsSet: key.length > 0 };
}Проверка, которую стоит повторять перед каждым релизом, — поиск значений секретов в собранных клиентских файлах:
$ npx next build
▲ Next.js 16.4.0 (Turbopack)
- Environments: .env.local
…
$ grep -rl "pk_test_FAKE_maps_browser_key" .next/static
.next/static/chunks/3r_kr6wnxt_zd.js
$ grep -rl "sk_test_FAKE7d2c9e41b8a05f36" .next/static || echo "not in the client bundle"
not in the client bundleЕсли клиентский компонент попробует импортировать lib/payments.ts, сборка остановится:
$ npx next build
> Build error occurred
Error: Turbopack build failed with 2 errors:
./lib/payments.ts:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
Learn more: https://nextjs.org/docs/app/building-your-application/rendering/server-components
> 1 | import 'server-only';
| ^^^^^^^^^^^^^^^^^^^^^
Ecmascript file had an error
Import traces:
Server Component:
./lib/payments.ts
./app/page.tsx
Client Component Browser:
./lib/payments.ts [Client Component Browser]
./app/map-widget.tsx [Client Component Browser]
…Секреты в CI, на хостинге и в Docker
На сервере файла .env обычно нет: значения задаются в панели хостинга или в хранилище секретов CI, а платформа передаёт их процессу как переменные окружения. В GitHub Actions секрет создают в Settings → Secrets and variables → Actions или командой gh secret set NAME и читают через secrets.NAME. Секреты окружения (environment: production) получает только задание, которое на это окружение ссылается.
name: Migrate
on:
workflow_dispatch:
permissions:
contents: read
jobs:
migrate:
runs-on: ubuntu-24.04
environment: production # the job gets production secrets only
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- run: npm ci
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # an env var, not a command-line argument
run: npm run migrate- GitHub маскирует значения секретов в логах, но, по его же документации, не гарантированно: если секрет преобразован (Base64, JSON), маска может не сработать. Не храните структурированные данные одним секретом и не печатайте секреты даже «для отладки».
- Pull request из форка секретов не получает. Проверки pull request должны обходиться без них — подробнее в материале о CI/CD на GitHub Actions.
- На хостинге разделяйте конфиг и секреты. Например, в Vercel переменная типа Secret после сохранения доступна только на запись, а её значение длиной от 32 символов маскируется в логах сборки.
- Задавайте разные значения для production и preview/staging: preview-сборки собираются из любых веток и не должны видеть боевые ключи.
Отдельная ловушка — Docker. Документация Docker предупреждает: аргументы сборки и переменные окружения не годятся для передачи секретов в сборку, потому что остаются в итоговом образе. Мы проверили на образе с ARG PAYMENTS_SECRET_KEY и ENV SPAM_API_KEY=… в Dockerfile:
$ docker build -q -t env-leak --build-arg PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36 .
sha256:1eda9198ee8f…
$ docker history --no-trunc env-leak | grep -o "PAYMENTS_SECRET_KEY=[a-zA-Z0-9_]*" | head -1
PAYMENTS_SECRET_KEY=sk_test_FAKE7d2c9e41b8a05f36
$ docker inspect env-leak --format "{{json .Config.Env}}"
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","SPAM_API_KEY=FAKE_spam_key_baked_into_image"]Секреты в контейнер передают при запуске (docker run --env-file, переменные окружения оркестратора или смонтированный файл), а если секрет нужен на этапе сборки — например, токен приватного npm-реестра — через secret mounts BuildKit (RUN --mount=type=secret). OWASP добавляет инженерный компромисс: переменные окружения видны всем процессам контейнера и могут попасть в дампы, поэтому для чувствительных систем предпочтительнее файлы, смонтированные оркестратором, или менеджер секретов (Vault, облачные Secret Manager).
Сканирование: gitleaks и secret scanning
Ошибка всё равно случится: ключ вставят в тест, в конфиг «на минутку», в Markdown с примером. Сканер находит такие строки по шаблонам известных ключей (Stripe, AWS, GitHub и десятки других) и по энтропии. gitleaks — открытый сканер, который работает локально и в CI. Режим git проверяет всю историю коммитов через git log -p, режим dir — файлы в каталоге.
Тестовый репозиторий: в первом коммите ключ записан прямо в src/payments.ts, во втором его заменили на process.env.PAYMENTS_SECRET_KEY. В текущих файлах ключа нет, но gitleaks находит его в истории:
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo$ docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo
Finding: REDACTED'
Secret: REDACTED
RuleID: stripe-access-token
Entropy: 4.521641
File: src/payments.ts
Line: 1
Commit: d7e5da9132cc2f72e810470d91a16146c0f49549
Author: Dev
Email: dev@example.com
Date: 2026-10-12T01:52:59Z
Fingerprint: d7e5da9132cc2f72e810470d91a16146c0f49549:src/payments.ts:stripe-access-token:1
1:53AM INF 2 commits scanned.
1:53AM INF scanned ~218 bytes (218 bytes) in 172ms
1:53AM WRN leaks found: 1
$ echo $?
1В том же каталоге режим dir нашёл ключ в .env — файл не в Git, но лежит на диске. Поэтому для репозитория используйте git, а dir — для того, что уходит наружу целиком: каталога сборки или содержимого образа. Чтобы ловить ключ до коммита, gitleaks подключают хуком pre-commit (готовая конфигурация есть в README), а в CI — отдельным заданием на каждый pull request:
name: Secret scan
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
gitleaks:
name: gitleaks
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # the whole history, not just the last commit
persist-credentials: false
- name: Scan git history
run: >-
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1
git --redact --no-banner /repoОсознанный тестовый ключ помечают комментарием gitleaks:allow в той же строке или добавляют отпечаток находки в .gitleaksignore. Рекомендация OWASP — завести в команде стандартные тестовые секреты, чтобы не разбирать ложные срабатывания на каждом проекте.
gitleaks
- Что делает
- ищет секреты в истории, файлах и stdin; хук pre-commit и задание CI
- Где доступен
- везде: открытый, запускается локально и в любом CI
GitHub secret scanning
- Что делает
- сканирует всю историю всех веток, а также issues, pull request и wiki; партнёрские ключи сообщает провайдеру
- Где доступен
- публичные репозитории — бесплатно; приватные репозитории организаций — с GitHub Secret Protection на Team и Enterprise Cloud
GitHub push protection
- Что делает
- блокирует push с найденным секретом до попадания в репозиторий
- Где доступен
- для пользователей включена по умолчанию и защищает публичные репозитории; для репозитория — нужна Secret Protection
Ключ утёк: план действий
Утечкой считается любое попадание секрета туда, где его могут прочитать посторонние: коммит (даже в приватный репозиторий и даже удалённый следующим коммитом), лог CI, скриншот, сообщение в чате, клиентский бандл. И OWASP, и GitHub сходятся в главном: сначала ключ отзывают и заменяют, а чистка истории — вторична.
Отозвать и перевыпустить ключ
В кабинете провайдера создайте новый ключ и отзовите старый. Пока старый ключ действует, переписывание истории не защищает: копия уже может быть у кого-то в клоне, форке или кэше.
Заменить значение везде
Хостинг, секреты CI и окружений, менеджер паролей команды,
.envразработчиков. Перезапустите приложения, чтобы они прочитали новое значение.Проверить, пользовались ли ключом
Журналы провайдера и своих систем: запросы с незнакомых адресов, платежи, рассылки, обращения к данным за время с момента утечки. OWASP советует фиксировать, у кого был доступ к секрету и когда его последний раз меняли.
Найти все копии
Прогоните
gitleaks gitпо всем репозиториям, где мог оказаться ключ, проверьте логи CI и чаты. Логи прогонов GitHub Actions с открытым секретом удалите.Решить, нужно ли переписывать историю
По документации GitHub, если секрет отозван, переписывание часто не требуется. Если всё же нужно (персональные данные, ключ, который нельзя отозвать), используйте
git filter-repo --sensitive-data-removal.Разобрать причину
Как ключ попал в код и почему этого не поймали: добавьте сканер в pre-commit и CI, включите push protection, обновите
.env.exampleи правила команды.
git clone https://github.com/YOUR-USERNAME/YOUR-REPOSITORY
cd YOUR-REPOSITORY
echo 'sk_test_FAKE7d2c9e41b8a05f36' > ../leaked.txt
git filter-repo --sensitive-data-removal --replace-text ../leaked.txt
git push --force --mirror origin$ git filter-repo --sensitive-data-removal --replace-text ../leaked.txt
NOTICE: Fetching all refs from origin to make sure we rewrite
all history that may reference the sensitive data, via
git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
…
You rewrote 2 (of 2) commits.
NOTE: First Changed Commit(s) is/are:
d7e5da9132cc2f72e810470d91a16146c0f49549
…
$ git log --oneline
d931c47 Move payments key to env
dd4cd57 Add payments client
$ git show HEAD~1:src/payments.ts | head -1
const PAYMENTS_SECRET_KEY = '***REMOVED***';
$ docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.30.1 git -v --redact --no-banner /repo
1:53AM INF 2 commits scanned.
1:53AM INF no leaks found- Хэши всех коммитов после утечки меняются: ломаются ссылки на коммиты, подписи коммитов снимаются, у закрытых pull request пропадает diff.
- Защиту ветки от force push придётся временно отключить. Коллеги должны переклонировать репозиторий или пересадить свои ветки через rebase — один
git pullиgit pushсо старого клона вернёт секрет обратно. - Копии в форках, кэшированных представлениях GitHub и pull request удаляются только через поддержку GitHub, а в чужих клонах — никак. Ещё одна причина, почему первый шаг — отзыв ключа.
- В репозитории есть
.env.exampleс полным списком переменных и пояснениями;.envи.env.*в.gitignore. - Секреты передаются через менеджер паролей команды, а не в чатах и почте.
- У каждого окружения свои ключи с минимальными правами; тестовые ключи не уходят в production.
- Приложение проверяет обязательные переменные при старте и никогда не печатает значения секретов.
- В клиентский код попадают только переменные с
NEXT_PUBLIC_/VITE_, и среди них нет секретов. - Секреты не передаются в Docker через
ARGиENV. - gitleaks работает в pre-commit и в CI с
fetch-depth: 0; для публичных репозиториев включён secret scanning. - Утёкший ключ сначала отзывается и перевыпускается, и только потом решается вопрос с историей Git.
Как проверено5 · пройдено2 · частичносверено 11 октября 2026 г.
- запущено, работает
.env.example, .env (фейковые значения), .gitignore, src/config.ts, src/main.ts, package.json scripts
node:24-alpine (Node 24.20.0) и Windows Node 24.18.0: tsc -p . (TypeScript 7.0.2, @types/node 24) без ошибок; node --env-file=.env — загрузка; PORT=4000 перекрывает значение из файла; без переменных — список имён и exit 1; NODE_ENV=production с sk_test_ — exit 1; отсутствующий --env-file — 'not found', exit 9; --env-file-if-exists продолжает без файла. git check-ignore -v: .env, .env.local, .env.production игнорируются, .env.example — нет (правило !.env.example). Вывод в статье — реальный из контейнера
- запущено, работает
git rm --cached .env
тестовый репозиторий: .env закоммичен → .gitignore + git rm --cached + commit: файл не отслеживается, остаётся на диске (status !! .env), git log -- .env показывает его в истории
- запущено, работает
Next.js: .env.local, app/map-widget.tsx, lib/payments.ts (server-only)
временный проект next 16.4.0, react 19.3.0, server-only 0.0.1, TypeScript 5: next build успешен; pk_test_FAKE_maps_browser_key найден в .next/static/chunks/3r_kr6wnxt_zd.js, sk_test_FAKE… нет ни в .next/static, ни в .next/server; на клиенте секрет даёт ветку 'not available'. Импорт lib/payments.ts из клиентского компонента → next build exit 1 с ошибкой server-only (текст ошибки упоминает Pages Router — так выводит Next 16.4)
- запущено, работает
Docker: ARG/ENV в образе
alpine:3, docker build --build-arg PAYMENTS_SECRET_KEY=sk_test_FAKE…: значение видно в docker history --no-trunc, ENV — в docker inspect .Config.Env; в реальном прогоне тег образа kb-env-leak (в статье env-leak), образ удалён после проверки
- запущено, работает
gitleaks: поиск ключа в истории
docker zricethezav/gitleaks:v8.30.1 на тестовом репозитории (commit 1 — sk_test_FAKE7d2c9e41b8a05f36 в src/payments.ts, commit 2 — process.env): git -v --redact --no-banner → stripe-access-token в commit d7e5da9, exit 1; режим dir находит ключ в неотслеживаемом .env
- частично
git filter-repo --sensitive-data-removal --replace-text
git-filter-repo 2.47.0 (pip в venv) на свежем клоне локального bare-репозитория: переписаны 2 из 2 коммитов, строка заменена на ***REMOVED***, хэши изменились (d7e5da9 → dd4cd57), старый объект недоступен, gitleaks git — no leaks found. git push --force --mirror origin не выполнялся: команда force push отклонена системой разрешений среды даже для локального bare-репозитория; шаг взят из документации GitHub
- частично
.github/workflows/secrets.yml (gitleaks в CI), migrate.yml (секрет через env + environment)
actionlint 1.7.12 (docker rhysd/actionlint) — 0 ошибок; та же команда docker run gitleaks выполнена локально на тестовом репозитории (exit 1 при находке). На GitHub не запускались: секреты и окружения GitHub не создавались; gh secret set не выполнялся (команда gh отклонена системой разрешений, синтаксис — по документации)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.