TechLead
Lesson 18 of 22
5 min read
Cybersecurity

Secrets Management

Securely store, rotate, and manage API keys, database credentials, and encryption keys across environments

What is Secrets Management?

Secrets management is the practice of securely storing, distributing, and managing sensitive credentials such as API keys, database passwords, encryption keys, OAuth client secrets, and certificates. Poor secrets management is one of the most common causes of security breaches — leaked credentials in source code, environment files, or CI/CD logs can give attackers direct access to your systems.

A proper secrets management system provides encrypted storage, access control (who and what can access which secrets), audit logging (who accessed what and when), rotation capabilities (changing secrets without downtime), and dynamic secrets (credentials generated on-demand with limited lifetimes).

Security Warning: Where Secrets Get Leaked

  • Git repositories: Accidentally committed .env files, config files, or hardcoded secrets in source code. GitHub scans for exposed secrets, but by the time it notifies you, the secret may already be compromised.
  • CI/CD logs: Secrets printed to build logs via console.log, error messages, or verbose mode. Logs are often stored indefinitely and accessible to many people.
  • Error tracking: Services like Sentry may capture environment variables or request headers containing tokens.
  • Docker images: Secrets baked into Docker image layers persist even if the file is later deleted. Always use multi-stage builds or runtime injection.
  • Client-side code: API keys embedded in JavaScript bundles are visible to anyone who views the source.

Environment Variables Best Practices

// Secure environment variable handling
import { z } from "zod";

// Define and validate all required environment variables at startup
const envSchema = z.object({
  NODE_ENV: z.enum(["development", "staging", "production"]),
  DATABASE_URL: z.string().url().startsWith("postgresql://"),
  REDIS_URL: z.string().url(),
  JWT_PRIVATE_KEY: z.string().min(100), // Must be a proper key
  JWT_PUBLIC_KEY: z.string().min(100),
  ENCRYPTION_KEY: z.string().length(64), // 32 bytes hex-encoded
  SESSION_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  SENDGRID_API_KEY: z.string().startsWith("SG."),
  SENTRY_DSN: z.string().url().optional(),
});

// Validate at startup - fail fast if secrets are missing
function validateEnv() {
  const result = envSchema.safeParse(process.env);
  if (!result.success) {
    console.error("Missing or invalid environment variables:");
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    }
    process.exit(1); // Do not start with invalid configuration
  }
  return result.data;
}

const env = validateEnv();

// .gitignore - Always include these
/*
.env
.env.local
.env.*.local
*.pem
*.key
credentials.json
service-account.json
*/

// .env.example - Commit this as a template (no real values!)
/*
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
JWT_PRIVATE_KEY=generate-with-openssl
ENCRYPTION_KEY=generate-32-random-bytes-hex
SESSION_SECRET=generate-random-string
STRIPE_SECRET_KEY=sk_test_...
SENDGRID_API_KEY=SG....
*/

Using a Secrets Manager

For production systems, use a dedicated secrets manager like HashiCorp Vault, AWS Secrets Manager, or Google Secret Manager. These provide encrypted storage, fine-grained access control, automatic rotation, and comprehensive audit logs. Secrets are fetched at runtime, never stored in files or environment variables on disk.

// AWS Secrets Manager integration
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

class SecretsProvider {
  private client: SecretsManagerClient;
  private cache: Map<string, { value: string; expiry: number }> = new Map();
  private cacheTTL = 5 * 60 * 1000; // 5 minutes

  constructor(region: string) {
    this.client = new SecretsManagerClient({ region });
  }

  async getSecret(secretName: string): Promise<string> {
    // Check cache first
    const cached = this.cache.get(secretName);
    if (cached && cached.expiry > Date.now()) {
      return cached.value;
    }

    // Fetch from Secrets Manager
    const command = new GetSecretValueCommand({ SecretId: secretName });
    const response = await this.client.send(command);

    const value = response.SecretString!;

    // Cache the secret
    this.cache.set(secretName, {
      value,
      expiry: Date.now() + this.cacheTTL,
    });

    return value;
  }

  async getJsonSecret<T>(secretName: string): Promise<T> {
    const raw = await this.getSecret(secretName);
    return JSON.parse(raw);
  }
}

// Usage
const secrets = new SecretsProvider("us-east-1");

async function getDatabaseConfig() {
  const dbSecrets = await secrets.getJsonSecret<{
    host: string;
    port: number;
    username: string;
    password: string;
    database: string;
  }>("production/database");

  return {
    connectionString: `postgresql://${dbSecrets.username}:${dbSecrets.password}@${dbSecrets.host}:${dbSecrets.port}/${dbSecrets.database}`,
    ssl: { rejectUnauthorized: true },
  };
}

Secret Rotation

// Implementing secret rotation without downtime
class RotatingSecret {
  private currentKey: string;
  private previousKey: string | null = null;
  private rotationInterval: NodeJS.Timeout;

  constructor(
    private secretProvider: SecretsProvider,
    private secretName: string,
    private rotationPeriodMs: number = 24 * 60 * 60 * 1000 // 24 hours
  ) {
    this.currentKey = "";
    this.rotationInterval = setInterval(
      () => this.rotate(),
      this.rotationPeriodMs
    );
  }

  async initialize() {
    this.currentKey = await this.secretProvider.getSecret(this.secretName);
  }

  async rotate() {
    this.previousKey = this.currentKey;
    // Trigger rotation in the secrets manager
    this.currentKey = await this.secretProvider.getSecret(this.secretName);

    // Keep previous key valid for a grace period
    setTimeout(() => {
      this.previousKey = null;
    }, 60 * 60 * 1000); // 1 hour grace period
  }

  // Verify using current or previous key (for in-flight requests)
  verify(data: string, signature: string): boolean {
    if (verifyHMAC(data, this.currentKey, signature)) return true;
    if (this.previousKey && verifyHMAC(data, this.previousKey, signature)) return true;
    return false;
  }

  sign(data: string): string {
    return createHMAC(data, this.currentKey);
  }
}

Secrets Management Best Practices

  • Never commit secrets: Use .gitignore, pre-commit hooks, and secret scanning to prevent accidental commits.
  • Use a secrets manager in production: Not environment variables on disk.
  • Rotate secrets regularly: Implement automated rotation with grace periods.
  • Use different secrets per environment: Development, staging, and production should never share credentials.
  • Audit access: Log who accessed which secrets and when.
  • Limit scope: Give each service only the secrets it needs (principle of least privilege).
  • Scan for leaks: Use tools like git-secrets, truffleHog, or GitHub secret scanning to detect leaked credentials.

Continue Learning