DEV Community

Alex Chen
Alex Chen

Posted on

The .env File Is Not a Security Strategy

The .env File Is Not a Security Strategy

Every week, another company leaks secrets through environment variables. Here's how to stop being one of them.

The Problem

# Your .env file (NEVER do this)
DATABASE_URL=postgres://admin:password123@db.example.com/myapp
STRIPE_SECRET_KEY=sk_live_abc123...
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
JWT_SECRET=my-super-secret-jwt-key-12345
SENDGRID_API_KEY=SG.abc123...
Enter fullscreen mode Exit fullscreen mode

One git add . with a missing .gitignore entry. One docker commit that includes the env layer. One debug log that dumps process.env.

Your secrets are now on GitHub. Forever.

The Hard Truth About .env Files

.env files give you a false sense of security:

Myth Reality
".env is excluded from git" Until someone forgets .gitignore
"Environment variables are secure" They're visible in process managers, crash dumps, child processes
"Docker secrets are safe" They're in image layers if you use ENV
"CI/CD masks secrets" Only in logs. They're still in memory during the run

What to Do Instead

Layer 1: Secret Manager

// secrets.js — Load from environment OR secret manager
const AWS = require('@aws-sdk/client-secrets-manager');

class Secrets {
  constructor() {
    this.cache = new Map();
    this.ttl = 5 * 60 * 1000; // 5 min cache
  }

  async get(key) {
    // Check cache first
    const cached = this.cache.get(key);
    if (cached && Date.now() - cached.time < this.ttl) {
      return cached.value;
    }

    // Try environment first (for local dev)
    if (process.env[key]) {
      return process.env[key];
    }

    // Fall back to secret manager
    try {
      const client = new AWS.SecretsManager({});
      const response = await client.getSecretValue({
        SecretId: `myapp/${key}`,
      });

      const value = response.SecretString;
      this.cache.set(key, { value, time: Date.now() });
      return value;
    } catch (err) {
      throw new Error(`Secret "${key}" not found in env or secret manager`);
    }
  }
}

module.exports = new Secrets();
Enter fullscreen mode Exit fullscreen mode

Layer 2: Git Pre-Commit Hook

#!/bin/bash
# .git/hooks/pre-commit — Block secrets from being committed

# Check staged files for common secret patterns
PATTERNS=(
  'password\s*[:=]\s*["\x27][^\s"\x27]{8,}'
  'api[_-]?key\s*[:=]\s*["\x27][^\s"\x27]{10,}'
  'secret\s*[:=]\s*["\x27][^\s"\x27]{8,}'
  'AKIA[0-9A-Z]{16}'
  'sk_live_[a-zA-Z0-9]{20,}'
  '-----BEGIN (RSA |DSA |EC )?PRIVATE KEY-----'
)

STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FOUND_SECRETS=false

for file in $STAGED; do
  if [ ! -f "$file" ]; then continue; fi

  for pattern in "${PATTERNS[@]}"; do
    if grep -qiE "$pattern" "$file"; then
      echo "🔒 WARNING: Possible secret detected in $file"
      echo "   Pattern: $pattern"
      FOUND_SECRETS=true
    fi
  done
done

if [ "$FOUND_SECRETS" = true ]; then
  echo ""
  echo "❌ Commit blocked: Possible secrets detected."
  echo "   Use --no-verify to bypass (NOT recommended)."
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Layer 3: Environment-Specific Configs

// config.js — Type-safe, environment-aware configuration
const z = require('zod');

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().optional(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

function loadConfig() {
  // Validate environment variables at startup
  const result = envSchema.safeParse(process.env);

  if (!result.success) {
    console.error('❌ Invalid environment configuration:');
    result.error.errors.forEach(err => {
      console.error(`   ${err.path.join('.')}: ${err.message}`);
    });
    process.exit(1); // Fail fast
  }

  return {
    ...result.data,
    isDev: result.data.NODE_ENV === 'development',
    isProd: result.data.NODE_ENV === 'production',
  };
}

const config = loadConfig();
module.exports = config;
Enter fullscreen mode Exit fullscreen mode

Docker: The Right Way

# ❌ WRONG: Secrets in ENV
ENV DATABASE_URL=postgres://admin:password@db/myapp

# ❌ WRONG: Secrets in ARG (visible in image history)
ARG DATABASE_URL

# ✅ CORRECT: Runtime-only environment variables
# Pass at docker run:
# docker run -e DATABASE_URL=$DATABASE_URL myapp

# ✅ BEST: Docker secrets (Swarm/Kubernetes)
# secrets are mounted as files, never in env vars
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
    environment:
      - DATABASE_HOST=db
      - DATABASE_USER=admin
      # Password comes from file, not env
      - DATABASE_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt  # NOT committed to git
Enter fullscreen mode Exit fullscreen mode
// Read secret from file
const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();
Enter fullscreen mode Exit fullscreen mode

CI/CD: Preventing Leaks

GitHub Actions

# Use GitHub Secrets, never hardcode
jobs:
  deploy:
    steps:
      - uses: actions/checkout@v4

      # ✅ Good: Use secrets context
      - run: npm run deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}
          DB_URL: ${{ secrets.DATABASE_URL }}

      # ❌ Bad: Never use env context (visible in logs)
      # - run: echo $API_KEY
Enter fullscreen mode Exit fullscreen mode

Scan for Existing Leaks

# Install git-secrets (Amazon's tool)
git clone https://github.com/awslabs/git-secrets.git
cd git-secrets && make install

# Scan entire repo history
cd your-repo
git secrets --scan-history

# Register patterns to watch for
git secrets --register-aws
git secrets --add 'sk_live_[a-zA-Z0-9]{20,}'
Enter fullscreen mode Exit fullscreen mode
# Or use truffleHog (open source secret scanner)
pip install trufflehog
trufflehog --regex --entropy=True git://github.com/yourorg/yourrepo.git
Enter fullscreen mode Exit fullscreen mode

If You Already Leaked a Secret

# 1. Rotate the secret IMMEDIATELY
#    Don't just remove it from git — it's already in history

# 2. Remove from git history
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch .env' \
  --prune-empty --tag-name-filter cat -- --all

# 3. Force push (coordinate with team first!)
git push origin --force --all

# 4. Add to .gitignore
echo ".env" >> .gitignore
echo ".env.*" >> .gitignore
echo "!/.env.example" >> .gitignore

# 5. Scan for the leaked secret elsewhere
# Check: GitHub search, Pastebin, other repos
Enter fullscreen mode Exit fullscreen mode

The Checklist

  • [ ] .env in .gitignore (and .env.* except .env.example)
  • [ ] .env.example committed with placeholder values
  • [ ] No secrets in Dockerfile (ENV/ARG)
  • [ ] Pre-commit hook scans for secrets
  • [ ] CI/CD uses secret managers, not env vars
  • [ ] Zod validation at app startup
  • [ ] git filter-branch history cleaned if ever leaked
  • [ ] Secret rotation schedule (every 90 days)
  • [ ] Audit log for secret access (production)

A .env file is a development convenience, not a security boundary. Treat it accordingly.


Follow @armorbreak for more production-ready security practices.

Top comments (0)