Environment Variables Best Practices: .env Files, Security & Secrets Management
The myth that silently destroys security teams
“Our .env file is in a private repo, so we’re fine.” This belief caused the 2022 Twilio breach (phishing exposed an employee’s .env), the 2019 Capital One breach (misconfigured IAM credentials), and countless smaller incidents. According to GitGuardian’s 2024 State of Secrets Sprawl report, 12.8 million new secrets were detected on GitHub in 2023 — 45% of them in private repositories.
The problem isn’t usually malicious intent. It’s the gap between what developers think is secure and what actually is. This guide closes that gap.
Key Takeaways
- ✓ Never commit secrets — even to private repos. Commit only
.env.examplewith placeholder values. - ✓ Separate config from secrets — non-sensitive vars (PORT, LOG_LEVEL) can live in .env; credentials belong in a secrets manager.
- ✓ Validate at startup — use Zod or envalid to fail fast with clear errors, not at 3 AM in production.
- ✓ Install pre-commit scanners — gitleaks or truffleHog catch leaks before git push.
- ✓ Use OIDC over long-lived keys — GitHub Actions, GitLab CI, and Bitbucket support keyless authentication to cloud providers.
The .env file hierarchy: what loads when
The dotenv pattern was popularized by Ruby’s Foreman gem in 2012, then ported to Node.js by motdotla in 2013 (now at 33M+ weekly npm downloads). Every major framework adopts it with a specific loading order. Understanding this order prevents the “why is my dev DB showing in production?” nightmare.
# Loading order: later files override earlier ones
# Next.js / Vite / CRA / Laravel loading hierarchy:
.env # Base defaults — SAFE to commit (no secrets)
.env.local # Local machine overrides — in .gitignore
.env.development # Development environment
.env.development.local # Dev overrides — in .gitignore
.env.test # Test environment
.env.production # Production overrides — may commit (no secrets!)
.env.production.local # Production secrets — NEVER commit
# Example: what should live where
# .env (committed):
NODE_ENV=development
APP_NAME=MyApp
LOG_LEVEL=info
PORT=3000
API_TIMEOUT_MS=30000
# .env.local (gitignored — developer's actual secrets):
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_abc123
JWT_SECRET=local-dev-not-real-min-32-chars-abc
# .env.example (committed — documentation template):
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_REPLACE_ME
JWT_SECRET=GENERATE_RANDOM_32_CHARS_MINThe .env.example file is not optional — it’s the contract between your repo and every developer who clones it. A missing variable discovered at 11 PM by a new hire is a preventable outage. When building config objects from environment data, use our JSON Formatter to validate configuration structure before deploying.
Config vs secrets: the distinction that matters
Most teams make one critical mistake: they treat all environment variables as equally sensitive (or equally safe). The correct model has two tiers:
Configuration (safe to version control)
PORT=3000LOG_LEVEL=infoNODE_ENV=productionMAX_CONNECTIONS=10NEXT_PUBLIC_API_URL=https://api.example.comFEATURE_NEW_UI=true
Secrets (must use secrets manager in prod)
DATABASE_URL=postgres://user:pass@host/dbSTRIPE_SECRET_KEY=sk_live_...JWT_SECRET=random-256-bit-keyAWS_SECRET_ACCESS_KEY=...SENDGRID_API_KEY=SG.xxxENCRYPTION_KEY=...
The test: if rotating the variable requires notifying external parties (Stripe, AWS, SendGrid) or revoking access from running systems, it’s a secret. Everything else is configuration. Per the OWASP Application Security Verification Standard 4.0, secrets must be stored with encryption at rest, access auditing, and revocation capability — requirements that .env files cannot satisfy.
Secret scanning: catch leaks before they become breaches
Installing a pre-commit secret scanner is the single highest-ROI security control for most development teams. It costs minutes to set up and prevents multi-day incident responses. GitHub itself runs automated secret scanning across all public repositories and notified 1+ million organizations about detected secrets in 2023 (per GitHub’s Security blog).
| Tool | Type | GitHub ⭐ | Detects | Best For |
|---|---|---|---|---|
| truffleHog | Git/File scan | 17k+ | 700+ secret types | Deep git history scanning |
| gitleaks | Git/File scan | 18k+ | 200+ regex patterns | CI/CD pipeline gates, pre-commit |
| detect-secrets | File scan | 4k+ | 30+ patterns + custom | Yelp-maintained, fine-grained allowlists |
| GitGuardian | SaaS + git | N/A (SaaS) | 350+ types | GitHub/GitLab org-wide monitoring |
| Semgrep | Code + config | 10k+ | Custom SAST rules | Broad SAST including env leaks |
# Install gitleaks as a pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
# Or install globally and add to .git/hooks/pre-commit:
# brew install gitleaks
# gitleaks detect --source . --verbose
# GitHub Actions: scan on every push
name: Security
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history, not just latest commit
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}Note fetch-depth: 0 in the GitHub Actions config — without full history, the scanner only checks the latest commit, missing secrets buried in earlier commits. For analyzing security headers and HTTPS configurations on deployed applications, see our HTTPS and TLS guide.
Secrets managers compared: beyond .env files
For production systems, .env files fail on three dimensions: they have no access auditing (who read the secret?), no rotation automation, and no encryption at rest beyond filesystem permissions. Dedicated secrets managers address all three. Per the 2025 HashiCorp State of Cloud Strategy Survey, 67% of organizations now use a centralized secrets manager in production, up from 41% in 2022.
| Service | Cloud | Auto-Rotation | Audit Log | Dynamic Secrets | Price | Best For |
|---|---|---|---|---|---|---|
| AWS Secrets Manager | AWS | ✅ Automatic | ✅ CloudTrail | ❌ | $0.40/secret/month | AWS-native apps, Lambda |
| HashiCorp Vault | Any | ✅ Automatic | ✅ Built-in | ✅ DB/PKI/AWS | Free (self-hosted) / $0.03/hr HCP | Multi-cloud, on-prem, dynamic creds |
| Google Secret Manager | GCP | ✅ Via Cloud Functions | ✅ Cloud Audit Logs | ❌ | $0.06/10k ops | GCP-native apps, Cloud Run |
| Azure Key Vault | Azure | ✅ Automatic | ✅ Monitor | ❌ | $0.03/10k ops | Azure-native apps, AKS |
| Doppler | Any | ⚠️ Manual | ✅ Change history | ❌ | Free → $6.99/user/month | Team workflows, multi-env sync |
| .env files | Local only | ❌ Manual only | ❌ None | ❌ | Free | Local development ONLY |
HashiCorp Vault’s dynamic secrets feature is worth calling out specifically. Instead of storing a static database password, Vault generates a short-lived credential on demand (e.g., valid for 1 hour) directly from your Postgres or MySQL server. When the lease expires, Vault automatically revokes the credential. Even if the credential leaks from an application’s memory or logs, it’s expired. This eliminates the rotation problem entirely for supported backends.
# Fetch a secret from AWS Secrets Manager at runtime
# Node.js — no .env file needed in production
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'
const client = new SecretsManagerClient({ region: 'us-east-1' })
async function getSecret(secretName: string): Promise<string> {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
)
return response.SecretString ?? ''
}
// At startup:
const dbUrl = await getSecret('production/myapp/database-url')
const stripeKey = await getSecret('production/myapp/stripe-secret-key')
// IAM policy grants ONLY this Lambda/ECS task read access
// to these specific secrets — least privilege enforced.Type-safe validation: fail fast, fail loudly
Environment variables arrive as strings. A missing PORT silently becomes NaN. An empty DATABASE_URL crashes on the first query, not at startup. Startup-time validation with Zod (13M+ weekly npm downloads) turns runtime mysteries into clear boot errors.
// config.ts — validate env vars at process startup
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url().startsWith('postgres'),
REDIS_URL: z.string().url().optional(),
// Secrets — validate format, not value
STRIPE_SECRET_KEY: z.string()
.refine(k => k.startsWith('sk_live_') || k.startsWith('sk_test_'), {
message: 'Must be a Stripe secret key (sk_live_ or sk_test_)',
}),
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
// Multi-value — parse from comma-separated string
CORS_ORIGINS: z.string()
.transform(s => s.split(',').map(o => o.trim()))
.pipe(z.string().url().array().min(1)),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
})
// Throws ZodError immediately if any var is missing/invalid
// TypeScript infers exact types: config.PORT is number, not string
export const config = envSchema.parse(process.env)
// For Next.js App Router, use @t3-oss/env-nextjs for
// build-time type safety on NEXT_PUBLIC_ variables.For Python, pydantic-settings provides the same pattern: define a BaseSettings class with field types, and Pydantic validates and coerces os.environ at import time. The runtime fail-fast approach is consistent across languages.
OIDC federation: the future of keyless deployments
The highest-risk environment variable pattern is long-lived cloud credentials (AWS_ACCESS_KEY_ID, GOOGLE_APPLICATION_CREDENTIALS) stored in CI/CD secrets. These keys don’t expire, and a leaked CI/CD secret provides persistent cloud access. OpenID Connect (OIDC) federation eliminates the need to store any cloud credentials at all.
# GitHub Actions — OIDC authentication to AWS (no stored credentials)
# .github/workflows/deploy.yml
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed!
# GitHub requests a JWT from its OIDC provider,
# AWS verifies it and issues a 1-hour STS token.
- name: Deploy to ECS
run: aws ecs update-service --cluster prod --service api --force-new-deployment
# The IAM trust policy on github-actions-deploy role:
# {
# "Effect": "Allow",
# "Principal": { "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com" },
# "Condition": { "StringLike": { "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main" } }
# }GitHub, GitLab, CircleCI, and Bitbucket Pipelines all support OIDC federation with AWS, GCP, and Azure. For CI/CD security practices, see our guide to CI/CD pipeline security.
Naming conventions and organization
Consistent naming prevents collisions between services and makes variables self-documenting. The universal convention is SCREAMING_SNAKE_CASE, established by POSIX and followed by every major runtime.
# DO: Service-prefixed names prevent collisions
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SENDGRID_API_KEY=SG.xxx
[email protected]
AWS_REGION=us-east-1
AWS_S3_BUCKET=myapp-uploads
# (Don't store AWS_ACCESS_KEY_ID in files — use OIDC or instance roles)
# DO: Explicit boolean values (pick a convention, stick to it)
FEATURE_NEW_CHECKOUT=true # string "true"/"false"
ENABLE_RATE_LIMITING=1 # "1"/"0"
# DON'T: Ambiguous names
KEY=sk_live_... # What service?
SECRET=abc123 # What kind?
DB=postgres://localhost/app # Use DATABASE_URL (conventional name)
# DON'T: Spaces around = sign (breaks dotenv parsers)
DATABASE_URL = postgres://localhost/db # WRONG
DATABASE_URL=postgres://localhost/db # CORRECT
# Security: use a password generator for secrets
# Target: 256+ bits of entropy minimum for signing keys
# 32 random bytes = openssl rand -hex 32Generate cryptographically strong secrets for JWT signing and encryption keys with our Password Generator, which uses the browser’s crypto.getRandomValues() API — no server-side processing, no logging.
Framework browser-exposure prefixes
Every framework that supports SSR or static generation needs a way to distinguish server-only variables from ones safe for the browser bundle. Exposing the wrong variable to the browser is a very common source of credential leaks — the secret ends up in every user’s browser JavaScript.
| Framework | Client prefix | Access method | Server-only access |
|---|---|---|---|
| Next.js | NEXT_PUBLIC_ | process.env | Server Components, API routes |
| Vite | VITE_ | import.meta.env | Vite server plugins only |
| Create React App | REACT_APP_ | process.env | None (CSR only) |
| Remix | None (explicit) | process.env in loaders | Loaders/actions (server) |
| SvelteKit | PUBLIC_ | $env/static/public | $env/static/private |
| Astro | PUBLIC_ | import.meta.env | .astro files, API routes |
SvelteKit’s approach is the most secure by default: $env/static/private throws a compile-time error if you try to import it from a client-side module, making accidental exposure impossible at build time. Next.js 14+ App Router with Server Components offers similar guarantees for variables accessed only in server components.
Production security checklist
Add .env*, .env.local, .env.*.local to .gitignore
Run git log --all -- "**/.env*" to confirm no historical commits.
Commit .env.example with placeholder values
Use REPLACE_ME or sk_test_REPLACE_ME as placeholders — never real values, not even dev values.
Install gitleaks pre-commit hook
CI gate as well — some developers bypass hooks with git commit --no-verify.
Use different secrets per environment
Dev, staging, and production must never share database passwords or API keys.
Enable GitHub Secret Scanning
Free on all public repos, available on GitHub Advanced Security for private repos.
Validate all variables at startup with Zod or envalid
Test the validation in CI by omitting one required variable — confirm it throws.
Never log environment variables
Add redaction patterns to your logger: pino has built-in redact option.
Use OIDC federation for CI/CD cloud access
Eliminates stored cloud credentials from secrets stores entirely.
Document rotation schedule per secret type
Track in an internal runbook with owner, last rotated date, and SLA.
Frequently asked questions
What is the difference between a secret and a configuration environment variable?▼
Configuration variables define behavior (PORT=3000, LOG_LEVEL=info, MAX_CONNECTIONS=10) and are safe to commit to version control. Secrets are credentials that grant access or carry risk if exposed: API keys, database passwords, JWT signing secrets, OAuth client secrets, and private keys. Secrets need different handling: encrypted storage, access auditing, rotation schedules, and never logging. The rule of thumb: if rotating a variable would require informing external parties or revoking access, it is a secret. Mixing secrets with config in the same .env file is the root cause of most accidental credential leaks.
Is it safe to commit a .env file to a private GitHub repository?▼
No. Private repositories are frequently made public by accident, forked by collaborators, or accessed via leaked tokens. According to GitGuardian's 2024 State of Secrets Sprawl report, 12.8 million new secrets were detected on GitHub in 2023, with 45% found in private repositories. Once a secret is in git history, removing it with git filter-branch does not guarantee safety — anyone who cloned the repo before removal has the secret. The correct pattern: commit only .env.example with placeholder values, and store actual secrets in a password manager or dedicated secrets manager.
What is the NEXT_PUBLIC_ prefix in Next.js and why does it exist?▼
Next.js inlines environment variables at build time into the client-side JavaScript bundle. The NEXT_PUBLIC_ prefix is a security gate: only variables with this prefix are exposed to browser code. Without it, variables are server-only (API routes, Server Components, getServerSideProps). Vite uses VITE_ for the same purpose, Create React App uses REACT_APP_. Critical rule: never prefix secrets with NEXT_PUBLIC_. A database password or private API key with NEXT_PUBLIC_ becomes visible in the browser's JavaScript bundle, readable by anyone who opens DevTools. Public API URLs, analytics IDs, and feature flags are fine candidates for NEXT_PUBLIC_.
How should I handle environment variables in Docker and Kubernetes?▼
In Docker: use docker run -e VAR=value or env_file in docker-compose.yml for development. Never bake secrets into image layers with Dockerfile ENV instructions — they are visible in docker history. For Kubernetes: use Secrets (base64-encoded, not encrypted by default) and consider encrypting etcd at rest or using External Secrets Operator to sync from AWS Secrets Manager, Vault, or GCP Secret Manager. Kubernetes Secrets exposed as environment variables are visible to any process in the pod, so use volume mounts for high-sensitivity secrets. Per the CNCF 2025 survey, 38% of Kubernetes clusters still store secrets as plaintext in etcd.
What should I do if I accidentally committed a secret to git?▼
Assume the secret is compromised regardless of how quickly you act. Follow this sequence: (1) IMMEDIATELY revoke and rotate the secret — change the API key, reset the database password, generate a new JWT secret. Do this before anything else. (2) Remove the secret from git history using BFG Repo Cleaner (faster than git filter-branch) or git filter-repo. (3) Force-push the cleaned history to all remotes. (4) Notify all collaborators to re-clone — cached clones still have the secret. (5) Check your cloud provider's audit logs for unauthorized API calls in the window between commit and rotation. (6) Add pre-commit hooks (gitleaks detect, truffleHog) to prevent recurrence. The 2022 Uber breach and 2019 Capital One breach both involved exposed cloud credentials.
How do I validate environment variables at startup?▼
Use a schema validation library to parse process.env at startup and throw immediately if required variables are missing or malformed. In Node.js/TypeScript, zod is the most popular choice (over 13M weekly npm downloads): const config = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000), JWT_SECRET: z.string().min(32) }).parse(process.env). This converts string values to typed values (PORT becomes number, not string), applies defaults, and throws a descriptive error at boot rather than a cryptic runtime failure. Alternatives include envalid (Node.js), pydantic BaseSettings (Python), and @t3-oss/env-nextjs for Next.js App Router with compile-time type safety.
What is the correct way to pass environment variables in GitHub Actions?▼
Store secrets in GitHub Settings → Secrets and Variables → Actions. Reference them as ${{ secrets.SECRET_NAME }} in workflow YAML. Use them as env: blocks at the step level (not the job level) to minimize scope. Never echo secrets in run: steps — they are masked in logs but bash -x can reveal them. For environment-specific secrets, use GitHub Environments (Settings → Environments) which require review approvals before production deployments. Use OIDC federation instead of long-lived secrets where possible: GitHub Actions can request temporary AWS/GCP/Azure tokens via OIDC without storing any credentials at all, which is the current gold standard per GitHub's own security hardening guide (2025).
How often should I rotate secrets?▼
Rotation frequency depends on the secret type and risk profile. API keys for third-party services: every 90 days minimum, or immediately after any team member departure. Database passwords: every 6-12 months for low-risk systems, every 30-90 days for PCI/HIPAA environments. JWT signing secrets: rotate via key versioning (keep old key for token validation for 24h after rotation to avoid invalidating active sessions). Service account credentials: use short-lived tokens via OIDC or dynamic secrets (HashiCorp Vault) whenever possible — these expire automatically, making rotation a non-issue. NIST SP 800-63B (2024 update) actually removed mandatory periodic password rotation for humans, but for machine credentials, rotation remains a core defense-in-depth control.
BytePane tools for secure development
Generate cryptographically strong secrets with the Password Generator — uses crypto.getRandomValues(), nothing is sent to any server. Decode and inspect JWT tokens (including the exp claim) with the JWT Decoder. Generate secure UUIDs for API keys with the UUID Generator.
Related articles
OWASP Top 10 (2025)
Critical web application security risks analyzed from 2.8M apps.
SSH Keys Guide
Key generation, ssh-agent, config management, and rotation.
CI/CD Pipeline Guide
GitHub Actions, Jenkins, GitLab CI — pipeline security patterns.
Docker Container Best Practices
Multi-stage builds, non-root users, and secrets in containers.