Loading content...
A developer pushes a new configuration file to production. It contains the application's port number, feature flags, logging levels, and... the database password. All in the same file, treated the same way.
This conflation of configuration and secrets is one of the most common—and most dangerous—mistakes in software engineering. When everything is dumped into the same .env file or configuration system, the secrets inevitably leak with the non-sensitive configuration.
The distinction between configuration and secrets isn't merely academic. It determines:
Mastering this distinction is fundamental to designing secure, maintainable systems.
By the end of this page, you will be able to: definitively classify any value as configuration or secret, understand the different handling requirements for each category, design systems that properly separate configuration from secrets, implement configuration patterns that don't accidentally leak secrets, and establish team practices that maintain the separation over time.
Configuration consists of values that control application behavior without granting access to protected resources or containing sensitive information.
The Key Characteristic:
Configuration values can be safely known by anyone with access to the codebase without creating security risk. If a value being exposed would cause no harm, it's configuration.
Configuration enables flexibility — the same code running in different environments, with different behaviors, without code changes. Configuration is about behavior, not access.
| Category | Examples | Why It's Configuration | Storage Approach |
|---|---|---|---|
| Environment Settings | NODE_ENV, LOG_LEVEL, DEBUG_MODE | Controls behavior, no access granted | Environment variables, config files |
| Feature Flags | ENABLE_NEW_CHECKOUT, BETA_FEATURES | Toggles functionality, no security impact | Config service, environment variables |
| Service Endpoints | API_URL (domain only), CDN_HOST | Routing information, publicly discoverable anyway | Config files, DNS, service discovery |
| Performance Tuning | MAX_CONNECTIONS, TIMEOUT_MS, RETRY_COUNT | Operational parameters, no access control | Config files, environment variables |
| Display Settings | ITEMS_PER_PAGE, DEFAULT_LANGUAGE, TIMEZONE | User experience settings | Config files, environment variables |
| Business Rules | TAX_RATE, FREE_SHIPPING_THRESHOLD | Business logic parameters (unless proprietary) | Config files, database |
Configuration Characteristics:
A simple heuristic: If you would feel comfortable tweeting a value to the world, it's configuration. If the thought of tweeting it makes you anxious, it's probably a secret (or at least sensitive data). 'Our API runs on port 8080' is fine to tweet. 'Our database password is...' is not.
Secrets are values whose exposure would enable unauthorized access, impersonation, data breach, or other security harm.
The Key Characteristic:
Secrets grant capability. They are the keys, passwords, and tokens that prove identity and authorize actions. Knowing a secret is equivalent to having all the powers that secret grants.
Secrets are about access control — they prove you are who you claim to be or that you're authorized to do what you're attempting.
| Category | Examples | What Exposure Enables | Required Protection |
|---|---|---|---|
| Authentication Credentials | Database passwords, service account passwords | Full access to data stores | Encryption, access control, rotation, audit |
| API Keys & Tokens | Stripe API key, GitHub PAT, AWS access keys | API access as the key owner | Secret store, rotation, scope limitation |
| Cryptographic Keys | JWT signing key, encryption keys, private certificates | Token forgery, data decryption | HSM, strict access control, rotation |
| Connection Strings | Full database URLs with credentials embedded | Complete database access | Secret injection, never in logs/code |
| OAuth Secrets | Client secrets, refresh tokens | Application impersonation, user access | Backend-only storage, secure injection |
| Internal URLs with Auth | Webhook URLs with embedded tokens | Unauthorized webhook triggering | Treat as secrets, rotate periodically |
Secrets Characteristics:
If you're unsure whether a value is configuration or a secret, treat it as a secret. The cost of over-protecting configuration is minimal (slightly more complex access). The cost of under-protecting a secret can be catastrophic. Err on the side of caution.
While the definitions seem clear, real-world values often fall into gray areas. Understanding these edge cases is crucial for making sound classification decisions.
Gray Area 1: Internal URLs and Endpoints
An internal API URL like https://internal-api.company.com/v1 seems like configuration—it's just a URL. But consider:
The Rule: Treat internal URLs as sensitive unless they're intentionally public or protected by other means (authentication, network isolation).
Gray Area 2: Identifiers and IDs
User IDs, organization IDs, and resource identifiers seem like simple configuration. But:
The Rule: Use opaque, non-sequential IDs. If IDs must be exposed, ensure they don't enable unwanted actions on their own.
Gray Area 3: Environment Names
Environment identifiers like production, staging, dev seem harmless. But:
The Rule: Environment names are generally safe as configuration, but avoid exposing them unnecessarily in user-facing contexts.
Gray Area 4: Salt Values for Hashing
Salts for password hashing are specifically designed to be stored alongside hashes—they don't need to be secret. But salts for application-wide purposes (like HMAC signatures) might need protection.
The Rule: Per-record salts (like password salts) are stored with data. Application-wide salts used in security operations should be treated as secrets.
Gray Area 5: Third-Party Service Endpoints
External API URLs like https://api.stripe.com/v1/charges are public and documented—clearly configuration. But what about:
The Rule: The base URL is configuration; any token, signature, or identifier embedded in the URL is a secret.
Ask these questions: 1) Does knowing this value grant access to anything? 2) Would exposure help an attacker? 3) Is this value subject to any regulatory protection? 4) Would you put this value in a public README? If any answer suggests sensitivity, treat it as a secret.
Once classified, configuration and secrets require fundamentally different handling throughout the software lifecycle. Understanding these differences is essential for designing systems that are both secure and maintainable.
| Aspect | Configuration | Secrets |
|---|---|---|
| Storage Location | Version control, config files, config services | Secrets managers (Vault, AWS SM), encrypted stores, never VCS |
| Access Control | Repository access is sufficient | Need-to-know basis, separate from code access |
| Visibility | Can appear in logs, error messages, docs | Must be redacted from all outputs |
| Change Process | Normal development workflow, PR review | Security review, rotation procedure, coordination |
| Environment Variance | Different values per environment are expected | Different values AND different access per environment |
| Default Values | Safe to provide sensible defaults in code | NEVER default; fail if missing |
| Documentation | Can document values publicly | Document existence only, never values |
| Backup/Recovery | Standard backup procedures | Special encrypted backups, recovery procedures |
| Audit Requirements | Change history sufficient | Access logs, read audit, change audit |
| Emergency Response | Rollback like any configuration | Immediate rotation, breach assessment |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
/** * Demonstrating Proper Handling of Configuration vs Secrets */ // ============================================// CONFIGURATION: Safe to include in code and logs// ============================================ interface AppConfiguration { // All of these can safely appear in code, logs, and error messages environment: 'development' | 'staging' | 'production'; port: number; logLevel: 'debug' | 'info' | 'warn' | 'error'; apiHost: string; // Domain only, no credentials maxConnections: number; timeoutMs: number; features: { newCheckout: boolean; darkMode: boolean; betaFeatures: boolean; };} // ✅ Configuration can have defaults - it's not sensitiveconst DEFAULT_CONFIG: Partial<AppConfiguration> = { port: 3000, logLevel: 'info', maxConnections: 10, timeoutMs: 5000,}; // ✅ Configuration can be logged at startupfunction logConfiguration(config: AppConfiguration): void { console.log('Application Configuration:'); console.log(` Environment: ${config.environment}`); console.log(` Port: ${config.port}`); console.log(` Log Level: ${config.logLevel}`); console.log(` API Host: ${config.apiHost}`); console.log(` Max Connections: ${config.maxConnections}`); console.log(` Features: ${JSON.stringify(config.features)}`);} // ✅ Configuration can be included in error messagesclass ConfigurationError extends Error { constructor(public readonly config: Partial<AppConfiguration>, message: string) { // Safe to include config details in error super(`Configuration error: ${message}. Current config: ${JSON.stringify(config)}`); }} // ============================================// SECRETS: Must be protected at every stage// ============================================ // Secrets have no defaults - fail if missinginterface ApplicationSecrets { databaseUrl: string; // Contains credentials jwtSigningKey: string; // Cryptographic material stripeSecretKey: string; // Third-party API credential encryptionKey: string; // Data protection key} // ✅ Secrets wrapper prevents accidental exposureclass SecretValue { private readonly encryptedValue: Buffer; constructor(value: string) { // Store encrypted in memory this.encryptedValue = this.encrypt(value); } // Explicit method name makes access intentional unsafeGetValue(): string { return this.decrypt(this.encryptedValue); } // Prevent accidental logging/serialization toString(): string { return '[SECRET]'; } toJSON(): string { return '[SECRET]'; } // Safe operations that don't expose value get length(): number { return this.unsafeGetValue().length; } equals(other: SecretValue): boolean { return this.unsafeGetValue() === other.unsafeGetValue(); } private encrypt(value: string): Buffer { // Implementation: use crypto module with app-specific key return Buffer.from(value); // Simplified } private decrypt(encrypted: Buffer): Buffer { return encrypted; // Simplified }} // ❌ NEVER provide defaults for secretsfunction loadSecrets(): ApplicationSecrets { const databaseUrl = process.env.DATABASE_URL; const jwtSigningKey = process.env.JWT_SECRET; const stripeSecretKey = process.env.STRIPE_SECRET_KEY; const encryptionKey = process.env.ENCRYPTION_KEY; // Fail immediately if any secret is missing const missing: string[] = []; if (!databaseUrl) missing.push('DATABASE_URL'); if (!jwtSigningKey) missing.push('JWT_SECRET'); if (!stripeSecretKey) missing.push('STRIPE_SECRET_KEY'); if (!encryptionKey) missing.push('ENCRYPTION_KEY'); if (missing.length > 0) { // ✅ Log WHICH secrets are missing, never the values throw new Error(`Missing required secrets: ${missing.join(', ')}`); } return { databaseUrl: databaseUrl!, jwtSigningKey: jwtSigningKey!, stripeSecretKey: stripeSecretKey!, encryptionKey: encryptionKey!, };} // ❌ NEVER log secretsfunction unsafeStartup(secrets: ApplicationSecrets): void { // ❌ CATASTROPHICALLY WRONG - exposes all secrets console.log('Starting with secrets:', secrets);} // ✅ LOG that secrets were loaded, not the valuesfunction safeStartup(secrets: ApplicationSecrets): void { console.log('Secrets loaded:'); console.log(' DATABASE_URL: [loaded]'); console.log(' JWT_SECRET: [loaded]'); console.log(' STRIPE_SECRET_KEY: [loaded]'); console.log(' ENCRYPTION_KEY: [loaded]');} // ============================================// COMBINED: Proper separation in application// ============================================ interface ApplicationContext { config: AppConfiguration; secrets: ApplicationSecrets;} async function initializeApplication(): Promise<ApplicationContext> { // Load configuration (can have defaults, can log values) const config: AppConfiguration = { ...DEFAULT_CONFIG, environment: (process.env.NODE_ENV as AppConfiguration['environment']) || 'development', port: parseInt(process.env.PORT || '3000', 10), logLevel: (process.env.LOG_LEVEL as AppConfiguration['logLevel']) || 'info', apiHost: process.env.API_HOST || 'http://localhost:3000', maxConnections: parseInt(process.env.MAX_CONNECTIONS || '10', 10), timeoutMs: parseInt(process.env.TIMEOUT_MS || '5000', 10), features: { newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true', darkMode: process.env.FEATURE_DARK_MODE === 'true', betaFeatures: process.env.FEATURE_BETA === 'true', }, }; // Load secrets (no defaults, fail if missing, never log values) const secrets = loadSecrets(); // ✅ Log configuration freely logConfiguration(config); // ✅ Log that secrets exist, not their values safeStartup(secrets); return { config, secrets };}Proper separation requires intentional design. Systems that treat configuration and secrets the same way will inevitably leak secrets. Here's how to design systems that maintain clean separation.
config.json or app.config.ts. Secrets come from environment variables, secret managers, or separate secure files. Never mix them.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
/** * Clean Architecture for Configuration/Secrets Separation */ // ============================================// SEPARATE TYPE DEFINITIONS// ============================================ // config/types.ts - Public, version-controlledexport interface AppConfig { server: { port: number; host: string; cors: { origins: string[]; credentials: boolean; }; }; features: { newDashboard: boolean; aiRecommendations: boolean; }; limits: { maxRequestSize: string; rateLimit: number; };} // secrets/types.ts - Only type definitions, no valuesexport interface AppSecrets { database: { connectionUrl: string; replicaUrl?: string; }; authentication: { jwtSecret: string; sessionSecret: string; }; externalServices: { stripeKey: string; sendgridKey: string; };} // ============================================// SEPARATE LOADING MECHANISMS// ============================================ // config/loader.ts - Can have defaults, can be lenientimport { readFileSync } from 'fs';import { z } from 'zod'; const configSchema = z.object({ server: z.object({ port: z.number().default(3000), host: z.string().default('0.0.0.0'), cors: z.object({ origins: z.array(z.string()).default(['http://localhost:3000']), credentials: z.boolean().default(true), }).default({}), }).default({}), features: z.object({ newDashboard: z.boolean().default(false), aiRecommendations: z.boolean().default(false), }).default({}), limits: z.object({ maxRequestSize: z.string().default('10mb'), rateLimit: z.number().default(100), }).default({}),}); export function loadConfig(path: string = './config.json'): AppConfig { try { const raw = readFileSync(path, 'utf-8'); const parsed = JSON.parse(raw); // Defaults applied automatically by Zod return configSchema.parse(parsed); } catch (error) { console.warn(`Config file not found at ${path}, using defaults`); // ✅ Safe to use defaults for configuration return configSchema.parse({}); }} // secrets/loader.ts - No defaults, must fail on missingconst secretsSchema = z.object({ database: z.object({ connectionUrl: z.string().min(1, 'DATABASE_URL is required'), replicaUrl: z.string().optional(), }), authentication: z.object({ jwtSecret: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), sessionSecret: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'), }), externalServices: z.object({ stripeKey: z.string().startsWith('sk_', 'STRIPE_KEY must start with sk_'), sendgridKey: z.string().startsWith('SG.', 'SENDGRID_KEY must start with SG.'), }),}); export function loadSecrets(): AppSecrets { const secretsData = { database: { connectionUrl: process.env.DATABASE_URL, replicaUrl: process.env.DATABASE_REPLICA_URL, }, authentication: { jwtSecret: process.env.JWT_SECRET, sessionSecret: process.env.SESSION_SECRET, }, externalServices: { stripeKey: process.env.STRIPE_SECRET_KEY, sendgridKey: process.env.SENDGRID_API_KEY, }, }; // ❌ No defaults, strict validation const result = secretsSchema.safeParse(secretsData); if (!result.success) { // ✅ Log validation errors, never log attempted values const errors = result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`); throw new Error(`Secrets validation failed:\n${errors.join('\n')}`); } return result.data;} // ============================================// SEPARATE ACCESS PATTERNS// ============================================ // In application code, make the separation clearclass ApplicationBootstrap { private config!: AppConfig; private secrets!: AppSecrets; async initialize(): Promise<void> { // Step 1: Load public configuration this.config = loadConfig(); console.log('Configuration loaded:', JSON.stringify(this.config, null, 2)); // Step 2: Load secrets (separate step, different treatment) this.secrets = loadSecrets(); console.log('Secrets loaded: [REDACTED - all secrets present]'); // Step 3: Initialize services with appropriate data await this.initializeDatabase(this.secrets.database); this.initializeServer(this.config.server); } private async initializeDatabase(dbSecrets: AppSecrets['database']): Promise<void> { // Database initialization receives only what it needs // The connection URL (a secret) is passed but never logged console.log('Connecting to database...'); // Connection happens here without logging the URL } private initializeServer(serverConfig: AppConfig['server']): void { // Server initialization uses only configuration // Safe to log all details console.log(`Starting server on ${serverConfig.host}:${serverConfig.port}`); } // ✅ Public method for health checks - only config getPublicStatus(): { environment: string; port: number; features: object } { return { environment: process.env.NODE_ENV || 'development', port: this.config.server.port, features: this.config.features, }; } // ❌ Never expose secrets through any API // This method should not exist: // getSecrets(): AppSecrets { return this.secrets; }}TypeScript's type system becomes a security tool when you separate configuration and secrets types. A function that expects AppConfig physically cannot receive AppSecrets without explicit casting. This type of compile-time enforcement catches mistakes before they become vulnerabilities.
Despite clear guidelines, several anti-patterns persist in codebases. Recognizing these patterns helps you catch and correct them in code reviews and legacy system audits.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
// ============================================// ANTI-PATTERN 1: The Monolithic Config Object// ============================================ // ❌ WRONG: Everything in one objectconst config = { port: 3000, // Configuration ✓ logLevel: 'info', // Configuration ✓ databaseUrl: process.env.DATABASE_URL, // Secret ✗ jwtSecret: process.env.JWT_SECRET, // Secret ✗ enableDarkMode: true, // Configuration ✓ stripeKey: process.env.STRIPE_KEY, // Secret ✗}; // This will log secrets!console.log('Starting with config:', config); // ============================================// ANTI-PATTERN 2: Secrets with Default Values// ============================================ // ❌ WRONG: Default secrets are security vulnerabilitiesconst dbUrl = process.env.DATABASE_URL || 'postgresql://user:pass@localhost/db';const jwtSecret = process.env.JWT_SECRET || 'development-secret-key'; // In production, if env vars aren't set, app runs with known credentials! // ============================================// ANTI-PATTERN 3: Secrets in Configuration Files// ============================================ // ❌ WRONG: config.json with embedded secretsconst fileConfig = { "server": { "port": 3000 }, "database": { // These will be committed to version control! "host": "db.company.com", "password": "realProductionPassword123" }}; // ============================================// ANTI-PATTERN 4: Logging Configuration That Includes Secrets// ============================================ // ❌ WRONG: Generic logging exposes secretsfunction debugConfig(settings: Record<string, unknown>): void { Object.entries(settings).forEach(([key, value]) => { // Will log DATABASE_URL, JWT_SECRET, etc. console.log(`${key} = ${value}`); });} // ============================================// ANTI-PATTERN 5: Exposing Secrets in Error Messages// ============================================ // ❌ WRONG: Connection string in errorasync function connectToDatabase(connectionString: string): Promise<void> { try { // ... connection logic } catch (error) { // Exposes the full connection string including credentials throw new Error(`Failed to connect to ${connectionString}: ${error}`); }} // ============================================// ANTI-PATTERN 6: Secrets Accessible Through APIs// ============================================ // ❌ WRONG: Debug endpoint exposing configurationapp.get('/debug/config', (req, res) => { // Exposes all environment variables including secrets res.json({ env: process.env, // Contains secrets! config: applicationConfig // Might contain secrets! });}); // ============================================// ANTI-PATTERN 7: Shared Secrets Across Environments// ============================================ // ❌ WRONG: Same secret used everywhere// "We'll use the same JWT secret for dev, staging, and prod to make it easier"// A developer with dev access can now forge production tokens! // ============================================// HOW TO FIX: Proper Patterns// ============================================ // ✅ CORRECT: Separate types, separate handlinginterface Configuration { port: number; logLevel: string; enableDarkMode: boolean;} interface Secrets { databaseUrl: string; jwtSecret: string; stripeKey: string;} function loadConfiguration(): Configuration { return { port: parseInt(process.env.PORT || '3000', 10), logLevel: process.env.LOG_LEVEL || 'info', enableDarkMode: process.env.ENABLE_DARK_MODE === 'true', };} function loadSecrets(): Secrets { const databaseUrl = process.env.DATABASE_URL; const jwtSecret = process.env.JWT_SECRET; const stripeKey = process.env.STRIPE_KEY; if (!databaseUrl || !jwtSecret || !stripeKey) { throw new Error('Missing required secrets'); // Don't list which! } return { databaseUrl, jwtSecret, stripeKey };} // ✅ Only log configurationconsole.log('Configuration:', loadConfiguration());console.log('Secrets: [loaded]'); // Never log valuesIf you're working with a codebase that has these anti-patterns, prioritize fixing them. Create a remediation plan: 1) Identify all secrets in configuration, 2) Rotate any secrets that may have been exposed, 3) Refactor to proper separation, 4) Implement pre-commit hooks to prevent regression.
Technical controls are essential, but organizational practices ensure long-term success. Even the best technical design will erode without team discipline and proper processes.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
# ============================================# APPLICATION CONFIGURATION# These are non-sensitive settings that control app behavior# You can use the example values directly for local development# ============================================ # Server ConfigurationPORT=3000HOST=localhostLOG_LEVEL=debug # Feature FlagsENABLE_NEW_CHECKOUT=falseENABLE_DARK_MODE=trueENABLE_BETA_FEATURES=false # Performance TuningMAX_CONNECTIONS=10REQUEST_TIMEOUT_MS=5000RATE_LIMIT_PER_MINUTE=100 # ============================================# SECRETS (SENSITIVE DATA)# These grant access to protected resources# DO NOT commit real values to version control# Get these from your team lead or secrets manager# ============================================ # Database (get connection string from 1Password vault)DATABASE_URL=<get-from-secrets-manager> # Authentication (each developer has unique test keys)JWT_SECRET=<generate-32-character-secret>SESSION_SECRET=<generate-32-character-secret> # External Services (use test/sandbox keys for development)STRIPE_SECRET_KEY=<get-from-stripe-dashboard-test-mode>SENDGRID_API_KEY=<get-from-sendgrid-dashboard> # ============================================# INSTRUCTIONS FOR NEW DEVELOPERS# 1. Copy this file to .env (which is gitignored)# 2. Keep configuration values as-is for local development# 3. Request secrets from your team lead# 4. Never commit .env or share secrets in Slack/email# 5. Report any accidental secret exposure immediately# ============================================The distinction between configuration and secrets is foundational to secure software design. Let's consolidate the key takeaways:
What's next:
Now that we understand the distinction between configuration and secrets, we'll explore secrets injection — the techniques and patterns for delivering secrets to applications at runtime without exposing them in code, configuration files, or logs. This is where theory meets practice: how do secrets actually get from a secure store into a running application?
You now have a rigorous framework for classifying configuration and secrets. You can identify values that require protection, design systems that enforce separation, recognize and fix anti-patterns, and establish team practices that maintain security over time. Next, we'll explore how to inject secrets safely at runtime.