Loading
Encrypt secrets at rest with AES-256-GCM, enforce access control per service, maintain an audit log, support key rotation, and expose both a CLI and HTTP API.
Every production system depends on secrets — API keys, database credentials, signing tokens. Hardcoding them in source files is a security incident waiting to happen, and .env files don't scale across teams or services. In this tutorial, you'll build a secrets manager from scratch in Node.js that encrypts values at rest using AES-256-GCM, enforces per-service access control, logs every access event, supports key rotation without downtime, and provides both a CLI and HTTP API for integration.
What you'll learn:
By the end, you'll have a working secrets vault that a small team could actually use in development and staging environments.
Create tsconfig.json:
Now create src/crypto.ts — the foundation of the entire system:
AES-256-GCM provides both confidentiality and integrity. The auth tag means any tampering with the ciphertext will cause decryption to fail, which is exactly what you want in a secrets store.
The vault stores encrypted secrets alongside metadata. Each secret record tracks its version, who created it, and which encryption key version was used.
Access control determines which services can read which secrets. A service called payment-api might access STRIPE_KEY but not ADMIN_DB_PASSWORD.
Every secret access — read, write, delete, rotation — gets logged. The audit log is append-only, and each entry includes a hash chain linking it to the previous entry so tampering is detectable.
Key rotation re-encrypts every secret with a new derived key. The old master password is replaced, and each record is updated in place.
Add a verification command that checks the integrity of the entire audit chain and produces a summary report:
Run the test suite with npx tsx src/test.ts. Every test validates a core feature: encryption round-trips, ACL enforcement, audit integrity, and key rotation. From here, you can extend the system with features like secret versioning (keeping all previous values), TTL-based expiry, or integration with cloud KMS for the master key instead of a password.
mkdir secrets-manager && cd secrets-manager
npm init -y
npm install -D typescript @types/node tsx{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const KEY_LENGTH = 32;
const SALT_LENGTH = 16;
export function deriveKey(password: string, salt: Buffer): Buffer {
return scryptSync(password, salt, KEY_LENGTH) as Buffer;
}
export function encrypt(plaintext: string, key: Buffer): Buffer {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Format: [iv (12)] [tag (16)] [ciphertext (variable)]
return Buffer.concat([iv, tag, encrypted]);
}
export function decrypt(payload: Buffer, key: Buffer): string {
const iv = payload.subarray(0, IV_LENGTH);
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = payload.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final("utf8");
}
export function generateSalt(): Buffer {
return randomBytes(SALT_LENGTH);
}// src/store.ts
import { readFileSync, writeFileSync, existsSync } from "node:fs";
interface SecretRecord {
name: string;
encryptedValue: string; // base64-encoded encrypted payload
keyVersion: number;
createdAt: string;
updatedAt: string;
createdBy: string;
}
interface VaultData {
version: number;
salt: string; // base64-encoded salt for key derivation
secrets: Record<string, SecretRecord>;
acl: Record<string, string[]>; // service -> allowed secret names
keyVersion: number;
}
const DEFAULT_VAULT: VaultData = {
version: 1,
salt: "",
secrets: {},
acl: {},
keyVersion: 1,
};
export class VaultStore {
private data: VaultData;
private filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
this.data = existsSync(filePath)
? JSON.parse(readFileSync(filePath, "utf8"))
: { ...DEFAULT_VAULT };
}
getData(): VaultData {
return this.data;
}
setSecret(name: string, record: SecretRecord): void {
this.data.secrets[name] = record;
this.persist();
}
getSecret(name: string): SecretRecord | undefined {
return this.data.secrets[name];
}
deleteSecret(name: string): boolean {
if (this.data.secrets[name]) {
delete this.data.secrets[name];
this.persist();
return true;
}
return false;
}
listSecrets(): string[] {
return Object.keys(this.data.secrets);
}
setSalt(salt: string): void {
this.data.salt = salt;
this.persist();
}
incrementKeyVersion(): number {
this.data.keyVersion += 1;
this.persist();
return this.data.keyVersion;
}
private persist(): void {
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
}
}// src/acl.ts
import { VaultStore } from "./store.js";
export class AccessControl {
constructor(private store: VaultStore) {}
grant(service: string, secretName: string): void {
const data = this.store.getData();
if (!data.acl[service]) {
data.acl[service] = [];
}
if (!data.acl[service].includes(secretName)) {
data.acl[service].push(secretName);
}
}
revoke(service: string, secretName: string): void {
const data = this.store.getData();
if (data.acl[service]) {
data.acl[service] = data.acl[service].filter((s) => s !== secretName);
}
}
check(service: string, secretName: string): boolean {
const data = this.store.getData();
const allowed = data.acl[service];
if (!allowed) return false;
return allowed.includes(secretName) || allowed.includes("*");
}
listForService(service: string): string[] {
const data = this.store.getData();
return data.acl[service] ?? [];
}
}// src/audit.ts
import { appendFileSync, readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
interface AuditEntry {
timestamp: string;
action: "read" | "write" | "delete" | "rotate" | "grant" | "revoke";
actor: string;
secret: string;
service?: string;
previousHash: string;
hash: string;
}
export class AuditLog {
private filePath: string;
private lastHash: string;
constructor(filePath: string) {
this.filePath = filePath;
this.lastHash = this.loadLastHash();
}
log(action: AuditEntry["action"], actor: string, secret: string, service?: string): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
actor,
secret,
service,
previousHash: this.lastHash,
hash: "",
};
entry.hash = this.computeHash(entry);
this.lastHash = entry.hash;
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
}
verify(): { valid: boolean; entries: number; brokenAt?: number } {
if (!existsSync(this.filePath)) return { valid: true, entries: 0 };
const lines = readFileSync(this.filePath, "utf8").trim().split("\n");
let previousHash = "genesis";
for (let i = 0; i < lines.length; i++) {
const entry: AuditEntry = JSON.parse(lines[i]);
if (entry.previousHash !== previousHash) {
return { valid: false, entries: lines.length, brokenAt: i };
}
const expected = this.computeHash({ ...entry, hash: "" });
if (entry.hash !== expected) {
return { valid: false, entries: lines.length, brokenAt: i };
}
previousHash = entry.hash;
}
return { valid: true, entries: lines.length };
}
private computeHash(entry: AuditEntry): string {
const content = `${entry.timestamp}|${entry.action}|${entry.actor}|${entry.secret}|${entry.previousHash}`;
return createHash("sha256").update(content).digest("hex");
}
private loadLastHash(): string {
if (!existsSync(this.filePath)) return "genesis";
const lines = readFileSync(this.filePath, "utf8").trim().split("\n");
if (lines.length === 0 || lines[0] === "") return "genesis";
const last: AuditEntry = JSON.parse(lines[lines.length - 1]);
return last.hash;
}
}// src/vault.ts
import { encrypt, decrypt, deriveKey, generateSalt } from "./crypto.js";
import { VaultStore } from "./store.js";
import { AccessControl } from "./acl.js";
import { AuditLog } from "./audit.js";
export class Vault {
private store: VaultStore;
private acl: AccessControl;
private audit: AuditLog;
private key: Buffer;
constructor(vaultPath: string, auditPath: string, masterPassword: string) {
this.store = new VaultStore(vaultPath);
this.acl = new AccessControl(this.store);
this.audit = new AuditLog(auditPath);
const data = this.store.getData();
let salt: Buffer;
if (!data.salt) {
salt = generateSalt();
this.store.setSalt(salt.toString("base64"));
} else {
salt = Buffer.from(data.salt, "base64");
}
this.key = deriveKey(masterPassword, salt);
}
set(name: string, value: string, actor: string): void {
const encrypted = encrypt(value, this.key);
this.store.setSecret(name, {
name,
encryptedValue: encrypted.toString("base64"),
keyVersion: this.store.getData().keyVersion,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: actor,
});
this.audit.log("write", actor, name);
}
get(name: string, actor: string, service?: string): string {
if (service && !this.acl.check(service, name)) {
throw new Error(`Service "${service}" is not authorized to access "${name}"`);
}
const record = this.store.getSecret(name);
if (!record) throw new Error(`Secret "${name}" not found`);
this.audit.log("read", actor, name, service);
const payload = Buffer.from(record.encryptedValue, "base64");
return decrypt(payload, this.key);
}
delete(name: string, actor: string): void {
if (!this.store.deleteSecret(name)) {
throw new Error(`Secret "${name}" not found`);
}
this.audit.log("delete", actor, name);
}
grant(service: string, secretName: string, actor: string): void {
this.acl.grant(service, secretName);
this.audit.log("grant", actor, secretName, service);
}
revoke(service: string, secretName: string, actor: string): void {
this.acl.revoke(service, secretName);
this.audit.log("revoke", actor, secretName, service);
}
list(): string[] {
return this.store.listSecrets();
}
}// src/rotation.ts
import { VaultStore } from "./store.js";
import { encrypt, decrypt, deriveKey, generateSalt } from "./crypto.js";
import { AuditLog } from "./audit.js";
export function rotateKeys(
store: VaultStore,
audit: AuditLog,
oldPassword: string,
newPassword: string,
actor: string
): { rotated: number } {
const data = store.getData();
const oldSalt = Buffer.from(data.salt, "base64");
const oldKey = deriveKey(oldPassword, oldSalt);
const newSalt = generateSalt();
const newKey = deriveKey(newPassword, newSalt);
const newVersion = store.incrementKeyVersion();
let count = 0;
for (const [name, record] of Object.entries(data.secrets)) {
const payload = Buffer.from(record.encryptedValue, "base64");
const plaintext = decrypt(payload, oldKey);
const reEncrypted = encrypt(plaintext, newKey);
store.setSecret(name, {
...record,
encryptedValue: reEncrypted.toString("base64"),
keyVersion: newVersion,
updatedAt: new Date().toISOString(),
});
count++;
}
store.setSalt(newSalt.toString("base64"));
audit.log("rotate", actor, `*all* (${count} secrets)`);
return { rotated: count };
}// src/cli.ts
import { Vault } from "./vault.js";
import { rotateKeys } from "./rotation.js";
import { VaultStore } from "./store.js";
import { AuditLog } from "./audit.js";
const VAULT_PATH = process.env.VAULT_PATH ?? "./vault.json";
const AUDIT_PATH = process.env.AUDIT_PATH ?? "./audit.log";
const MASTER_PASSWORD = process.env.VAULT_MASTER_PASSWORD;
if (!MASTER_PASSWORD) {
console.error("Error: VAULT_MASTER_PASSWORD env var is required");
process.exit(1);
}
const vault = new Vault(VAULT_PATH, AUDIT_PATH, MASTER_PASSWORD);
const [, , command, ...args] = process.argv;
try {
switch (command) {
case "set": {
const [name, value] = args;
if (!name || !value) throw new Error("Usage: set <name> <value>");
vault.set(name, value, "cli-user");
console.log(`Secret "${name}" stored.`);
break;
}
case "get": {
const [name, service] = args;
if (!name) throw new Error("Usage: get <name> [service]");
const value = vault.get(name, "cli-user", service);
console.log(value);
break;
}
case "delete": {
const [name] = args;
if (!name) throw new Error("Usage: delete <name>");
vault.delete(name, "cli-user");
console.log(`Secret "${name}" deleted.`);
break;
}
case "list":
console.log(vault.list().join("\n"));
break;
case "grant": {
const [service, name] = args;
if (!service || !name) throw new Error("Usage: grant <service> <secret>");
vault.grant(service, name, "cli-user");
console.log(`Granted "${service}" access to "${name}".`);
break;
}
case "revoke": {
const [service, name] = args;
if (!service || !name) throw new Error("Usage: revoke <service> <secret>");
vault.revoke(service, name, "cli-user");
console.log(`Revoked "${service}" access to "${name}".`);
break;
}
default:
console.log("Commands: set, get, delete, list, grant, revoke");
}
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}// src/server.ts
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { Vault } from "./vault.js";
const VAULT_PATH = process.env.VAULT_PATH ?? "./vault.json";
const AUDIT_PATH = process.env.AUDIT_PATH ?? "./audit.log";
const MASTER_PASSWORD = process.env.VAULT_MASTER_PASSWORD ?? "";
const API_TOKEN = process.env.VAULT_API_TOKEN ?? "";
const PORT = parseInt(process.env.PORT ?? "3100", 10);
const vault = new Vault(VAULT_PATH, AUDIT_PATH, MASTER_PASSWORD);
function parseBody(req: IncomingMessage): Promise<Record<string, string>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch {
reject(new Error("Invalid JSON"));
}
});
});
}
function json(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
const auth = req.headers.authorization;
if (auth !== `Bearer ${API_TOKEN}`) {
return json(res, 401, { error: "Unauthorized" });
}
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
const service = req.headers["x-service"] as string | undefined;
try {
if (req.method === "GET" && url.pathname === "/secrets") {
return json(res, 200, { secrets: vault.list() });
}
if (req.method === "GET" && url.pathname.startsWith("/secrets/")) {
const name = url.pathname.split("/")[2];
const value = vault.get(name, "api", service);
return json(res, 200, { name, value });
}
if (req.method === "POST" && url.pathname === "/secrets") {
const { name, value } = await parseBody(req);
vault.set(name, value, "api");
return json(res, 201, { stored: name });
}
if (req.method === "DELETE" && url.pathname.startsWith("/secrets/")) {
const name = url.pathname.split("/")[2];
vault.delete(name, "api");
return json(res, 200, { deleted: name });
}
json(res, 404, { error: "Not found" });
} catch (err) {
json(res, 400, { error: (err as Error).message });
}
});
server.listen(PORT, () => console.log(`Vault API listening on :${PORT}`));// src/report.ts
import { readFileSync, existsSync } from "node:fs";
import { AuditLog } from "./audit.js";
interface AuditEntry {
timestamp: string;
action: string;
actor: string;
secret: string;
service?: string;
}
export function generateReport(auditPath: string): string {
const audit = new AuditLog(auditPath);
const integrity = audit.verify();
if (!existsSync(auditPath)) return "No audit log found.";
const lines = readFileSync(auditPath, "utf8").trim().split("\n");
const entries: AuditEntry[] = lines.map((l) => JSON.parse(l));
const actionCounts: Record<string, number> = {};
const secretAccess: Record<string, number> = {};
for (const entry of entries) {
actionCounts[entry.action] = (actionCounts[entry.action] ?? 0) + 1;
if (entry.action === "read") {
secretAccess[entry.secret] = (secretAccess[entry.secret] ?? 0) + 1;
}
}
const mostAccessed = Object.entries(secretAccess)
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
return [
`Audit Report`,
`============`,
`Integrity: ${integrity.valid ? "PASS" : `FAIL at entry ${integrity.brokenAt}`}`,
`Total entries: ${integrity.entries}`,
``,
`Actions:`,
...Object.entries(actionCounts).map(([k, v]) => ` ${k}: ${v}`),
``,
`Most accessed secrets:`,
...mostAccessed.map(([name, count]) => ` ${name}: ${count} reads`),
].join("\n");
}// src/test.ts
import { Vault } from "./vault.js";
import { AuditLog } from "./audit.js";
import { rotateKeys } from "./rotation.js";
import { VaultStore } from "./store.js";
import { unlinkSync, existsSync } from "node:fs";
function cleanup(): void {
for (const f of ["./test-vault.json", "./test-audit.log"]) {
if (existsSync(f)) unlinkSync(f);
}
}
function assert(condition: boolean, msg: string): void {
if (!condition) throw new Error(`FAIL: ${msg}`);
console.log(`PASS: ${msg}`);
}
cleanup();
// Basic set/get
const vault = new Vault("./test-vault.json", "./test-audit.log", "master123");
vault.set("DB_PASSWORD", "supersecret", "tester");
assert(vault.get("DB_PASSWORD", "tester") === "supersecret", "Set and get a secret");
// Access control
vault.grant("payment-api", "DB_PASSWORD", "tester");
assert(
vault.get("DB_PASSWORD", "tester", "payment-api") === "supersecret",
"Authorized service reads"
);
try {
vault.get("DB_PASSWORD", "tester", "unknown-service");
assert(false, "Unauthorized service should fail");
} catch {
assert(true, "Unauthorized service rejected");
}
// Delete
vault.set("TEMP_KEY", "temporary", "tester");
vault.delete("TEMP_KEY", "tester");
assert(!vault.list().includes("TEMP_KEY"), "Secret deleted");
// Audit integrity
const audit = new AuditLog("./test-audit.log");
const result = audit.verify();
assert(result.valid, "Audit log integrity intact");
// Key rotation
const store = new VaultStore("./test-vault.json");
const rotateResult = rotateKeys(store, audit, "master123", "newmaster456", "tester");
assert(rotateResult.rotated > 0, `Rotated ${rotateResult.rotated} secrets`);
// Verify secret still readable with new password
const vault2 = new Vault("./test-vault.json", "./test-audit.log", "newmaster456");
assert(vault2.get("DB_PASSWORD", "tester") === "supersecret", "Secret readable after rotation");
cleanup();
console.log("\nAll tests passed.");