Secrets Management for Node.js and Next.js: Protect Your API Keys in 2026
Introduction
Most startup breaches don't start with a zero-day. They start with a secret that leaked: an AWS key committed to a public repo, a .env file pushed in a hurry, a Stripe key pasted into a frontend bundle. Once an attacker has a valid credential, every other control in your stack — WAF rules, rate limiting, input validation — becomes irrelevant. They're already inside.
The Node.js ecosystem makes this worse. The dotenv culture normalized loading config from a single .env file at the project root, and that file ends up in git more often than teams like to admit. A quick GitHub search for AWS_SECRET_ACCESS_KEY returns thousands of live repositories. Cloud security researchers have repeatedly demonstrated that exposed credentials get weaponized within minutes of being pushed — attackers run automated scanners over public code and commit history continuously.
This guide gives you a complete secrets management strategy for Node.js and Next.js applications: where secrets leak, how to keep them out of your codebase, how to load and validate them safely, how to use a secrets manager in production, and what to do when one leaks anyway. Every section ends with code you can ship today.
The Threat Model: Six Places Secrets Leak
Before fixing anything, understand where the leaks happen. In a typical Node.js/Next.js startup, secrets escape through six channels:
| Leak Vector | How It Happens | Risk |
|-------------|----------------|------|
| Git history | .env committed once, then removed from the working tree — but it lives forever in history | Critical |
| npm packages | Bundler includes config files in the published package | Critical |
| Client bundles | NEXT_PUBLIC_* or hardcoded keys compiled into the browser bundle | Critical |
| Logs & error trackers | Secrets logged in request bodies, stack traces, or debug output | High |
| Docker images | COPY . . bakes .env into image layers; ENV vars are inspectable | High |
| CI/CD logs | Workflow files with plaintext secrets, echo $SECRET in build logs | High |
The common thread: secrets get treated as code instead of as credentials. The fix is a set of rules that make leaking structurally difficult, not just unlikely.
Rule 1: Never Hardcode. Never Commit. Never Fall Back.
The first rule is also the cheapest: a secret is not allowed to exist in source code. Not in a constant, not in a config object, not as a default value in a function parameter.
// [X] DANGEROUS — hardcoded credential
const stripe = new Stripe("sk_live_51Hx...");
// [X] DANGEROUS — fallback hides the problem and ships prod keys to dev
const dbUrl = process.env.DATABASE_URL ?? "postgres://admin:admin@localhost:5432/prod";
// [X] DANGEROUS — "temporary" defaults that graduate to production
const apiKey = process.env.OPENAI_API_KEY || "sk-test-1234";
Fallbacks are the sneakiest of the three. They make the app "work" on your laptop, so the missing environment variable never surfaces — until the same code path runs in production with a default that happens to be real, or with a credential that never gets rotated because nobody knows where it's defined.
The rule: every secret is read from process.env, and every missing secret fails fast at startup. No silent defaults, no empty strings that become auth failures hours later.
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
OPENAI_API_KEY: z.string().min(1),
JWT_SECRET: z.string().min(32), // no short secrets, ever
});
// Fail fast: the process refuses to boot with a missing/invalid secret
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
With this in place, a developer who clones the repo and forgets to create .env.local gets an immediate, explicit error — not a mystery 500 three hours later. Teams that skip schema validation are the teams that discover missing secrets at 2 AM during an incident.
And the .gitignore rule is non-negotiable:
# .gitignore
.env
.env.*
!.env.example
The exception file, .env.example, is checked in deliberately: it documents every variable the app needs, with placeholder values and a comment describing each one. It's the contract between your app and your deployment pipeline.
Rule 2: Keep Secrets Out of Git History
A secret deleted from the working tree but present in git log is a secret that is public. Attackers don't read your latest commit — they clone the repo and walk the entire history, or query GitHub's code search API for known secret patterns.
Prevention: gitleaks in pre-commit and CI
Gitleaks is the standard scanner. Install it as a pre-commit hook and — more importantly — as a CI job that runs on every push and pull request:
# .github/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan full history for secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The CI scan with fetch-depth: 0 matters: it checks the entire history, not just the diff, so a secret added in an earlier commit still gets caught at PR time.
Enable GitHub secret scanning + push protection
On GitHub, turn on Secret Scanning and Push Protection for all repos (free for public repos, available on private repos for GitHub Advanced Security customers). Push Protection blocks commits containing known secret patterns before they land, and Secret Scanning alerts you when a secret is detected — including in forks and past commits. If you're on Forgejo, Gitea, or GitLab, equivalents exist: GitLab has its own secret detection, and Forgejo supports secret scanning via CI. Whatever your platform, scanning must run at the platform level, not just in your head.
Remediation: scrub history with git-filter-repo
If a secret is already in history, deleting the commit isn't enough — you must rewrite history and rotate the credential. Rotation first, cleanup second, in that order:
# 1. ROTATE the secret NOW — assume it's compromised (it is public)
# Regenerate the key, update the secrets manager, redeploy.
# 2. Remove the file from ALL history
git filter-repo --invert-paths --path .env
# 3. Force-push the cleaned history
git remote add origin <new-origin> # filter-repo strips remotes by design
git push origin --force --all
git push origin --force --tags
# 4. Tell every collaborator to re-clone — old clones still contain the secret
git-filter-repo is preferred over filter-branch — it's faster, safer, and removes the old history correctly. But remember: any clone, fork, or CI cache made before the scrub still holds the secret. Rotation is the only real fix. Cleaning history is hygiene; rotating is security.
Rule 3: Use a Secrets Manager at Runtime
Reading secrets from process.env is correct for configuration, but the environment itself becomes the storage problem. On a single VPS, an .env file sitting on disk is one cat away from anyone with shell access — and one misconfigured backup or log shipper away from leaking.
For any team past "one server, one hobby project," move the secrets themselves into a dedicated store:
| Tool | Best For | Notes | |------|----------|-------| | HashiCorp Vault | Self-hosted, multi-cloud, dynamic secrets | Most flexible; you run the infra | | AWS Secrets Manager | Teams already on AWS | Automatic rotation for RDS, IAM | | Google Secret Manager | Teams on GCP | Versioning + IAM built in | | Doppler | Startups that want zero-ops | Syncs to CI and environments | | 1Password/Infisical | Small teams, human-friendly | Good developer UX |
The runtime pattern is the same everywhere: fetch once at boot, cache in memory, never write to disk. Here's the pattern with AWS Secrets Manager:
// src/lib/secrets.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
// In-memory cache — never persist to disk, never log
const cache = new Map<string, string>();
export async function getSecret(name: string): Promise<string> {
if (cache.has(name)) return cache.get(name)!;
const { SecretString } = await client.send(
new GetSecretValueCommand({ SecretId: name })
);
if (!SecretString) throw new Error(`Secret ${name} is empty`);
cache.set(name, SecretString);
return SecretString;
}
// Usage — secrets are fetched, never embedded in source
import { getSecret } from "@/lib/secrets";
export async function createStripeClient() {
return new Stripe(await getSecret("stripe/live/secret_key"));
}
The cache is deliberate: it avoids a network round-trip per request and reduces your blast radius on API quota, while keeping the secret out of process memory for only as long as needed.
Do you need a vault today? Honest answer: if you have fewer than ~10 secrets and a single deployment target, validated process.env + a strict .env.example contract is acceptable — provided the platform injects the variables (Vercel, Railway, Render, a systemd environment file) rather than a committed file. The moment you have multiple environments (dev/staging/prod), multiple services, or any compliance requirement (SOC 2, ISO 27001), move to a manager. The migration cost is a day; the leak cost is your company.
Rule 4: CI/CD Hygiene
CI pipelines are where secrets die: workflows committed with plaintext credentials, secrets echoed into logs, or third-party actions sniffing environment variables. Three rules:
1. Use the platform's secret store, never workflow files. In GitHub Actions, that's ${{ secrets.* }}; in GitLab, protected CI variables; in Forgejo, repository secrets. The value lives in the platform's encrypted store and is masked in logs automatically.
# [X] DANGEROUS — secret in the workflow file
env:
STRIPE_KEY: "sk_live_51Hx..."
# [X] DANGEROUS — debugging secrets into logs
- run: echo "Deploying with key ${{ secrets.STRIPE_KEY }}"
# [OK] Reference the store; never print
- name: Deploy
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
run: ./deploy.sh
2. Vet third-party actions. A compromised action can exfiltrate every secret in the job. Pin actions to full commit SHAs, review the source of anything you add, and keep the action list short.
3. Prefer OIDC over long-lived credentials. The biggest CI secret of all is often a long-lived cloud key with broad IAM permissions, sitting in the secrets store for years. Cloud providers support OIDC federation: the CI platform exchanges a short-lived, workload-scoped token for cloud credentials, with no static secret stored at all.
# GitHub Actions → AWS via OIDC: no AWS keys in the pipeline
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-west-1
The IAM role trusts the GitHub OIDC issuer and scopes permissions per repo and per environment. No AWS_ACCESS_KEY_ID in the secrets store, nothing to rotate, nothing to leak.
Rule 5: Docker Images — Don't Bake Secrets In
Docker is a silent secrets leak. COPY . . into an image copies your .env if it exists in the build context. ENV STRIPE_KEY=... stores the value in the image config, visible to anyone with docker inspect. And secrets set at build time end up in image layers permanently, even if removed later.
# [X] DANGEROUS — .env baked into the layer
COPY . .
# [X] DANGEROUS — secret visible via docker inspect
ENV STRIPE_KEY=sk_live_51Hx...
# [OK] BuildKit secrets — available at build time, never stored in the layer
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=stripe_key \
export STRIPE_KEY=$(cat /run/secrets/stripe_key) && \
./build.sh
# Build with the secret injected from the local machine's env
docker build --secret id=stripe_key,env=STRIPE_KEY -t myapp .
Two companion rules: add .env to .dockerignore so it never enters the build context, and use multi-stage builds so the runtime image contains only the compiled output — no source, no toolchain, no stray config files. Runtime secrets are injected by the orchestrator (Docker --env-file, Kubernetes secret, ECS task definition), never by the image.
Rule 6: Client-Side — NEXT_PUBLIC_ Is Public
In Next.js (and Vite, and every bundler), anything prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time. It is downloaded by every visitor, visible in the Network tab, and indexed by scrapers. It is not a secret.
// [X] DANGEROUS — this ships to the browser
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // it's in the bundle — public by design
);
// [X] DANGEROUS — a real key in a client component
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
NEXT_PUBLIC_SUPABASE_ANON_KEY is designed to be public (it's scoped by Row Level Security). The danger is when teams apply the pattern to real credentials. The mitigation is structural:
- Prefix convention: only publish-safe, capability-scoped values ever get
NEXT_PUBLIC_. Enforce it with a lint rule or a code review checklist item. - Server-only by default: in Next.js, server-only secrets are read in server components, route handlers, and server actions — never in client components. Mark modules with the
server-onlypackage so the bundler fails if a client component imports them. - Test the bundle:
grep -r "sk_live\|AKIA" .next/static/after a build. Zero matches, or you have a leak shipping to production.
Rotation and Incident Response
Assume every secret has a half-life. Rotation is not a chore; it's the control that makes every other leak survivable. The discipline:
- Short-lived credentials by default. Temporary credentials (IAM STS, OIDC, Vault dynamic secrets) expire in minutes or hours. A stolen short-lived token is a non-event; a stolen static key is a breach.
- Automated rotation for anything long-lived. AWS Secrets Manager and Vault both support scheduled rotation for supported services (RDS passwords, etc.). If a secret is over 90 days old and rotation isn't automated, schedule the work.
- The incident playbook. When a secret leaks, in this order: (1) rotate it immediately — treat it as compromised, because it is; (2) revoke and reissue, don't patch around it; (3) audit access logs for the affected resource; (4) check for lateral movement (was the key reused? did the attacker read your secrets store?); (5) update the scan tooling so the same leak is caught earlier next time. Communicate internally with the same bluntness: the leak happened because the process failed, not the person.
Secrets Management Checklist
- [ ] Zero hardcoded secrets in source (
grep -rn "sk_\|AKIA\|password" src/returns nothing actionable) - [ ] Zero default fallbacks for secrets — schema-validated
process.env, fail-fast at boot - [ ]
.envand.env.*in.gitignore;.env.examplecommitted and current - [ ] gitleaks in pre-commit and CI, scanning full history on every push
- [ ] Platform secret scanning + push protection enabled
- [ ] Secrets stored in a manager (Vault, AWS SM, Doppler) for multi-env/production
- [ ] CI uses the platform secret store; workflows contain no plaintext secrets
- [ ] OIDC federation replaces long-lived cloud keys in CI
- [ ] Docker:
.envin.dockerignore, BuildKit--mount=type=secret, multi-stage builds - [ ] No
NEXT_PUBLIC_or bundle-visible real credentials; server-only imports enforced - [ ] Rotation automated for long-lived secrets; all static keys under 90 days old
- [ ] Incident playbook written and tested (rotate → revoke → audit → scan)
Summary
-
Secrets are credentials, not code. They don't belong in source, in fallbacks, in bundles, in images, or in logs — only in environment injection and dedicated stores.
-
Fail fast, always. A validated, schema-checked
process.envturns a missing secret into a clear boot error instead of a production incident. -
History is forever. Assume anything in git history is public. Scan on every push, and when something leaks, rotate first and scrub second.
-
Reduce the blast radius. Short-lived credentials, OIDC in CI, secrets managers at runtime, and
NEXT_PUBLIC_only for values that are safe to publish — each control makes the next leak cheaper. -
Rotation is the control that saves you. A secret that rotates in hours turns a leak into an inconvenience. A secret that never rotates turns a leak into a breach.
Secrets management sits at the intersection of developer workflow and security engineering — which is exactly why it's the highest-leverage fix most startups haven't made. For a deeper look at the supply chain side of this problem — what npm audit misses and how to audit your dependency tree properly — see our npm audit is not enough guide, and the OWASP Top 10 for Node.js Backends for where misconfigured secrets show up in the vulnerability taxonomy.
Next week: Race conditions and business logic flaws — why your auth is only as strong as your state transitions, with exploit patterns and fixes for double-spending, TOCTOU, and broken workflows.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.