v9.2.0

Secret stores

Read secrets from a secrets manager as typed values, through a wrapper CLI or fetched at startup.

You've copied a database password out of a secrets manager and into a .env file or a host's settings page before. The next rotation changed the store, and your copy went stale.

envapt reads secrets from whichever source you bind, so you never write a secret into a file.

RouteHow the values arriveWhat you writeCost
Wrapper CLIthe CLI sets real environment variables, then starts your processnothing on Node, Bun, and Deno, because FileSource reads process.enva rotation needs a process restart
SDK or HTTP fetchyour code fetches at boota fetch function and one useSource calla bootstrap credential (the token your fetch logs in with), and a network call that can stop startup

Wrapper CLIs

A wrapper CLI resolves the secrets, then starts your process with them set as environment variables. On Node, Bun, and Deno the app reads them like any other variable.

op run --env-file=secrets.env -- node dist/server.js  # 1Password
doppler run -- node dist/server.js                    # Doppler
infisical run -- node dist/server.js                  # Infisical
bws run -- node dist/server.js                        # Bitwarden

With 1Password, secrets.env holds op:// references and no secret value, so you can commit it.

secrets.env
DB_PASSWORD=op://prod/db/password
API_BASE_URL=op://prod/api/base-url
const  = .('DB_PASSWORD', .);
const  = .('API_BASE_URL', .);

With op run, DB_PASSWORD and API_BASE_URL already hold the resolved values when the process starts, so the app never calls useSource.

Fetch at boot, then bind

Reduce what your SDK or HTTP call returns to a flat Record<string, string>. A nested value would reach envapt as JSON text that only Converters.Json parses. Then pass FileSource and the secrets to merge in one useSource call, with the secrets second so they win a shared key.

import { , , , ,  } from 'envapt';

import {  } from './secrets';

export async function (): <void> {
    .((new (), new (await ())));
}

await ();

export const  = .(
    { : ., : ., : . },
    'camelCase'
);

bindSecrets awaits the fetch before it calls useSource, since envapt reads a source synchronously and can't wait for a fetch. With merge, a key from the fetched secrets overrides the same key in process.env. It also wins over the .env files unless you turn on override in the file options. Any key the store omits still comes from process.env and the files.

On Node, if a read runs before bindSecrets finishes, it gets values from process.env and the files without the secrets, because the node build bound FileSource on import. That read doesn't throw NoSourceBound, so nothing tells you it ran too early. Import config.ts before any module that reads config, as when values are read explains.

WARNING

If you bind only a PortableSource on Node, it replaces FileSource. The app reads only the secrets. process.env and the .env cascade are gone. Envapter.environment returns 'development' unless the secrets include one of the detection keys. Setting baseDir or envPaths, or calling configureProfiles, throws FileApiUnsupported. Merge with FileSource to keep process.env, the cascade, environment detection, and the file APIs.

On Workers, there is no FileSource. Merge two PortableSource objects instead, one built from the Worker's env and one from the fetched secrets. Do it inside the request handler, because Cloudflare throws if a Worker calls fetch() while its top-level code runs. Each bind rebuilds envapt's cache, as Reading fresh values explains.

The secrets you fetched are a snapshot from boot. To refresh them, call bindSecrets() again. A value you already read keeps its old result, so config above stays as it was. Decorated fields read the new secrets on their next access, because useSource clears their cached values.

The fetch, per store

Pin and verify every SDK against its own docs before shipping, since each snippet matches the SDK as it was when this page was written. The snippets read their own settings through envapt too. Those reads run before useSource, so they come from process.env and the files. If a key is missing, getRequired throws MissingEnvValue (305) at boot.

import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
import { Converters, Envapter } from 'envapt';

export async function fetchSecrets(): Promise<Record<string, string>> {
    const client = new SecretsManagerClient();
    const secretId = Envapter.getRequired('SECRET_ID', Converters.String);
    const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
    if (!SecretString) throw new Error(`${secretId} has no SecretString`);
    return JSON.parse(SecretString) as Record<string, string>;
}

Store the secret as one flat JSON object with string values, since the cast hides any other type. The client resolves credentials through the standard AWS credential chain.

On this page