Where you store JWT secrets is as important as how you generate them. This guide compares three common approaches: environment variables, HashiCorp Vault, and cloud Key Management Services (KMS).
The Problem
JWT secrets must be:
- Accessible - Your application needs them at runtime
- Secure - Unauthorized access is catastrophic
- Manageable - You need to rotate them regularly
- Auditable - You need to know who accessed what, when
Let's compare the three main storage options.
Option 1: Environment Variables
The simplest approach. Secrets are set in the environment and read by your application at startup.
Implementation
// .env (never commit this!)
JWT_SECRET=a1b2c3d4e5f6...
// app.js
const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
// Signing
const token = jwt.sign({sub: '123'}, secret, {algorithm: 'HS256'});
// Verifying
const decoded = jwt.verify(token, secret);Pros
- Simple to implement
- No external dependencies
- Works everywhere (containers, serverless, VMs)
- No network calls (fast)
Cons
- No audit logging
- Rotation requires restart or custom logic
- Can leak via logs, error messages, process listings
- Hard to manage across many services
- Often stored in config files or CI/CD systems
Best For
- Development environments
- Simple applications with few secrets
- Teams without dedicated security infrastructure
Option 2: HashiCorp Vault
Vault is a dedicated secret management solution with enterprise features.
Implementation
const vault = require('node-vault')({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN
});
async function getJwtSecret() {
const result = await vault.read('secret/data/jwt');
return result.data.data.secret;
}
// Usage
const secret = await getJwtSecret();
const token = jwt.sign({sub: '123'}, secret);AppRole Authentication (Recommended)
// More secure than static tokens
const vault = require('node-vault')({
endpoint: process.env.VAULT_ADDR
});
async function authenticate() {
const result = await vault.approleLogin({
role_id: process.env.VAULT_ROLE_ID,
secret_id: process.env.VAULT_SECRET_ID
});
vault.token = result.auth.client_token;
return vault;
}Pros
- Full audit logging
- Automatic secret rotation
- Dynamic secrets (database credentials)
- Lease-based access (secrets expire)
- Cross-cloud (works with any infrastructure)
- Encryption as a service
Cons
- Operational complexity (must run Vault)
- Network dependency (adds latency)
- Learning curve for team
- Cost (especially for enterprise features)
Best For
- Teams with security expertise
- Multi-cloud environments
- Applications requiring audit compliance
- Large organizations with many secrets
Option 3: Cloud KMS
Cloud providers offer managed secret storage integrated with their IAM systems.
AWS Secrets Manager
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const client = new SecretsManager({region: 'us-east-1'});
async function getJwtSecret() {
const response = await client.getSecretValue({
SecretId: 'jwt-secret'
});
return JSON.parse(response.SecretString).secret;
}
// With caching
const secret = await getJwtSecret();
const token = jwt.sign({sub: '123'}, secret);Google Cloud Secret Manager
const {SecretManagerServiceClient} = require('@google-cloud/secret-manager');
const client = new SecretManagerServiceClient();
async function getJwtSecret() {
const [version] = await client.accessSecretVersion({
name: 'projects/my-project/secrets/jwt-secret/versions/latest'
});
return version.payload.data.toString();
}Azure Key Vault
const { DefaultAzureCredential } = require('@azure/identity');
const { SecretClient } = require('@azure/keyvault-secrets');
const credential = new DefaultAzureCredential();
const client = new SecretClient(
'https://my-vault.vault.azure.net',
credential
);
async function getJwtSecret() {
const secret = await client.getSecret('jwt-secret');
return secret.value;
}Pros
- Fully managed (no infrastructure)
- Integrated with cloud IAM
- Automatic rotation (AWS)
- Version history
- Native audit logging
Cons
- Vendor lock-in
- Network latency
- Cost per API call
- Secret size limits
Best For
- Cloud-native applications
- Teams already using cloud IAM
- Want managed solution without operations
Comparison Matrix
| Feature | Env Vars | Vault | Cloud KMS |
|---|---|---|---|
| Setup Complexity | Low | High | Medium |
| Audit Logging | None | Full | Full |
| Rotation | Manual | Automatic | Automatic |
| Network Dependency | None | Required | Required |
| Cost | Free | Server + License | Per API call |
| Cloud Lock-in | None | None | Yes |
Recommendations
Development
Use environment variables. Add .env to .gitignore. Never commit secrets.
Small Production
Start with cloud KMS if you're on a cloud platform. Use environment variables if on bare metal.
Enterprise Production
Use HashiCorp Vault for full control, or cloud KMS with proper IAM policies. Implement caching to reduce latency.
Secret Caching
Fetching secrets on every request adds latency. Implement caching:
class SecretCache {
constructor(fetchFn, ttlSeconds = 3600) {
this.fetchFn = fetchFn;
this.ttl = ttlSeconds * 1000;
this.cache = null;
this.lastFetch = 0;
}
async get() {
const now = Date.now();
if (this.cache && (now - this.lastFetch) < this.ttl) {
return this.cache;
}
this.cache = await this.fetchFn();
this.lastFetch = now;
return this.cache;
}
}
// Usage
const secretCache = new SecretCache(getJwtSecret, 300); // 5 min cache
const secret = await secretCache.get();Important: Cache in memory, not on disk. Restart applications to pick up rotated secrets.
Security Best Practices
- Never log secrets - Redact before logging
- Never return secrets in error messages
- Use least privilege - Apps only read, admins manage
- Rotate regularly - Every 90 days minimum
- Separate environments - Dev/staging/prod use different secrets
- Monitor access - Alert on unusual patterns
Tools
- JWT Secret Generator - Generate secure secrets
- Encryption Key Generator - Generate other keys