Hardcoding secrets is a security breach waiting to happen. Committing .env to Git is an instant incident. Claude Code generates safe secrets management patterns when you define the rules in CLAUDE.md.
CLAUDE.md for Secrets Management
## Secrets Management Rules
### Prohibited
- No hardcoded secrets (API keys, passwords, tokens) anywhere in code
- Never commit .env to Git — .gitignore must list .env
- No secrets in log output (mask before logging)
### Production
- Load secrets from AWS Secrets Manager or HashiCorp Vault
- Load at application startup into memory
- Never write secrets to files or environment after startup
- Rotate every 30 days
### Development
- Use .env file locally
- .env.example contains variable names only (no values)
- Document how to obtain each value in .env.example comments
### Startup Validation
- Validate all secrets with Zod schema on startup
- Fail fast: if any required secret is missing, log the missing field names and exit(1)
- Do not start the server with incomplete secrets
Zod Secrets Schema
// src/config/secretsSchema.ts
import { z } from 'zod';
export const secretsSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
REDIS_URL: z.string().url(),
GITHUB_WEBHOOK_SECRET: z.string().min(16),
});
export type Secrets = z.infer<typeof secretsSchema>;
Each field has a domain-specific validator — url() for connection strings, startsWith('sk_') to catch wrong Stripe keys (test vs live), min(32) to enforce key strength.
loadSecrets() — Production + Dev Branch
// src/config/secrets.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
import { secretsSchema, Secrets } from './secretsSchema';
import { logger } from '../lib/logger';
const client = new SecretsManagerClient({ region: process.env.AWS_REGION ?? 'ap-northeast-1' });
let cachedSecrets: Secrets | null = null;
export async function loadSecrets(): Promise<void> {
let raw: Record<string, unknown>;
if (process.env.NODE_ENV === 'production') {
const command = new GetSecretValueCommand({
SecretId: process.env.SECRET_NAME ?? 'myapp/production',
});
const response = await client.send(command);
raw = JSON.parse(response.SecretString ?? '{}');
} else {
// Development: read from process.env (populated by dotenv)
raw = process.env as Record<string, unknown>;
}
const result = secretsSchema.safeParse(raw);
if (!result.success) {
const missing = result.error.issues.map((i) => i.path.join('.')).join(', ');
logger.fatal({ missing }, 'Missing or invalid secrets — refusing to start');
process.exit(1);
}
cachedSecrets = result.data;
}
export function getSecrets(): Secrets {
if (!cachedSecrets) {
throw new Error('Secrets not loaded — call loadSecrets() first');
}
return cachedSecrets;
}
safeParse instead of parse avoids throwing — the error path logs all missing fields at once before calling process.exit(1). Operators see exactly what's misconfigured, not a cryptic runtime crash 10 minutes later.
main.ts — Fail Fast on Startup
// src/main.ts
import 'dotenv/config'; // no-op in production
import { loadSecrets } from './config/secrets';
import { connectDatabase } from './lib/database';
import { connectRedis } from './lib/redis';
import { createApp } from './app';
import { logger } from './lib/logger';
async function main() {
// 1. Load and validate secrets first — exit if anything is missing
await loadSecrets();
// 2. Connect to infrastructure
await connectDatabase();
await connectRedis();
// 3. Start the server
const app = createApp();
const port = process.env.PORT ?? 3000;
app.listen(port, () => {
logger.info({ port }, 'Server started');
});
}
main().catch((err) => {
logger.fatal(err, 'Fatal startup error');
process.exit(1);
});
Order matters: secrets first, then infrastructure, then server. A misconfigured secret fails in under a second instead of after a health check timeout.
.env.example
# Database
# Format: postgresql://USER:PASSWORD@HOST:PORT/DBNAME
DATABASE_URL=
# JWT
# Generate with: openssl rand -base64 32
JWT_SECRET=
# Stripe
# Get from: https://dashboard.stripe.com/apikeys
# Use sk_test_ for development, sk_live_ for production
STRIPE_SECRET_KEY=
# Redis
# Format: redis://HOST:PORT
REDIS_URL=
# GitHub Webhooks
# Generate with: openssl rand -hex 20
# Set same value in GitHub repo Settings > Webhooks > Secret
GITHUB_WEBHOOK_SECRET=
.env.example ships with the repo. .env never does. Comments explain where to get each value so new team members don't ask in Slack.
Summary
Four rules for safe secrets management with Claude Code:
- CLAUDE.md — prohibit hardcoding, mandate Secrets Manager in production, fail fast
-
AWS Secrets Manager — single source of truth in production, no
.envfiles on servers - Zod schema — validate types, formats, and strength at startup (not at runtime)
-
Fail fast —
process.exit(1)with the missing field names; never start with incomplete config
Security Pack (¥1,480) includes /security-check — audits your CLAUDE.md rules for secrets exposure, hardcoded credentials, and misconfigured environment handling.
Myouga (@myougatheaxo) — Claude Code engineer focused on security patterns.
Top comments (0)