The risk of hardcoded secrets
A developer puts a Stripe API key in the code. The code goes to GitHub. A bot scrapes GitHub. An hour and a half later, your account is drained.
This happens thousands of times a month. Yet we still see it in production codebases.
Secrets management and environment configuration aren't glamorous. They feel like admin work. But they're essential for your SaaS's security, your customers, and your business.
In this article, we'll show you how to manage secrets safely, organize configuration per environment, and handle compliance and rotation. We go beyond "put it in .env" — we build an approach that's scalable, secure, and maintainable.
The problem with environment variables
Many SaaS applications use .env files. This works for local development, but falls apart once you go to production:
# .env (NEVER in git!)
DATABASE_URL=postgresql://user:password@localhost/mydb
STRIPE_API_KEY=sk_live_abcd1234...
SENDGRID_API_KEY=SG.xyz123...
JWT_SECRET=my-super-secret-key-that-i-used-locally
The problems:
- Shared secrets: same key in dev, test, staging, and production
- No rotation: how do you replace a key without downtime?
- No audit trail: who had access to which secrets, and when?
- Overpowered developers: junior developers have the same production secret as architects
- Git leaks: accident, a secret ends up in your history
- No expiration: secrets live forever
This is the difference between a sloppy system and a professional-grade setup.
The four pillars of secrets management
1. Centralization
All your secrets in one place, not scattered across files, databases, and Slack messages.
Bad: Distributed
.env → ENV vars → Config files → Hardcoded strings → Slack messages → Developer notes
Good: Centralized
Secrets Manager (AWS Secrets Manager, HashiCorp Vault, Doppler, etc)
↓
Applications (via API or environment injection)
2. Principle of Least Privilege (PoLP)
A microservice doesn't need to know your partner API's Stripe key.
// ❌ Bad: everything together
const stripe = require('stripe')(process.env.STRIPE_API_KEY);
const sendgrid = require('@sendgrid/mail')(process.env.SENDGRID_API_KEY);
const twilio = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
async function processPayment() {
// All three integrations available here, even though we only need one
await stripe.charges.create(...);
}
// ✅ Good: service gets only what it needs
class PaymentService {
constructor(stripeKey) {
this.stripe = require('stripe')(stripeKey);
}
async processPayment() {
// Sendgrid and Twilio aren't available here
return this.stripe.charges.create(...);
}
}
At the environment level:
# kubernetes secrets per namespace
---
apiVersion: v1
kind: Secret
metadata:
namespace: payment-service
name: stripe-keys
data:
api_key: <base64>
---
apiVersion: v1
kind: Secret
metadata:
namespace: notification-service
name: sendgrid-keys
data:
api_key: <base64>
# payment-service DOES NOT see sendgrid-keys
3. Rotation (without downtime)
You must be able to replace secrets without downtime.
Scenario:
- One of your developers leaves the company → you must replace all secrets
- A Stripe key is leaked → you must disable it and get a new one
- Security audit requires monthly rotation → automation is needed
Strategy: Blue-green secrets
// Secrets manager supports multiple versions
// "Staging" (blue) → "Active" (green)
interface SecretVersion {
name: 'stripe-api-key';
active: boolean; // Which version do we use?
value: string;
createdAt: Date;
rotateAfter: Date; // Automatic rotation?
}
// Your app periodically checks for new versions
async function refreshSecrets() {
const secrets = await secretsManager.list();
const activeStripeKey = secrets.find(s =>
s.name === 'stripe-api-key' && s.active
);
if (activeStripeKey.version !== this.currentVersion) {
// Refresh in-memory secret, no restart needed
this.stripeClient = new Stripe(activeStripeKey.value);
this.currentVersion = activeStripeKey.version;
}
}
// Check every 6 hours
setInterval(refreshSecrets, 6 * 60 * 60 * 1000);
4. Audit and monitoring
You need to know who touched which secret and when.
# CloudTrail audit log (AWS)
{
"eventTime": "2026-03-27T08:15:23Z",
"userIdentity": {
"principalId": "developer-alice"
},
"eventName": "GetSecretValue",
"requestParameters": {
"secretId": "stripe-api-key"
},
"sourceIPAddress": "203.0.113.42",
"userAgent": "aws-cli/2.x.x"
}
With alerts for suspicious activity:
// Alert if someone reads production secret outside business hours
secretsManager.on('secretAccess', (event) => {
const hour = new Date().getHours();
const isOutsideBusinessHours = hour < 6 || hour > 20;
if (event.environment === 'production' && isOutsideBusinessHours) {
await slack.notify({
text: `⚠️ Production secret accessed: ${event.secretName} by ${event.user}`,
channel: '#security-alerts'
});
}
});
Practical implementation per platform
AWS Secrets Manager
Best for teams already in the AWS ecosystem.
// AWS SDK v3
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "eu-west-1" });
async function getSecret(secretName) {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
return JSON.parse(response.SecretString);
}
// In your application
const dbConfig = await getSecret('prod/database');
const stripeKey = await getSecret('prod/stripe-api-key');
Caching with TTL:
const secretCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getCachedSecret(name) {
const cached = secretCache.get(name);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.value;
}
const value = await getSecret(name);
secretCache.set(name, { value, timestamp: Date.now() });
return value;
}
Kubernetes Secrets + External Secrets Operator
Best for containerized microservices.
# external-secrets operator syncs secrets from AWS to K8s
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-secret-store
spec:
provider:
aws:
service: SecretsManager
region: eu-west-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
spec:
refreshInterval: 15m
secretStoreRef:
name: aws-secret-store
kind: SecretStore
target:
name: app-secrets
creationPolicy: Owner
data:
- secretKey: database-url
remoteRef:
key: prod/database-url
- secretKey: stripe-key
remoteRef:
key: prod/stripe-api-key
The K8s Secret automatically refreshes and is available as:
- Environment variables
- Files in /var/run/secrets
- Application injection via library
HashiCorp Vault
Best for highly sensitive environments (healthcare, finance, GDPR-strict).
import VaultClient from 'node-vault';
const vault = new VaultClient({
endpoint: 'https://vault.company.com',
token: process.env.VAULT_TOKEN // Self-authenticated via K8s service account
});
async function getSecret(path) {
const secret = await vault.read(`secret/data/${path}`);
return secret.data.data;
}
// Audit trail built in
// Automatic key rotation via Vault policies
// Dynamic secrets (e.g. database credentials that expire)
Dynamic secrets — very useful for databases:
# Vault policy: generate database credentials automatically with TTL
path "database/creds/readonly" {
capabilities = ["read"]
}
# Vault generates for each request a unique user + password
# That expire after 1 hour
# No shared DB password anymore!
Doppler (simplest option)
Best for startups, quick setup.
# CLI setup
doppler setup
doppler projects create my-saas
doppler secrets set STRIPE_API_KEY sk_live_...
doppler secrets set DATABASE_URL postgresql://...
# In your code
const config = require('@doppler/sdk');
const stripe_key = config.get('STRIPE_API_KEY');
# Local development
doppler run -- npm start
# Production (inject via environment)
# Doppler CLI in your docker container
RUN curl -Ls https://cli.doppler.com/install.sh | sh
ENTRYPOINT ["doppler", "run", "--"]
CMD ["node", "server.js"]
Compliance-specific requirements
GDPR (EU)
- Encryption at rest: secrets must be encrypted at rest (AWS/Vault do this by default)
- Audit logging: who accesses personal data integrations (Sendgrid for emails, Stripe for payments)
- Data residency: keep secrets in the correct region (eu-west-1 for Europe)
NIS2 (EU critical infrastructure)
- Mandatory logging of all secret access
- Multi-factor authentication for access
- Secret rotation at least quarterly
- Incident response plan: what if a secret leaks?
SOC 2 Type II
- Segregation of duties: not everyone sees all secrets
- Change management: secrets can only be changed by authorized roles
- Availability: secrets manager must have 99.99% uptime
- Monitoring: real-time detection of suspicious access patterns
Onboarding / Offboarding checklist
Employee leaves:
- Revoke access in Vault/Secrets Manager
- Automatically rotate all secrets they knew about
- Review audit log: what did they last access?
- Disable API keys/tokens they created
async function offboardEmployee(employeeId) {
// Revoke all access
await vault.revokeAllTokensForEmployee(employeeId);
// Rotate all secrets this employee could see
const accessibleSecrets = await audit.getSecretsAccessedBy(employeeId);
for (const secret of accessibleSecrets) {
await secretsManager.rotateSecret(secret.id);
}
// Log the offboarding event
await audit.log({
event: 'EMPLOYEE_OFFBOARDED',
employeeId,
secretsRotated: accessibleSecrets.length,
timestamp: new Date()
});
}
Checklist: Build your secrets management system
- Centralize: all secrets in one manager (AWS/Vault/Doppler), not scattered
- Environment segregation: production secrets different from dev secrets
- Least privilege: services get only the secrets they need
- Rotation: implement automatic rotation (monthly minimum)
- Audit logging: every secret access is logged
- Encryption: secrets are encrypted at rest and in transit
- Monitoring: alerts for suspicious access patterns
- Onboarding/offboarding: automatically manage secret access on staff changes
- Backup & recovery: can you restore secrets after an incident?
- Testing: test your secret rotation without production impact
Conclusion
Secrets management feels like a detail, but it's a fundamental part of security engineering. A bad system costs you: breaches, compliance violations, downtime.
A good system gives you peace of mind: you know credentials are safe, automatically rotated, and fully audited.
Start small — graduate from .env files, migrate to a manager, automate rotation. It's one of the best investments you can make for your SaaS.