NodeJSManaging Secrets & API Keys

Managing Secrets & API Keys

A secret committed to source control is a public secret — even in a private repo. GitHub's secret scanning, anyone with repo access, a leaky git log, or a misconfigured CI artifact can expose it. The damage compounds: a leaked database password or API key can persist for years, since services don't know the secret was compromised. This page covers how to load secrets safely via environment variables, the layered tooling available, and what to do when a secret leaks.

The cardinal rule
Never commit secrets to source control — not even in private repos
Once a secret is in git history, it's persistent. `git rm` doesn't remove it from history; `git rebase` is risky and doesn't fully protect against cached clones. Even in a private repository, every developer, CI runner, and contractor with access is a potential vector. The rule: **credentials belong in environment variables or a secrets manager, never in source code or committed files.** Use `git-secrets`, `gitleaks`, or `detect-secrets` in your CI pipeline to catch accidental commits before they reach the remote.
Environment variables — the baseline

JS
// Load from the environment — never hardcode:
const DB_URL      = process.env.DATABASE_URL       // ✅
const JWT_SECRET  = process.env.JWT_SECRET         // ✅
const API_KEY     = process.env.STRIPE_SECRET_KEY  // ✅

// ❌ NEVER:
const DB_URL = 'postgresql://user:password@host/db'
const JWT_SECRET = 'my-secret-key'
dotenv for local development

Bash
npm install dotenv

.env (NEVER commit this file)

JS
DATABASE_URL=postgresql://postgres:password@localhost/myapp
JWT_SECRET=local-dev-secret-replace-in-prod
STRIPE_SECRET_KEY=sk_test_...

app.js — load as early as possible

JS
import 'dotenv/config'    // or: import dotenv from 'dotenv'; dotenv.config()
// After this line, process.env is populated from .env

.gitignore — always

Bash
# Never commit:
.env
.env.local
.env.*.local

# A template with fake values is fine to commit:
.env.example
.env is for local development only — production secrets come from the platform
dotenv is a development convenience that loads a local file into `process.env`. In production, the platform (Kubernetes secrets, AWS SSM, Heroku config vars, Vercel environment) injects real secrets directly into the process environment — no file to lose or commit. The `.env` file is only for local developer machines and is always gitignored. Commit a `.env.example` with placeholder values so new team members know what variables they need to set.
Validating required secrets at startup

config/env.js — fail fast if secrets are missing

JS
const required = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL']

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`)
  }
}

// Export typed config — centralises all env reads:
export const config = {
  dbUrl:     process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  redisUrl:  process.env.REDIS_URL,
  port:      Number(process.env.PORT ?? 3000),
  isProd:    process.env.NODE_ENV === 'production',
}
Fail at startup if required secrets are missing — better than a runtime error mid-request
A missing `JWT_SECRET` won't cause a crash until the first login attempt — potentially minutes after deploy when real users are hitting the app. Validating all required environment variables at startup ensures a misconfigured deployment fails **immediately and loudly** during the boot phase, before any traffic is served. This pattern also centralises all `process.env` reads so you have one place to audit what secrets the app expects.
Secrets managers for production

Service

How it works

AWS Secrets Manager / SSM

Fetch secrets at runtime via SDK; IAM controls access

HashiCorp Vault

Dedicated secrets engine; dynamic credentials support

GCP Secret Manager

Managed secret storage; IAM-gated access

Kubernetes Secrets

Injected as env vars or mounted volumes; encrypt at rest

Doppler / 1Password Secrets

Developer-friendly dashboards that sync to platforms

AWS SSM — fetch a secret at startup

JS
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'

const ssm = new SSMClient({ region: process.env.AWS_REGION })

const { Parameter } = await ssm.send(new GetParameterCommand({
  Name: '/myapp/prod/jwt-secret',
  WithDecryption: true,
}))
process.env.JWT_SECRET = Parameter.Value
Secrets rotation and revocation
  • Rotate regularly — database passwords, API keys, and JWT secrets should change periodically.

  • Revoke immediately when a secret is suspected leaked — don't wait.

  • Use short-lived credentials where the platform supports them (AWS IAM roles, OIDC tokens).

  • Audit access logs — who fetched which secret, when.

  • Never log secrets — strip them from request logs, error details, and debug output.

If a secret leaks

Text
1. Revoke the secret IMMEDIATELY at the provider (GitHub, Stripe, AWS console).
2. Issue a new secret and rotate it into all environments.
3. Audit access logs for the window between creation and revocation.
4. Remove from git history (git filter-repo, BFG) — but assume it was already seen.
5. Post-mortem: how did it leak? Add the detection/prevention control.
Rotate first, investigate second — every minute of delay is more potential access
When you discover a leaked secret, the temptation is to understand the scope before acting. Reverse the order: **revoke and rotate immediately** to stop the bleeding, then investigate what access occurred during the exposure window. Delayed rotation means the attacker (or anyone who found it) retains access while you're still figuring out what happened.
Next
Audit what your packages bring in: [Dependency Vulnerability Scanning](/nodejs/dependency-security).