jsmanifest logojsmanifest

Secrets Management in Node.js Applications

Secrets Management in Node.js Applications

Learn how to properly manage secrets in Node.js applications—from development .env files to production vault solutions. Includes practical examples of AWS Secrets Manager, HashiCorp Vault, and runtime secret rotation.

While I was looking over some production incidents the other day, I noticed a pattern that made me cringe. Three separate outages in the past month—all caused by exposed API keys, hardcoded database credentials, or secrets that leaked into version control. I was once guilty of the same mistakes early in my career, thinking "I'll fix this before production" only to forget completely.

Little did I know that secrets management would become one of the most critical aspects of building production Node.js applications. Let me share what I've learned about handling secrets properly.

Why Hardcoding Secrets Will Destroy Your Production App

Here's the thing—I've seen developers commit code like this more times than I care to admit:

// DON'T DO THIS!
const dbConfig = {
  host: 'prod-db.company.com',
  user: 'admin',
  password: 'SuperSecret123!',
  database: 'production'
};
 
const stripeKey = 'sk_live_51HxRealKeyHere...';

The problem isn't just that these secrets end up in Git history forever. It's that you've now hardcoded your entire security posture into your codebase. When someone leaves the company, you can't rotate credentials without deploying new code. When a key gets compromised, you're racing against attackers to push a fix.

I cannot stress this enough! Hardcoded secrets create a single point of failure that combines your code security with your infrastructure security. In other words, you need both to fail for a breach to occur, but you've made it so only one needs to fail.

The Evolution: From .env Files to Vault Solutions

When I finally decided to take secrets seriously, I started with the typical approach—environment variables and .env files:

// .env file (NEVER commit this!)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=my-super-secret-jwt-key
 
// app.js
require('dotenv').config();
 
const dbUrl = process.env.DATABASE_URL;
const stripeKey = process.env.STRIPE_SECRET_KEY;

This works great for local development, and it's a massive improvement over hardcoding. But in production, .env files create their own problems:

  • They're still files that need to be deployed
  • No audit trail of who accessed what
  • No automatic rotation
  • Manual process to update across multiple servers

Luckily we can move to vault solutions that solve these problems. Let's look at how to build a proper secrets loader.

Secrets management architecture diagram showing the flow from vault to application

Building a Secure Secrets Loader in Node.js

Here's a practical secrets loader I built that works across different environments. The key insight is that secrets should be loaded once at startup, cached in memory, and refreshed on a schedule:

// secrets-manager.ts
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
 
interface SecretCache {
  value: Record<string, string>;
  timestamp: number;
  ttl: number;
}
 
class SecretsManager {
  private client: SecretsManagerClient;
  private cache: Map<string, SecretCache> = new Map();
  private readonly DEFAULT_TTL = 3600000; // 1 hour
 
  constructor(region: string = 'us-east-1') {
    this.client = new SecretsManagerClient({ region });
  }
 
  async getSecret(secretName: string, ttl: number = this.DEFAULT_TTL): Promise<Record<string, string>> {
    const cached = this.cache.get(secretName);
    const now = Date.now();
 
    // Return cached value if still valid
    if (cached && (now - cached.timestamp) < cached.ttl) {
      return cached.value;
    }
 
    try {
      const command = new GetSecretValueCommand({ SecretId: secretName });
      const response = await this.client.send(command);
 
      if (!response.SecretString) {
        throw new Error(`Secret ${secretName} has no string value`);
      }
 
      const secretValue = JSON.parse(response.SecretString);
 
      // Update cache
      this.cache.set(secretName, {
        value: secretValue,
        timestamp: now,
        ttl
      });
 
      return secretValue;
    } catch (error) {
      // If fetch fails but we have stale cache, use it
      if (cached) {
        console.warn(`Failed to refresh secret ${secretName}, using stale cache`);
        return cached.value;
      }
      throw error;
    }
  }
 
  // Force refresh a specific secret
  async refreshSecret(secretName: string): Promise<void> {
    this.cache.delete(secretName);
    await this.getSecret(secretName);
  }
 
  // Clear all cached secrets
  clearCache(): void {
    this.cache.clear();
  }
}
 
// Singleton instance
export const secretsManager = new SecretsManager();
 
// Usage in your application
export async function initializeApp() {
  const dbSecrets = await secretsManager.getSecret('production/database');
  const apiSecrets = await secretsManager.getSecret('production/api-keys');
 
  return {
    database: {
      host: dbSecrets.host,
      user: dbSecrets.username,
      password: dbSecrets.password,
      database: dbSecrets.database
    },
    stripe: apiSecrets.stripeKey,
    jwt: apiSecrets.jwtSecret
  };
}

