When organizations adopt microservices, one question surfaces repeatedly: where do secrets live, and who guards them? The typical answer — environment variables, mounted config files, or a third-party vault — addresses storage but ignores the deeper problem. Credentials in a microservice ecosystem need a cryptographic trust anchor: a single service that owns the entire encryption lifecycle, enforces access through authenticated sessions, and provides a verifiable chain of custody from user passphrase down to the individual credential byte.
This guide walks through designing and implementing a Credential Vault Service (CVS) that serves as that trust anchor. We will cover the full cryptographic chain, the rationale behind each algorithm choice, the database schema, and the operational procedures that keep the system resilient.
The Problem with “Just Use a Vault”
Most secret management tools solve credential storage. They do not solve credential ownership. When Service A stores an API key, who can read it? When a user changes their passphrase, what happens to the encrypted material? When an administrator needs emergency access, how do you audit the break-glass event without exposing plaintext?
A cryptographic trust anchor answers these questions architecturally rather than through policy documents. The vault becomes the single point of cryptographic truth, and every other service in the mesh authenticates to it using verifiable tokens.
The Crypto Chain: From Passphrase to Plaintext
The full decryption path follows six steps, each adding a layer of assurance:
User Passphrase
→ Argon2id verification (is this the right user?)
→ Session key derivation (time-limited access grant)
→ KEK unwrap (unlock the user's key-encryption-key)
→ DEK unwrap (unlock the specific credential's data-encryption-key)
→ AES-256-GCM decrypt (recover the credential plaintext)
This is envelope encryption with two layers. The Data Encryption Key (DEK) protects the credential. The Key Encryption Key (KEK) protects the DEK. The user’s passphrase protects the KEK. At no point does a single compromise expose all credentials — an attacker needs the passphrase, the encrypted KEK material, and the encrypted DEK material together.
Why Argon2id Over bcrypt or scrypt
The passphrase verification step is the front door. Choosing the wrong algorithm here undermines everything downstream.
bcrypt was designed in 1999. Its 72-byte input limit silently truncates longer passphrases. Its memory usage is fixed at 4KB per hash — trivially parallelizable on modern GPUs. A 2024-era GPU cluster can attempt billions of bcrypt hashes per second at low cost parameters.
scrypt added memory hardness, but its memory/CPU ratio is fixed. An attacker who can afford the memory can proportionally reduce CPU cost. It also lacks a defense against side-channel timing attacks because its memory access patterns are data-dependent.
Argon2id combines Argon2i’s resistance to side-channel attacks (data-independent memory access in the first pass) with Argon2d’s resistance to GPU cracking (data-dependent access in subsequent passes). The OWASP 2024 recommended parameters are:
Memory: 64 MiB (65536 KiB)
Iterations: 3
Parallelism: 4 threads
Salt: 16 bytes (crypto random)
Hash: 32 bytes
These parameters force approximately 192 MiB of memory usage per concurrent hash attempt (64 MiB x 3 iterations), making GPU-based attacks economically impractical. A single verification takes roughly 500ms on modern server hardware — acceptable for login, devastating for brute force.
import argon2 from 'argon2';
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
hashLength: 32, // 256-bit output
saltLength: 16 // 128-bit salt
};
async function hashPassphrase(passphrase) {
return argon2.hash(passphrase, ARGON2_OPTIONS);
}
async function verifyPassphrase(passphrase, storedHash) {
return argon2.verify(storedHash, passphrase);
}
Envelope Encryption Implementation
Key Hierarchy
Each user in the system has:
- A passphrase hash (Argon2id) stored for verification
- A KEK (256-bit AES key) encrypted with a key derived from the passphrase
- Multiple DEKs (one per credential), each encrypted with the KEK
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
function generateKey() {
return randomBytes(32); // 256 bits
}
function encryptAES256GCM(plaintext, key) {
const iv = randomBytes(12); // 96-bit IV for GCM
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag(); // 128-bit tag
// Store as: IV || AuthTag || Ciphertext
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
function decryptAES256GCM(packed, key) {
const data = Buffer.from(packed, 'base64');
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]).toString('utf8');
}
AES-256-GCM provides authenticated encryption — if anyone tampers with the ciphertext, IV, or auth tag, decryption fails rather than producing corrupted output. This is critical for a credential vault where silent corruption could lead to using the wrong credentials against production systems.
Session Key Derivation and TTL
After passphrase verification succeeds, the vault derives a session key and caches the unwrapped KEK in memory with a strict TTL:
const sessionCache = new Map();
function createVaultSession(userId, unwrappedKEK, ttlMinutes = 15) {
const sessionId = randomBytes(32).toString('hex');
const expiresAt = Date.now() + (ttlMinutes 60 1000);
sessionCache.set(sessionId, {
userId,
kek: unwrappedKEK,
expiresAt,
createdAt: Date.now()
});
// Automatic cleanup
setTimeout(() => {
const session = sessionCache.get(sessionId);
if (session) {
session.kek.fill(0); // Zero the key material
sessionCache.delete(sessionId);
}
}, ttlMinutes 60 1000);
return { sessionId, expiresAt };
}
The 15-minute default TTL means that even if a session token is stolen, the window of exposure is narrow. The KEK is zeroed from memory on expiry — not garbage collected, but explicitly overwritten.
Database Schema: Prisma Patterns for Encrypted Fields
The database never stores plaintext credentials. Every sensitive field contains Base64-encoded ciphertext with the IV and auth tag prepended.
model VaultUser {
id String @id @default(uuid())
email String @unique
passphraseHash String @map("passphrase_hash")
encryptedKEK String @map("encrypted_kek")
kekSalt String @map("kek_salt")
kekVersion Int @default(1) @map("kek_version")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
credentials Credential[]
auditLogs VaultAuditLog[]
@@map("vault_users")
}
model Credential {
id String @id @default(uuid())
userId String @map("user_id")
name String
category String @default("general")
encryptedDEK String @map("encrypted_dek")
encryptedPayload String @map("encrypted_payload")
dekVersion Int @default(1) @map("dek_version")
metadata Json? // Non-sensitive labels only
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
rotatedAt DateTime? @map("rotated_at")
user VaultUser @relation(fields: [userId], references: [id])
@@index([userId, category])
@@map("credentials")
}
model VaultAuditLog {
id String @id @default(uuid())
userId String @map("user_id")
action String // UNLOCK, READ, WRITE, ROTATE, BREAK_GLASS
resourceId String? @map("resource_id")
ipAddress String @map("ip_address")
userAgent String? @map("user_agent")
success Boolean
timestamp DateTime @default(now())
user VaultUser @relation(fields: [userId], references: [id])
@@index([userId, timestamp])
@@index([action, timestamp])
@@map("vault_audit_logs")
}
Key design decisions: The encryptedKEK field stores the user’s KEK encrypted with a key derived from their passphrase. The kekVersion and dekVersion fields support key rotation — when a KEK is rotated, all associated DEKs must be re-wrapped, but credential payloads remain untouched.
JWT Service-to-Service Authentication
Other microservices authenticate to the vault using signed JWTs with strict claims:
import jwt from 'jsonwebtoken';
function generateServiceToken(serviceId, secret, vaultAudience) {
return jwt.sign(
{
sub: serviceId,
iss: serviceId,
aud: vaultAudience,
scope: ['credential:read'],
jti: randomBytes(16).toString('hex')
},
secret,
{
algorithm: 'HS256',
expiresIn: '5m'
}
);
}
// Vault-side verification middleware
function verifyServiceToken(allowedServices, vaultAudience) {
return (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
const decoded = jwt.verify(token, getSecretForService(decoded.iss), {
algorithms: ['HS256'], // Prevent algorithm confusion
audience: vaultAudience,
issuer: allowedServices
});
req.serviceIdentity = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid service token' });
}
};
}
The aud claim ensures tokens minted for one service cannot be replayed against another. The jti (JWT ID) enables token revocation when combined with a short-lived Redis cache of consumed IDs.
Key Rotation Without Downtime
Key rotation follows a re-wrap strategy: the old KEK decrypts existing DEKs, and the new KEK re-encrypts them. Credential payloads (encrypted by DEKs) are never touched.
async function rotateUserKEK(userId, sessionId) {
const session = getValidSession(sessionId);
const oldKEK = session.kek;
const newKEK = generateKey();
const credentials = await prisma.credential.findMany({
where: { userId }
});
await prisma.$transaction(async (tx) => {
for (const cred of credentials) {
const dek = decryptAES256GCM(cred.encryptedDEK, oldKEK);
const rewrappedDEK = encryptAES256GCM(dek, newKEK);
await tx.credential.update({
where: { id: cred.id },
data: {
encryptedDEK: rewrappedDEK,
dekVersion: { increment: 1 }
}
});
}
// Re-encrypt KEK with passphrase-derived key
const newEncryptedKEK = encryptAES256GCM(
newKEK.toString('base64'),
session.passphraseKey
);
await tx.vaultUser.update({
where: { id: userId },
data: {
encryptedKEK: newEncryptedKEK,
kekVersion: { increment: 1 }
}
});
});
// Update session with new KEK
session.kek = newKEK;
oldKEK.fill(0); // Zero old key material
}
The entire rotation runs inside a database transaction. If any step fails, everything rolls back and the old KEK remains valid.
Break-Glass Procedures
Emergency access requires a split-knowledge recovery mechanism. During initial setup, the system generates a recovery key split into N shares using Shamir’s Secret Sharing, requiring K of N shares to reconstruct:
- Activation: K designated recovery officers each submit their share through an authenticated endpoint
- Reconstruction: The vault reconstructs the recovery key, which can decrypt a master recovery KEK
- Audit: Every break-glass event generates an immutable audit log entry with the identities of all participating officers
- Expiry: The reconstructed key is held in memory for a maximum of 10 minutes, then zeroed
The critical rule: break-glass access must be louder than normal access. The audit log entry should trigger immediate notifications to all security stakeholders, and the event should be irrevocable from the log.
Operational Considerations
Memory safety: In Node.js, you cannot guarantee that Buffer.fill(0) prevents the runtime from copying key material during garbage collection. For the highest assurance environments, the innermost crypto operations should delegate to a native module or HSM.
Backup encryption: Database backups contain encrypted material, but the encryption is only as strong as the passphrase protecting each KEK. Enforce minimum passphrase complexity and consider requiring hardware second factors for vault unlock.
Monitoring: Track vault unlock frequency per user. A user who unlocks their vault 50 times per day either has a workflow problem or an account compromise.
A well-designed credential vault does not just store secrets. It creates a cryptographic chain of accountability where every access is authenticated, every credential is individually encrypted, and every key can be rotated without touching the data it protects. That is what it means to be a trust anchor.
