DEV Community

Alex Chen
Alex Chen

Posted on

The .env File Is Not a Security Strategy

The .env File Is Not a Security Strategy

Your .env file is the first place attackers look. Here's how to actually protect secrets.

The Problem with .env Files

# .env — this is NOT secure!
DATABASE_URL=postgresql://admin:SuperSecret123@db:5432/mydb
STRIPE_SECRET=sk_live_51Habc123...
AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
JWT_SECRET=my-super-secret-jwt-key-12345

# Problems:
# 1. Committed to git (even with .gitignore, accidents happen)
# 2. Shared in screenshots/chat logs
# 3. Deployed in Docker images (docker history shows it!)
# 4. Logged in crash reports
# 5. Visible to anyone with server access
Enter fullscreen mode Exit fullscreen mode

The Real Security Strategy

Layer 1: Never Commit .env Files

# .gitignore — non-negotiable
.env
.env.local
.env.*.local
!.env.example
Enter fullscreen mode Exit fullscreen mode
# Pre-commit hook to catch accidental commits
#!/bin/bash
# .git-hooks/pre-commit
if git diff --cached --name-only | grep -q '\.env'; then
    echo "❌ ERROR: .env file detected in staged changes!"
    exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Layer 2: Use Environment-Specific Configs

// config/index.js — centralized config management
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'development'}` });

module.exports = {
  port: process.env.PORT || 3000,
  database: {
    url: process.env.DATABASE_URL,
    poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
  },
  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
  },

  // Validate required vars at startup
  validate() {
    const required = ['DATABASE_URL', 'JWT_SECRET'];
    const missing = required.filter(key => !process.env[key]);
    if (missing.length > 0) {
      throw new Error(`Missing environment variables: ${missing.join(', ')}`);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Layer 3: Production Secrets Management

Option A: Cloud Secret Managers (Best for production)

// Using AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

async function getSecret(secretName) {
  const client = new SecretsManagerClient();
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return JSON.parse(response.SecretString);
}

// Usage
const secrets = await getSecret('myapp/production');
// { database_url: "...", jwt_secret: "..." }
Enter fullscreen mode Exit fullscreen mode

Option B: HashiCorp Vault (Self-hosted)

# Store secrets in Vault
vault kv put secret/myapp \
  database_url="postgresql://..." \
  jwt_secret="..." \
  stripe_key="sk_live..."

# Retrieve in app
export VAULT_ADDR='https://vault.example.com'
vault kv get -field=database_url secret/myapp
Enter fullscreen mode Exit fullscreen mode

Option C: Encrypted .env (Simplest for small teams)

# Install sops (Mozilla's encrypted env tool)
brew install sops  # or apt install sops

# Create encrypted .env file
sops --encrypt --kms "arn:aws:kms:region:key-id" .env.enc > .env.enc

# In CI/CD:
sops --decrypt --output .env .env.enc
source .env && npm start
Enter fullscreen mode Exit fullscreen mode

Layer 4: Runtime Protection

// Prevent secrets from leaking into logs
const REDACT_KEYS = ['password', 'secret', 'key', 'token', 'credential'];

function redact(obj) {
  if (!obj || typeof obj !== 'object') return obj;

  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => {
      const lowerKey = key.toLowerCase();
      if (REDACT_KEYS.some(r => lowerKey.includes(r))) {
        return [key, '[REDACTED]'];
      }
      return [key, redact(value)];
    })
  );
}

// Use in logging
logger.info('Request data:', redact(req.body));
logger.info('Config loaded:', redact(config));
Enter fullscreen mode Exit fullscreen mode

Layer 5: Rotation & Expiry

// JWT secret rotation support
function verifyToken(token) {
  // Try current secret first
  try { return jwt.verify(token, currentSecret); } catch {}

  // Try previous secret (graceful rotation)
  try { return jwt.verify(token, previousSecret); } catch {}

  throw new AuthError('Invalid token');
}
Enter fullscreen mode Exit fullscreen mode

The .env Template Pattern

# .env.example — commit THIS to git (not .env!)
# Copy this file to .env and fill in your values

# Server
PORT=3000
NODE_ENV=development

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DB_POOL_SIZE=10

# Redis
REDIS_URL=redis://localhost:6379

# Authentication
JWT_SECRET=generate-with:openssl rand -base64 32
JWT_EXPIRES_IN=7d

# External Services
STRIPE_PUBLIC=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
SENDGRID_API_KEY=sg_xxx

# Feature Flags
ENABLE_SIGNUP=true
RATE_LIMIT=100
Enter fullscreen mode Exit fullscreen mode
# Onboarding script for new developers
#!/bin/bash
if [ ! -f .env ]; then
  cp .env.example .env
  echo "✅ Created .env from template"
  echo "⚠️  Edit .env with your values before starting!"
else
  echo ".env already exists"
fi
Enter fullscreen mode Exit fullscreen mode

Docker & .env

# ❌ BAD: Build args expose secrets in image layers
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL  # Visible in `docker history`!

# ✅ GOOD: Only inject at runtime via docker-compose
# docker-compose.yml
services:
  app:
    build: .
    env_file:
      - .env.production  # Not committed to git!
    environment:
      - NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode
# Or use Docker secrets (swarm mode)
services:
  app:
    secrets:
      - db_password
      - jwt_secret

secrets:
  db_password:
    external: true
  jwt_secret:
    external: true
Enter fullscreen mode Exit fullscreen mode

The Security Checklist

  • [ ] .env in .gitignore (always)
  • [ ] .env.example committed (template only, no real values)
  • [ ] Pre-commit hook prevents .env commits
  • [ ] Different credentials per environment (dev/staging/prod)
  • [ ] No secrets in source code or config files
  • [ ] No secrets in Docker images (use runtime injection)
  • [ ] Secrets rotated regularly (especially after breaches)
  • [ ] Access logging for sensitive operations
  • [ ] Secrets not in error messages or logs
  • [ ] CI/CD uses encrypted secrets (GitHub Actions secrets, etc.)
  • [ ] Team members know the security policy

Quick Reference: Where to Store Secrets

Environment Tool Best For
Local dev .env + dotenv Convenience
Staging CI/CD secrets Automated deploys
Production AWS SM / GCP SM / Azure KV Enterprise
Self-hosted HashiCorp Vault Full control
Small team SOPS encrypted files Git-based workflow

How do you manage secrets in your projects? Any horror stories?

Follow @armorbreak for more security tips.

Top comments (1)

Collapse
 
theoephraim profile image
Theo Ephraim

use varlock.dev - its a nice toolkit (open source and free!) to help deal with all of this.