Secrets — passwords, API keys, tokens and connection strings — do not belong in code: the app reads them from environment variables, locally from a .env file covered by .gitignore, while the repository holds only an .env.example template. In CI and on hosting, values live in their secret stores, and nothing reaches the browser bundle except variables with a public prefix such as NEXT_PUBLIC_. If a key does leak, you first revoke and reissue it, and only then decide whether the Git history needs rewriting.
After this you will be able to
- separate config from secrets and set up
.env,.env.exampleand.gitignoreso secrets never reach Git - load variables in Node.js with
--env-fileand stop the app at startup if a required variable is missing - explain which variables Next.js and Vite inline into the client bundle, and check a build for leaks
- pass secrets to GitHub Actions, hosting and containers without leaking them into images and logs, find a key in history with gitleaks and follow a plan when a key leaks
Verified with
Node.js 24 LTS (24.20)Next.js 16.4gitleaks 8.30.1git-filter-repo 2.47Docker Engine 29.6GitHub Actions
Checked against docs: October 11, 2026
Contents
What counts as a secret
A secret is any value that grants access: a database password, a payment provider key, a bot token, a JWT signing key, a private SSH key, a connection string with a login and password. Config is everything that differs between environments (development, staging, production) but grants no access: the port, the site URL, the log level. The Twelve-Factor App methodology requires strict separation of config from code and keeps config in environment variables. Its test is simple: could you open-source the repository right now without compromising a single credential?
PORT=3000, APP_URL, LOG_LEVEL
- Secret?
- no — config
- Where to keep it
- environment variables; defaults may live in code
DATABASE_URL with a password
- Secret?
- yes
- Where to keep it
- environment variables or a secret manager; locally —
.env
payment provider key, bot token
- Secret?
- yes
- Where to keep it
- the same; a separate key per environment
browser maps key (NEXT_PUBLIC_MAPS_KEY)
- Secret?
- no — public by nature
- Where to keep it
- in the bundle; protected by provider restrictions: domains, quotas
routes, module wiring, build flags
- Secret?
- no — internal config
- Where to keep it
- in code: it does not change between environments
.env, .env.example and .gitignore
Locally, variables are convenient to keep in a .env file — one NAME=value per line. The .env file itself never goes into Git. The repository holds an .env.example template instead: the same names, no real values, and comments on where to get them. A new developer copies the template to .env and fills it in — the list of required variables is always visible and changes together with the code.
# 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.exampleYou can check that the rule works with git check-ignore -v .env: it prints the .gitignore line that excludes the file. If .env has already been committed, .gitignore will not help — it only affects untracked files. Remove the file from the index and keep it on disk:
echo ".env" >> .gitignore
git rm --cached .env
git commit -m "Stop tracking .env"Loading in Node.js: --env-file and a startup check
Since Node.js 20.6, .env can be read without the dotenv package: the --env-file flag loads the file into process.env, and as of versions 24.10 and 22.21 it is no longer experimental. According to the documentation, if a variable is already set in the environment, the environment wins — so hosting values override the local file. You can pass several files: each subsequent one overrides the previous. If the file does not exist, Node.js exits with an error; for an optional file there is --env-file-if-exists, and from code — process.loadEnvFile().
{
"scripts": {
"dev": "node --env-file=.env --watch src/main.ts",
"start": "node src/main.ts"
}
}Reading process.env all over the code means discovering a missing variable at the moment of the first payment. It is more reliable to build the config in one module and check required variables at startup: an app without its key should not start at all.
// 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- The error message names the variables but never prints values: more people read logs than have access to secrets.
- The
sk_test_check in production catches a common deployment mistake: a test payment key on the live server. - The
stringtype after the check is asserted with!— the check above guarantees the value exists. For a large config a validation schema, for example with Zod, is more convenient.
Frontend: what ends up in the bundle
Anything that reaches the browser JavaScript can be read by any visitor: opening DevTools is enough. That is why bundlers do not expose variables to the client by default and only let through prefixed ones. In Next.js the prefix is NEXT_PUBLIC_: according to the documentation, next build substitutes the value into the bundle as a string. A variable without the prefix stays empty on the client. In Vite the prefix is VITE_, and the Vite documentation says plainly that such variables must not contain API keys.
# 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 };
}A check worth repeating before every release is searching the built client files for secret values:
$ 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 bundleIf a Client Component tries to import lib/payments.ts, the build stops:
$ 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]
…Secrets in CI, on hosting and in Docker
There is usually no .env file on a server: values are set in the hosting dashboard or the CI secret store, and the platform passes them to the process as environment variables. In GitHub Actions you create a secret in Settings → Secrets and variables → Actions or with gh secret set NAME, and read it via secrets.NAME. Environment secrets (environment: production) are available only to the job that references that environment.
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 masks secret values in logs, but according to its own documentation not reliably: if a secret is transformed (Base64, JSON), masking can fail. Do not store structured data as a single secret, and never print secrets, not even "for debugging".
- A pull request from a fork receives no secrets. Pull request checks must work without them — see the material on CI/CD with GitHub Actions.
- On hosting, separate config from secrets. For example, in Vercel a Secret-type variable is write-only after saving, and its value is masked in build logs when it is 32 characters or longer.
- Use different values for production and preview/staging: preview builds are made from any branch and must not see live keys.
Docker is a separate trap. The Docker documentation warns that build arguments and environment variables are inappropriate for passing secrets to a build, because they persist in the final image. We checked it on an image with ARG PAYMENTS_SECRET_KEY and ENV SPAM_API_KEY=… in the 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"]Pass secrets to a container at run time (docker run --env-file, orchestrator environment variables or a mounted file), and if a secret is needed during the build — for example, a private npm registry token — use BuildKit secret mounts (RUN --mount=type=secret). OWASP adds an engineering trade-off: environment variables are visible to every process in the container and can end up in dumps, so for sensitive systems files mounted by the orchestrator or a secret manager (Vault, cloud Secret Managers) are preferable.
Scanning: gitleaks and secret scanning
The mistake will happen anyway: a key pasted into a test, into a config "for a minute", into a Markdown example. A scanner finds such strings by the patterns of known keys (Stripe, AWS, GitHub and dozens more) and by entropy. gitleaks is an open-source scanner that runs locally and in CI. The git mode checks the whole commit history via git log -p, the dir mode checks files in a directory.
A test repository: in the first commit the key is written straight into src/payments.ts, in the second it is replaced with process.env.PAYMENTS_SECRET_KEY. The current files contain no key, but gitleaks finds it in history:
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 $?
1In the same directory, the dir mode found the key in .env — the file is not in Git but is on disk. So use git for a repository and dir for whatever is shipped as a whole: a build directory or an image's contents. To catch a key before it is committed, gitleaks is wired in as a pre-commit hook (the README has a ready configuration), and in CI as a separate job on every 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 /repoA deliberate test key is marked with a gitleaks:allow comment on the same line, or the finding's fingerprint is added to .gitleaksignore. OWASP recommends agreeing on standard test secrets within the team, so false positives do not have to be triaged on every project.
gitleaks
- What it does
- finds secrets in history, files and stdin; pre-commit hook and CI job
- Where it is available
- everywhere: open source, runs locally and in any CI
GitHub secret scanning
- What it does
- scans the whole history of all branches, plus issues, pull requests and wikis; reports partner keys to the provider
- Where it is available
- public repositories — free; private organization repositories — with GitHub Secret Protection on Team and Enterprise Cloud
GitHub push protection
- What it does
- blocks a push containing a detected secret before it reaches the repository
- Where it is available
- for users it is on by default and protects public repositories; per repository it requires Secret Protection
A key has leaked: the action plan
A leak is any appearance of a secret where outsiders can read it: a commit (even to a private repository, even one removed by the next commit), a CI log, a screenshot, a chat message, a client bundle. OWASP and GitHub agree on the main point: first the key is revoked and replaced; cleaning the history comes second.
Revoke and reissue the key
Create a new key in the provider's dashboard and revoke the old one. While the old key is valid, rewriting history protects nothing: a copy may already sit in someone's clone, fork or cache.
Replace the value everywhere
Hosting, CI and environment secrets, the team password manager, developers'
.envfiles. Restart the apps so they pick up the new value.Check whether the key was used
Provider and in-house logs: requests from unknown addresses, payments, mailings, data access since the moment of the leak. OWASP advises recording who had access to the secret and when it was last rotated.
Find every copy
Run
gitleaks gitover every repository where the key could have ended up, check CI logs and chats. Delete GitHub Actions run logs that expose the secret.Decide whether to rewrite history
According to GitHub's documentation, once the secret is revoked, rewriting is often unnecessary. If you still need it (personal data, a key that cannot be revoked), use
git filter-repo --sensitive-data-removal.Analyse the cause
How did the key get into the code and why was it not caught: add a scanner to pre-commit and CI, enable push protection, update
.env.exampleand the team rules.
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- The hashes of all commits after the leak change: links to commits break, commit signatures are dropped, closed pull requests lose their diffs.
- Branch protection against force pushes has to be disabled temporarily. Colleagues must re-clone or move their branches over with rebase — a single
git pullandgit pushfrom an old clone brings the secret back. - Copies in forks, GitHub cached views and pull requests are removed only through GitHub Support, and in other people's clones — not at all. One more reason why the first step is revoking the key.
- The repository has an
.env.examplewith the full list of variables and explanations;.envand.env.*are in.gitignore. - Secrets are shared through the team password manager, not in chats or email.
- Each environment has its own least-privilege keys; test keys never go to production.
- The app checks required variables at startup and never prints secret values.
- Only
NEXT_PUBLIC_/VITE_variables reach client code, and none of them is a secret. - Secrets are not passed to Docker through
ARGorENV. - gitleaks runs in pre-commit and in CI with
fetch-depth: 0; secret scanning is on for public repositories. - A leaked key is revoked and reissued first; the Git history question comes after.
How this was verified5 · passed2 · partialchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.