DevSecOps
env files
environment variables
secrets management
gitignore
+3 more

Securing .env Files & Environment Variables: The Definitive Guide

SCRs Team
February 14, 2026
14 min read
Share

The .env File Problem

Every week, GitHub detects millions of hardcoded secrets pushed to repositories. The leading source? .env files that were accidentally committed.

SourceSecrets Leaked (2025)
.env files committed to Git42%
Hardcoded in source code28%
Exposed in CI/CD logs15%
Shared via Slack/email9%
Docker images6%

Source: GitGuardian State of Secrets Sprawl 2025


Rule 1: Never Commit .env Files

# .gitignore — MUST include these
.env
.env.local
.env.*.local
.env.production
.env.staging
*.pem
*.key

What If You Already Committed a .env?

# Remove from tracking (keeps the file locally)
git rm --cached .env
echo '.env' >> .gitignore
git add .gitignore
git commit -m "Remove .env from tracking"

# ⚠️ The secret is STILL in Git history!
# You must rotate every credential that was exposed

# To fully remove from history (destructive):
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch .env' \
  --prune-empty --tag-name-filter cat -- --all
git push origin --force --all

Critical: Even after removing the file from history, consider every secret in that file compromised. Rotate all credentials immediately.


Rule 2: Use .env.example (Not .env) for Documentation

# .env.example — Commit this (no real values!)
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
JWT_SECRET=generate-a-random-secret-here
STRIPE_SECRET_KEY=sk_test_your_test_key
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
# .env.local — Never commit this (actual secrets)
DATABASE_URL=postgres://admin:r3alP@ssw0rd@prod-db.internal:5432/myapp
REDIS_URL=redis://:auth-token@redis.internal:6379
JWT_SECRET=a1b2c3d4e5f6...64-char-random-string
STRIPE_SECRET_KEY=sk_live_51ABC123DEF456...
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Rule 3: Validate Environment Variables at Startup

// lib/env.ts — Validate ALL env vars on startup
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().default(3000),
});

// This throws on startup if ANY variable is missing or invalid
export const env = envSchema.parse(process.env);
// Usage — type-safe, validated access
import { env } from '@/lib/env';

const db = new PrismaClient({ datasources: { db: { url: env.DATABASE_URL } } });
// If DATABASE_URL is missing, the app fails at startup — not at runtime

Rule 4: Use a Secrets Vault for Production

HashiCorp Vault

# Store secrets
vault kv put secret/myapp \
  database_url="postgres://..." \
  jwt_secret="..."

# Read in application
vault kv get -format=json secret/myapp | jq -r '.data.data.database_url'

AWS Secrets Manager

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(name: string): Promise<Record<string, string>> {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: name })
  );
  return JSON.parse(response.SecretString!);
}

// Usage
const secrets = await getSecret('myapp/production');
const db = new PrismaClient({ datasources: { db: { url: secrets.DATABASE_URL } } });

Rule 5: Never Log Environment Variables

// ❌ DANGEROUS — secrets end up in log files
console.log('Config:', process.env);
console.log('DB URL:', process.env.DATABASE_URL);
console.log('Starting with JWT secret:', process.env.JWT_SECRET);

// ✅ Log only safe information
console.log('Starting server on port:', process.env.PORT);
console.log('Environment:', process.env.NODE_ENV);
console.log('Database connected:', !!process.env.DATABASE_URL);

Rule 6: Framework-Specific Pitfalls

Next.js

# ❌ NEXT_PUBLIC_ variables are embedded in CLIENT-SIDE JavaScript
NEXT_PUBLIC_API_KEY=sk_secret_key  # EXPOSED TO BROWSER!

# ✅ Server-only variables (no NEXT_PUBLIC_ prefix)
DATABASE_URL=postgres://...
JWT_SECRET=...

# ✅ Only public-safe values get NEXT_PUBLIC_
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

Docker

# ❌ NEVER bake secrets into images
ENV DATABASE_URL=postgres://admin:password@db/myapp

# ✅ Use runtime injection
# docker run -e DATABASE_URL=postgres://... myapp

# ✅ Or Docker Secrets
# docker secret create db_url ./secret.txt

Secret Rotation Checklist

When rotating secrets, follow this order:

  1. Generate new secret
  2. Update in vault/secrets manager
  3. Deploy application with new secret
  4. Verify application works
  5. Revoke old secret
  6. Audit for any remaining usage of old secret
# Generate strong secrets
openssl rand -hex 32          # 64-char hex string
openssl rand -base64 32       # 44-char base64 string
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Environment Variable Security Checklist

  • .env in .gitignore — verified in every repo
  • .env.example committed with placeholder values
  • Environment variables validated at startup (Zod)
  • Production secrets in vault (AWS Secrets Manager, Vault, etc.)
  • No NEXT_PUBLIC_ prefix on server-only secrets
  • No secrets in Dockerfiles or docker-compose.yml
  • No secrets logged via console.log or error handlers
  • Secret rotation schedule (quarterly minimum)
  • GitHub secret scanning alerts enabled
  • Pre-commit hooks to detect secrets (gitleaks, trufflehog)

Advertisement