Skip to content

Secrets

@modularityjs/secrets defines an abstract SecretsService for reading API keys, database passwords, JWT signing keys, and similar credentials from a managed vault. Drivers exist for HashiCorp Vault, AWS Secrets Manager, and GCP Secret Manager; a memory driver covers tests and local dev. The secrets-config extension plugs the contract into the config chain so apps can call config.get('app/stripe-key') and the value transparently comes from the configured vault in production.

Contract

typescript
abstract class SecretsService {
  abstract get(name: string): Promise<string>; // throws NotFoundException
  abstract has(name: string): Promise<boolean>;
}

get throws NotFoundException when the secret is missing — that's almost always a configuration bug the caller cannot recover from. has lets callers (notably the config extension) probe without try/catch noise. The shape mirrors StorageService.read — same throws-on-missing rubric.

Drivers

Memory (@modularityjs/secrets-memory)

In-memory store seeded at boot. Useful for tests, and for apps that load secrets once at startup (e.g. from a CI-injected file).

typescript
import { SecretsModule } from '@modularityjs/secrets';
import { SecretsMemoryModule } from '@modularityjs/secrets-memory';

const modules = [
  SecretsModule,
  SecretsMemoryModule.forRoot({
    entries: {
      'stripe-api-key': process.env.STRIPE_API_KEY!,
      'jwt-signing-key': await loadFromCISecret(),
    },
  }),
];

HashiCorp Vault (@modularityjs/secrets-vault)

Reads KV v2 secrets via the Vault HTTP API. Token auth only in v0 — AppRole / Kubernetes auth is a follow-up. Zero SDK dependency (uses Node 22+ built-in fetch).

typescript
import { SecretsVaultModule } from '@modularityjs/secrets-vault';

const modules = [
  SecretsModule,
  SecretsVaultModule.forRoot({
    addr: 'https://vault.example.com:8200',
    token: process.env.VAULT_TOKEN!,
    mount: 'secret', // KV v2 mount; default 'secret'
    pathPrefix: 'app', // optional — prepended to every name
    field: 'value', // KV v2 data field; default 'value'
  }),
];

SecretsService.get('stripe-key') reads https://vault.example.com:8200/v1/secret/data/app/stripe-key and returns the value field. Each secret should be stored as {"value": "..."} (the default field).

AWS Secrets Manager (@modularityjs/secrets-aws)

Wraps SecretsManagerClient from @aws-sdk/client-secrets-manager. Credentials come from the default AWS chain (env vars, IAM role, etc.).

typescript
import { SecretsAwsModule } from '@modularityjs/secrets-aws';

const modules = [
  SecretsModule,
  SecretsAwsModule.forRoot({
    region: 'us-east-1',
    namePrefix: 'prod/', // optional — environment isolation
    // endpoint: 'http://localhost:4566',   // for LocalStack
  }),
];

Returns SecretString (UTF-8). Binary-only secrets are reported as missing.

GCP Secret Manager (@modularityjs/secrets-gcp)

Wraps SecretManagerServiceClient from @google-cloud/secret-manager. Auth via Application Default Credentials by default; pass keyFilename for a service-account key file.

typescript
import { SecretsGcpModule } from '@modularityjs/secrets-gcp';

const modules = [
  SecretsModule,
  SecretsGcpModule.forRoot({
    projectId: 'my-project',
    version: 'latest', // or a numeric version string ('1', '7')
    // keyFilename: '/etc/gcp/sa.json',
  }),
];

GCP secret names allow only [A-Za-z0-9_-], so the driver translates slashes to underscores: app/stripe-keyprojects/my-project/secrets/app_stripe-key/versions/latest.

Config Extension

@modularityjs/secrets-config registers a ConfigSource (priority 1500, above EnvConfigSource's 1000) that pre-loads declared secret paths at boot and serves them synchronously to ConfigService.get() / .resolve().

typescript
import { ConfigModule } from '@modularityjs/config';
import { SecretsModule } from '@modularityjs/secrets';
import { SecretsAwsModule } from '@modularityjs/secrets-aws';
import { SecretsConfigModule } from '@modularityjs/secrets-config';

const modules = [
  ConfigModule,
  SecretsModule,
  SecretsAwsModule.forRoot({ region: 'us-east-1' }),
  SecretsConfigModule.forRoot({
    paths: ['app/stripe-key', 'app/db-password', 'app/jwt-signing-key'],
  }),
];

Then in any service:

typescript
@Injectable()
class StripeService {
  constructor(@Inject(ConfigService) private readonly config: ConfigService) {}

  initialise() {
    const apiKey = this.config.get<string>('app/stripe-key');
    // …
  }
}

Why pre-load?

ConfigSource.get(path) is synchronous (apps call config.get() from constructors and other hot paths) but SecretsService.get() is async (network calls to Vault/AWS/GCP). The extension resolves this by fetching all declared paths once during the module's onInit and serving subsequent reads from an in-memory cache. Boot fails fast if any declared path is missing — exactly what you want for misconfiguration.

Cached values live until process exit; rotation requires an app restart in v0.

Path-name interplay

The extension passes the config path string straight to SecretsService.get(name). Each driver translates internally:

DriverPath app/stripe-key becomes…
memoryLookup key app/stripe-key in entries
vaulthttps://addr/v1/{mount}/data/{pathPrefix?}/app/stripe-key
awsGetSecretValue({ SecretId: '{namePrefix?}app/stripe-key' })
gcpprojects/{projectId}/secrets/app_stripe-key/versions/{version}

If your secret naming convention doesn't match, configure pathPrefix (vault), namePrefix (aws), or rename the path to match (gcp).

Failure Modes and Tuning

ConcernMitigation
Vault / AWS / GCP unreachable at bootSecretsConfigModule.onInit rejects → createApp() throws — app never starts with broken config.
A declared secret was deleted or renamedSame: boot fails on the next deploy with a clear NotFoundException naming the path.
Rotated secret in the vaultThe cached value is stale until process restart. v0 has no hot-reload — restart the app on rotation.
Network call latency at bootEach declared path is one round trip. 30 secrets × 50ms = 1.5s added to boot — usually fine; parallelise if not.
Missing IAM permissions (AWS) / role (GCP)The SDK throws on get, propagated as-is; boot fails with the SDK's error message.

Choosing a Driver

Production targetDriver
AWSsecrets-aws
GCPsecrets-gcp
On-prem / hybridsecrets-vault
Tests / local devsecrets-memory

Apps can register multiple modules — the last one in preferences: wins per the framework's standard last-wins rule. In practice you pick one driver per environment.