The beauty of this approach is the caching layer. You're not hitting your vault service on every request, which would be a disaster for performance and cost. Instead, you refresh secrets on a schedule and fall back to stale cache if the refresh fails.

Vault Integration: AWS Secrets Manager vs HashiCorp Vault vs Azure Key Vault

I've worked with all three major vault solutions in production, and here's what I learned about each:

AWS Secrets Manager is wonderful if you're already on AWS. The SDK integration is seamless, automatic rotation works great for RDS databases, and pricing is straightforward ($0.40 per secret per month plus API calls). The downside? It's AWS-specific, so multi-cloud deployments get messy.

HashiCorp Vault gives you the most flexibility. It's open source, runs anywhere, and has incredible features like dynamic secrets that are generated on-demand. But you have to run and maintain the Vault cluster yourself, which is non-trivial. I once spent two weeks debugging a Vault cluster that kept unsealing itself.

Azure Key Vault is fantastic if you're in the Microsoft ecosystem. The integration with Azure services is tight, and the access control through Azure AD is elegant. But the learning curve for the SDK is steeper than AWS.

My recommendation? Start with your cloud provider's native solution. You can always migrate later, but the time-to-value is much faster when you use what's already there.

Comparison chart of different vault solutions

Runtime Secret Rotation Without Downtime

Here's where things get fascinating! The real power of vault solutions is automatic rotation. But you need to handle rotation in your application code without restarting:

// secret-rotation.ts
import { secretsManager } from './secrets-manager';
import { EventEmitter } from 'events';
 
class RotationManager extends EventEmitter {
  private rotationIntervals: Map<string, NodeJS.Timeout> = new Map();
 
  startRotation(secretName: string, intervalMs: number = 3600000) {
    // Clear existing rotation if any
    this.stopRotation(secretName);
 
    const interval = setInterval(async () => {
      try {
        console.log(`Rotating secret: ${secretName}`);
        await secretsManager.refreshSecret(secretName);
        this.emit('rotated', secretName);
      } catch (error) {
        console.error(`Failed to rotate ${secretName}:`, error);
        this.emit('rotation-failed', secretName, error);
      }
    }, intervalMs);
 
    this.rotationIntervals.set(secretName, interval);
  }
 
  stopRotation(secretName: string) {
    const interval = this.rotationIntervals.get(secretName);
    if (interval) {
      clearInterval(interval);
      this.rotationIntervals.delete(secretName);
    }
  }
 
  stopAll() {
    this.rotationIntervals.forEach(interval => clearInterval(interval));
    this.rotationIntervals.clear();
  }
}
 
export const rotationManager = new RotationManager();
 
// Listen for rotation events to update application state
rotationManager.on('rotated', async (secretName) => {
  if (secretName === 'production/database') {
    // Recreate database connection pool with new credentials
    await recreateDatabasePool();
  }
});

This pattern lets you rotate secrets without downtime. The application continues using the old credentials while fetching new ones, then switches over atomically.

Secrets in CI/CD: GitHub Actions, Docker, and Kubernetes

Your CI/CD pipeline needs secrets too, but the approach is different. In GitHub Actions, I use repository secrets that get injected as environment variables:

# .github/workflows/deploy.yml
name: Deploy to Production
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      
      - name: Deploy application
        run: |
          # Application pulls secrets from AWS Secrets Manager at runtime
          # No secrets embedded in the Docker image!
          npm run deploy

For Kubernetes, use Secrets objects or integrate with External Secrets Operator to sync from your vault. Never bake secrets into container images.

5 Production-Ready Patterns for Secret Management

After years of production experience, here are my non-negotiables:

  1. Principle of Least Privilege: Each service gets only the secrets it needs. Your frontend API doesn't need database credentials.

  2. Audit Everything: Every secret access should be logged. When you get breached, you need to know what was accessed.

  3. Rotate Regularly: Set up automatic rotation for all credentials. Quarterly at minimum, monthly is better.

  4. Separate by Environment: Never share secrets between development, staging, and production. Use different AWS accounts or namespaces.

  5. Plan for Disasters: Have a runbook for "someone committed secrets to Git" and "a key was leaked publicly". Practice it.

From Development to Production: A Complete Secrets Strategy

Here's how I structure secrets across environments now:

For local development, use .env files that are git-ignored. Include a .env.example with fake values so new developers know what's needed.

For CI/CD, use your platform's secret management (GitHub Secrets, GitLab CI/CD variables, etc.) and grant minimal access.

For production, use a proper vault solution with automatic rotation, audit logging, and access controls. Never use environment variables injected at deploy time—pull them dynamically at runtime.

The transition from development to production should be seamless because your application uses the same secretsManager interface everywhere. Only the backing store changes.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!