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.

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.

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 deployFor 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:
-
Principle of Least Privilege: Each service gets only the secrets it needs. Your frontend API doesn't need database credentials.
-
Audit Everything: Every secret access should be logged. When you get breached, you need to know what was accessed.
-
Rotate Regularly: Set up automatic rotation for all credentials. Quarterly at minimum, monthly is better.
-
Separate by Environment: Never share secrets between development, staging, and production. Use different AWS accounts or namespaces.
-
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!