This document describes how the application loads, transforms, manages, and rotates secrets at runtime, and the security guarantees provided by the implementation.
Secrets are managed through four constructs defined in src/config/secrets.ts:
| Construct | Description |
|---|---|
Secret<T> |
Interface: get() + refresh() |
EnvSecret<T> |
Loads from process.env; supports optional transform |
SecretsManager |
Registry that groups named secrets and bulk-refreshes them |
RotatingSecret<T> |
Async provider-backed secret with optional background polling |
All constructs implement the same Secret<T> interface, so they are interchangeable at call sites.
interface Secret<T> {
get(): T;
refresh(): Promise<void>;
}Reads a value from process.env synchronously at construction time.
constructor(
key: string, // environment variable name
defaultValue?: T, // fallback if the variable is absent
transform?: (val: string) => T // optional coercion / validation
)- If the variable is absent and no
defaultValueis provided, the constructor throws immediately (fail-fast). refresh()re-readsprocess.env[key]— useful when a sidecar updates environment variables at runtime.
const manager = new SecretsManager();
manager.register('PORT', new EnvSecret<number>('PORT', 3001, Number));
manager.register('JWT_SECRET', new EnvSecret('JWT_SECRET'));
manager.getValue<number>('PORT'); // 3001
await manager.refreshAll(); // re-reads all registered secrets
manager.clear(); // removes all registrations (useful in tests)A default singleton secretsManager is exported and pre-populated by initializeSecrets().
Fetches a secret from an async provider function and caches the last successful value.
const secret = new RotatingSecret({
provider: async () => fetchFromVault('my-api-key'),
transform: (raw) => JSON.parse(raw),
defaultValue: undefined,
refreshIntervalMs: 60_000, // background refresh every minute
name: 'my-api-key', // used in log messages only (never the value)
});
await secret.refresh(); // manual first fetch
secret.get(); // returns cached value synchronously
secret.stopAutoRefresh(); // stops background timer (call in tests/shutdown)On refresh failure the previous value is retained (fail-safe) and only a minimal warning — containing the secret name, never its value — is written to the logger.
The transform callback receives the raw secret string. If the callback throws an error that echoes its input (e.g. a JSON parser that includes the malformed token in the message), a naive implementation would propagate the raw value into error.message, then into startup logs and stack traces.
EnvSecret.load() wraps transform in a catch block that discards the original error completely:
} catch {
// Never include the original error message or any derivative of the raw
// secret value in the thrown error — a thrown parser error can echo its
// input. Only the key name is safe to surface here.
throw new Error(
`Configuration Error: Failed to transform secret "${this.key}" — transform threw an error (details omitted to protect secret value)`
);
}The re-thrown error contains only the key name. It never includes:
error.messageString(error)JSON.stringify(error)- Any other serialisation of the caught error object
This means the raw secret value cannot leak into logs regardless of what the transform throws.
| Transform throws | Secret value leaks? |
|---|---|
new Error(\parse failed near: ${rawValue}`)` |
No — original message discarded |
throw rawValue (plain string) |
No — string not serialised |
throw { code: 'ERR', input: rawValue } (object) |
No — object not serialised |
throw null / throw undefined |
No — nothing to serialise |
| Nothing (success path) | N/A |
The catch block intentionally uses the binding-free catch { form (no catch (e)) so it is
syntactically impossible to reference the caught value, making accidental leakage a compile-time
error rather than a runtime risk.
src/config/secrets.test.ts contains a dedicated 'transform error redaction' suite that
asserts all four throw variants above produce error messages that contain neither the raw secret
value nor any 4-character substring of it.
import { EnvSecret } from './config/secrets';
const jwtSecret = new EnvSecret('JWT_SECRET');
jwtSecret.get(); // raw stringconst port = new EnvSecret<number>('PORT', 3001, (v) => {
const n = parseInt(v, 10);
if (isNaN(n)) throw new Error('PORT must be a number');
return n;
});
// If parseInt throws or returns NaN, the error message will NOT contain the raw value of PORTinterface DbConfig { host: string; port: number; }
const dbConfig = new EnvSecret<DbConfig>('DB_CONFIG', undefined, (raw) => {
// JSON.parse may throw with the raw string in its message — this is safe
// because EnvSecret will discard that error before re-throwing.
return JSON.parse(raw) as DbConfig;
});import { RotatingSecret } from './config/secrets';
const signingKey = new RotatingSecret({
provider: async () => {
const res = await fetch('https://vault.internal/v1/secret/signing-key');
return (await res.json()).data.value as string;
},
refreshIntervalMs: 5 * 60 * 1000, // every 5 minutes
name: 'signing-key',
});
await signingKey.refresh(); // initial fetch before serving traffic- Never log
secret.get()— pass the value only to the library/function that needs it. - Transform errors are safe to log — the re-thrown error from
EnvSecretcontains only the key name. RotatingSecretrefresh failures are logged atwarnlevel with only the secret name, never its value.- Default values in production — secrets with development defaults (
JWT_SECRET,DATABASE_URL) must be overridden via environment variables before deploying to production. The application does not enforce this automatically; use infrastructure-level checks (e.g., CI env-var validation) to catch missing production secrets early. redactSecretfromsrc/utils/redact.tscan be used at any call site to replace a secret value with[REDACTED]before it reaches a logger or response body.
import { redactSecret } from '../utils/redact';
logger.info('Using signing key', { key: redactSecret(signingKey.get()) });
// logs: { key: '[REDACTED]' }- Update
.env.examplewith the new variable (without real values) - Update this documentation to include the new secret in the Registered Secrets table
Comprehensive tests are located in src/config/secrets.test.ts. These tests cover:
- Successful loading from environment variables
- Usage of default values
- Error handling for missing required secrets
- Type transformation logic
- Secret rotation/refreshing
SecretsManagerregistration and retrievalRotatingSecretfail-safe behavior
JWT_SECRET and DATABASE_URL are required outside development/test.
If either is missing when NODE_ENV is production or staging, the app
throws at boot (initializeSecrets() in src/config/secrets.ts) instead of
silently falling back to a committed default.
- Required in production/staging (no fallback).
- Must be at least 32 characters.
- The known development placeholder (
dev-secret-keep-it-safe) is rejected even if explicitly set — it's committed to source, so anyone can forge a valid JWT if it's ever used outside development.
- Required in production/staging (no fallback).
- The known development placeholder
(
postgresql://localhost:5432/talenttrust) is rejected even if explicitly set, for the same reason.
A missing or placeholder secret in production is a silent security hole — the app boots fine, but auth tokens (or DB access) are signed/secured with a value anyone can find in the repo. Failing at boot turns that into an immediately visible deploy failure instead of a live vulnerability.
In development and test, both secrets fall back to their documented
defaults automatically — no setup needed. See .env.example.