Secrets Management

intermediate

Secure secrets handling with encryption at rest across environments.

environmentsecretssecurityencryption
Tested on201619TS5.9
$ bunx sinew add environment/secrets
Interactive demo coming soon

1The Problem

Environment variables in plain text are risky:

  • .env files accidentally committed to git
  • Secrets visible in CI/CD logs
  • No encryption at rest
  • Hard to rotate secrets across environments

2The Solution

Use encrypted secrets with a master key. Encrypt sensitive values at rest and decrypt at runtime.

3Files

lib/secrets.ts

lib/secrets.tsTypeScript
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "crypto";
import { promisify } from "util";

const scryptAsync = promisify(scrypt);
const ALGORITHM = "aes-256-gcm";
const ENCODING = "base64";

async function getKey(password: string, salt: Buffer): Promise<Buffer> {
  return (await scryptAsync(password, salt, 32)) as Buffer;
}

export async function encrypt(plaintext: string, masterKey: string): Promise<string> {
  const salt = randomBytes(16);
  const iv = randomBytes(12);
  const key = await getKey(masterKey, salt);

  const cipher = createCipheriv(ALGORITHM, key, iv);
  let encrypted = cipher.update(plaintext, "utf8", ENCODING);
  encrypted += cipher.final(ENCODING);

  const authTag = cipher.getAuthTag();

  // Format: salt:iv:authTag:encrypted
  return [
    salt.toString(ENCODING),
    iv.toString(ENCODING),
    authTag.toString(ENCODING),
    encrypted,
  ].join(":");
}

export async function decrypt(encryptedData: string, masterKey: string): Promise<string> {
  const [saltB64, ivB64, authTagB64, encrypted] = encryptedData.split(":");

  const salt = Buffer.from(saltB64, ENCODING);
  const iv = Buffer.from(ivB64, ENCODING);
  const authTag = Buffer.from(authTagB64, ENCODING);
  const key = await getKey(masterKey, salt);

  const decipher = createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, ENCODING, "utf8");
  decrypted += decipher.final("utf8");

  return decrypted;
}

// Cache decrypted secrets
const secretsCache = new Map<string, string>();

export async function getSecret(key: string): Promise<string> {
  if (secretsCache.has(key)) {
    return secretsCache.get(key)!;
  }

  const encryptedValue = process.env[key];
  if (!encryptedValue) {
    throw new Error(`Secret ${key} not found`);
  }

  // If value starts with "enc:", it's encrypted
  if (encryptedValue.startsWith("enc:")) {
    const masterKey = process.env.MASTER_KEY;
    if (!masterKey) {
      throw new Error("MASTER_KEY not set");
    }

    const decrypted = await decrypt(encryptedValue.slice(4), masterKey);
    secretsCache.set(key, decrypted);
    return decrypted;
  }

  // Not encrypted, return as-is (for local development)
  return encryptedValue;
}

scripts/encrypt-secret.ts

scripts/encrypt-secret.tsTypeScript
#!/usr/bin/env npx ts-node

import { encrypt } from "../lib/secrets";

async function main() {
  const [, , masterKey, secretValue] = process.argv;

  if (!masterKey || !secretValue) {
    console.error("Usage: npx ts-node scripts/encrypt-secret.ts <masterKey> <secretValue>");
    process.exit(1);
  }

  const encrypted = await encrypt(secretValue, masterKey);
  console.log(`enc:${encrypted}`);
}

main();

lib/env.ts

lib/env.tsTypeScript
import { z } from "zod";
import { getSecret } from "./secrets";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  DATABASE_URL: z.string(),
  // Add other env vars...
});

let cachedEnv: z.infer<typeof envSchema> | null = null;

export async function getEnv() {
  if (cachedEnv) return cachedEnv;

  const env = {
    NODE_ENV: process.env.NODE_ENV || "development",
    DATABASE_URL: await getSecret("DATABASE_URL"),
    // Decrypt other secrets as needed
  };

  const parsed = envSchema.parse(env);
  cachedEnv = parsed;
  return parsed;
}

// Synchronous version for cases where you can't await
export function getEnvSync() {
  if (!cachedEnv) {
    throw new Error("Environment not initialized. Call getEnv() first.");
  }
  return cachedEnv;
}

instrumentation.ts

instrumentation.tsTypeScript
export async function register() {
  // Initialize environment on app start
  const { getEnv } = await import("./lib/env");
  await getEnv();
  console.info("Environment initialized");
}

lib/rotate-secrets.ts

lib/rotate-secrets.tsTypeScript
import { encrypt, decrypt } from "./secrets";

export async function rotateSecrets(
  oldMasterKey: string,
  newMasterKey: string,
  encryptedSecrets: Record<string, string>
): Promise<Record<string, string>> {
  const rotated: Record<string, string> = {};

  for (const [key, encryptedValue] of Object.entries(encryptedSecrets)) {
    // Decrypt with old key
    const plaintext = await decrypt(encryptedValue, oldMasterKey);
    // Encrypt with new key
    const newEncrypted = await encrypt(plaintext, newMasterKey);
    rotated[key] = `enc:${newEncrypted}`;
  }

  return rotated;
}

.env.example

.env.exampleBash
# Master key for decrypting secrets (32+ chars recommended)
MASTER_KEY="your-super-secret-master-key-here"

# Encrypted secrets (generate with scripts/encrypt-secret.ts)
DATABASE_URL="enc:salt:iv:authTag:encryptedData"
STRIPE_SECRET_KEY="enc:salt:iv:authTag:encryptedData"

4Configuration

Generate a Master Key

openssl rand -base64 32
Bash

Encrypt a Secret

npx ts-node scripts/encrypt-secret.ts "your-master-key" "secret-value-to-encrypt"
Bash

Output:

enc:salt:iv:authTag:encryptedData
TypeScript

Store Encrypted Secrets

Add the encrypted value to your .env:

DATABASE_URL="enc:Abc123..."
Bash

5Usage

Getting Secrets in Code

import { getSecret } from "@/lib/secrets";

// Automatically decrypts if value starts with "enc:"
const dbUrl = await getSecret("DATABASE_URL");
TypeScript

Using with Database Clients

import { getEnv } from "@/lib/env";
import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

export async function getPrisma() {
  if (prisma) return prisma;

  const env = await getEnv();
  prisma = new PrismaClient({
    datasources: {
      db: { url: env.DATABASE_URL },
    },
  });

  return prisma;
}
TypeScript

6Troubleshooting

"MASTER_KEY not set" error

  • Ensure MASTER_KEY is set in production environment
  • In development, you can use unencrypted values (without enc: prefix)

Decryption failing

  • Verify the master key matches the one used for encryption
  • Check that the encrypted value hasn't been truncated
  • Ensure no extra whitespace in environment variables

Related patterns