Secrets Management Best Practices: API Keys, Credentials, and Vaults

TL;DR

The #1 secrets management best practice is to never commit secrets to git. Everything else follows from that. Keep values in environment variables, or in a secret manager like AWS Secrets Manager or HashiCorp Vault if you want rotation and an audit trail. Automate rotation, scan your commits, and keep production credentials out of development.

"A leaked secret is a permanent secret. The moment it hits version control, assume it's compromised and rotate immediately."

Best Practice 1: Never Hardcode Secrets 2 min

A secret in code doesn't stay in code. It ends up in git history, in log lines, and in the stack trace some framework helpfully prints to a public error page:

Wrong vs right way to handle secrets
// WRONG: Hardcoded secrets
const stripe = new Stripe('sk_live_abc123xyz789');
const db = new Database('postgres://admin:password@db.example.com');

// CORRECT: Environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const db = new Database(process.env.DATABASE_URL);

// BETTER: Secret manager with validation
function getRequiredSecret(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required secret: ${name}`);
  }
  return value;
}

const stripe = new Stripe(getRequiredSecret('STRIPE_SECRET_KEY'));

Best Practice 2: Use Git Hooks to Prevent Leaks 5 min

The cheapest fix in this whole article is a hook that refuses the commit:

.gitignore essentials
# Environment files
.env
.env.local
.env.*.local
.env.production

# Key files
*.pem
*.key
*.p12
*.pfx

# IDE secrets
.idea/
.vscode/settings.json

# Cloud credentials
credentials.json
service-account.json
.aws/credentials
Pre-commit hook with gitleaks
# Install gitleaks
brew install gitleaks  # macOS
# or download from github.com/gitleaks/gitleaks

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

# Run manually
gitleaks detect --source . --verbose

# Scan git history
gitleaks detect --source . --log-opts="--all"

Best Practice 3: Use a Secret Manager 10 min

Environment variables are fine. A secret manager buys you two things they can't: rotation without a redeploy, and a record of who read what.

SolutionProsConsBest For
AWS Secrets ManagerRotation, audit, IAMAWS lock-inAWS deployments
HashiCorp VaultFull-featured, multi-cloudComplex setupEnterprise, multi-cloud
GCP Secret ManagerGCP integrationGCP lock-inGCP deployments
Doppler/InfisicalDev-friendly, syncThird-party SaaSStartups, small teams
AWS Secrets Manager example
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretName) {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);

  if (response.SecretString) {
    return JSON.parse(response.SecretString);
  }

  throw new Error('Secret not found');
}

// Usage
const dbCreds = await getSecret('prod/database');
const db = new Database({
  host: dbCreds.host,
  user: dbCreds.username,
  password: dbCreds.password,
});

Best Practice 4: Rotate Secrets Regularly 10 min

Rotation is how you put an expiry date on a leak you have not noticed yet:

AWS Secrets Manager rotation
// Terraform: Enable automatic rotation
resource "aws_secretsmanager_secret_rotation" "db_password" {
  secret_id           = aws_secretsmanager_secret.db.id
  rotation_lambda_arn = aws_lambda_function.rotate_db.arn

  rotation_rules {
    automatically_after_days = 30
  }
}

// Application: Handle rotation gracefully
class DatabasePool {
  constructor() {
    this.pool = null;
    this.lastCredentialFetch = 0;
    this.credentialTTL = 5 * 60 * 1000; // 5 minutes
  }

  async getConnection() {
    if (this.shouldRefreshCredentials()) {
      await this.refreshPool();
    }
    return this.pool.getConnection();
  }

  shouldRefreshCredentials() {
    return Date.now() - this.lastCredentialFetch > this.credentialTTL;
  }

  async refreshPool() {
    const creds = await getSecret('prod/database');
    // Gracefully transition to new credentials
    const oldPool = this.pool;
    this.pool = createPool(creds);
    this.lastCredentialFetch = Date.now();
    if (oldPool) oldPool.end();
  }
}

Best Practice 5: Environment Separation 5 min

Separate credentials per environment, and mean it:

  • Namespace by environment (prod/stripe, staging/stripe, dev/stripe)
  • Restrict who can read the production namespace
  • Use test-mode or reduced-scope keys in development
  • Never copy a production secret into a local .env, not even once
Environment-based secret loading
// Secret naming convention
const secretName = `${process.env.ENVIRONMENT}/api-keys`;
// Results in: dev/api-keys, staging/api-keys, prod/api-keys

// IAM policy restricts access
{
  "Effect": "Allow",
  "Action": "secretsmanager:GetSecretValue",
  "Resource": "arn:aws:secretsmanager:*:*:secret:prod/*",
  "Condition": {
    "StringEquals": {
      "aws:PrincipalTag/Environment": "production"
    }
  }
}

Best Practice 6: Audit and Monitor Secret Access 5 min

You want to know who read a secret, and when. That log is what turns a suspicion into an answer:

CloudTrail monitoring for secrets
// CloudWatch alarm for unusual secret access
{
  "AlarmName": "UnusualSecretAccess",
  "MetricName": "SecretAccess",
  "Threshold": 100,
  "EvaluationPeriods": 1,
  "Period": 300,
  "Statistic": "Sum",
  "ComparisonOperator": "GreaterThanThreshold"
}

// Log secret access in application
async function getSecretWithAudit(secretName, reason) {
  logger.info('secret.access', {
    secretName,
    reason,
    requestedBy: getCurrentUser(),
    timestamp: new Date().toISOString(),
  });

  return getSecret(secretName);
}

Emergency Response: Rotate first. Investigate second. Once the old credential is dead you can take your time working out how it escaped and whether anyone used it, and that ordering is the whole plan. Write it down somewhere your future panicking self will find it.

External Resources: For comprehensive secrets management guidance, see the OWASP Secrets Management Cheat Sheet and the Cryptographic Storage Cheat Sheet . These resources provide industry-standard security recommendations for protecting sensitive credentials.

What if I accidentally committed a secret?

Rotate it now, before anything else. Generate a new one, revoke the old one, then scrub git history with git filter-branch or BFG Repo-Cleaner. Treat the old secret as permanently burned, because forks and clones keep a copy you can't reach.

Should I encrypt secrets in environment variables?

Environment variables are readable by the process and everything it spawns, so encrypting the value at rest buys you less than it sounds. If that matters, use a secret manager that decrypts at runtime. For most apps, controlling who can reach the environment is enough.

How do I handle secrets in CI/CD?

Use the platform's own secret store, such as GitHub Secrets or GitLab CI Variables. Inject at runtime and keep them out of config files entirely. Where the provider supports OIDC, assume a role instead of storing a long-lived credential.

Further Reading

Put these practices into action with our step-by-step guides.

Scan for Exposed Secrets

Check your codebase for leaked API keys and credentials.

Best Practices

Secrets Management Best Practices: API Keys, Credentials, and Vaults