# 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](/docs/sources) you bind, so you never write a secret into a file.

| Route | How the values arrive | What you write | Cost |
| --- | --- | --- | --- |
| Wrapper CLI | the CLI sets real environment variables, then starts your process | nothing on Node, Bun, and Deno, because [`FileSource`](/docs/sources#the-sources-envapt-ships) reads `process.env` | a rotation needs a process restart |
| SDK or HTTP fetch | your code fetches at boot | a fetch function and one `useSource` call | a 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.

```bash
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.

```dotenv title="secrets.env"
DB_PASSWORD=op://prod/db/password
API_BASE_URL=op://prod/api/base-url
```

```ts
const dbPassword = Envapter.getRequired('DB_PASSWORD', Converters.String);
const apiBase = Envapter.getRequired('API_BASE_URL', Converters.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`](/docs/sources#combining-sources) in one [`useSource`](/docs/sources) call, with the secrets second so they win a shared key.

```ts
import { Converters, Envapter, FileSource, PortableSource, merge } from 'envapt';

import { fetchSecrets } from './secrets';

export async function bindSecrets(): Promise<void> {
    Envapter.useSource(merge(new FileSource(), new PortableSource(await fetchSecrets())));
}

await bindSecrets();

export const config = Envapter.getRequiredAll(
    { DATABASE_URL: Converters.Url, DB_POOL_SIZE: Converters.Integer, CACHE_TTL: Converters.Time },
    'camelCase'
);
```

`bindSecrets` awaits the fetch before it calls `useSource`, since envapt reads a source synchronously and can't wait for a fetch. With [`merge`](/docs/sources#combining-sources), 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](/docs/env-files#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](/docs/sources#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](/docs/env-files) are gone. `Envapter.environment` returns `'development'` unless the secrets include one of the [detection keys](/docs/environment#detecting-the-environment). Setting `baseDir` or `envPaths`, or calling `configureProfiles`, throws [`FileApiUnsupported`](/docs/errors). Merge with `FileSource` to keep `process.env`, the cascade, environment detection, and the file APIs.

On Workers, there is no `FileSource`. Merge two [`PortableSource`](/docs/sources#the-sources-envapt-ships) objects instead, one built from the [Worker's `env`](/docs/workers) 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](/docs/workers#reading-fresh-values-on-every-request) 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](/docs/decorators) 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.

**AWS**

```ts
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.

**Vault**

```ts
import vault from 'node-vault';
import { Converters, Envapter } from 'envapt';

export async function fetchSecrets(): Promise<Record<string, string>> {
    const client = vault({ endpoint: Envapter.getRequired('VAULT_ADDR', Converters.String) });
    await client.approleLogin({
        role_id: Envapter.getRequired('VAULT_ROLE_ID', Converters.String),
        secret_id: Envapter.getRequired('VAULT_SECRET_ID', Converters.String)
    });

    const { data } = await client.read('secret/data/myapp');
    return data.data as Record<string, string>;
}
```

HashiCorp has no official Node client. `node-vault` is a community library. KV v2 puts the payload at `data.data`, and the read path carries a `data/` segment (`secret/data/myapp`).

**1Password**

```ts
import sdk from '@1password/sdk';
import { Converters, Envapter } from 'envapt';

const REFS = {
    DB_PASSWORD: 'op://prod/db/password',
    API_KEY: 'op://prod/api/key'
};

export async function fetchSecrets(): Promise<Record<string, string>> {
    const client = await sdk.createClient({
        auth: Envapter.getRequired('OP_SERVICE_ACCOUNT_TOKEN', Converters.String),
        integrationName: 'my-app',
        integrationVersion: 'v1.0.0'
    });

    const secrets: Record<string, string> = {};
    for (const [key, ref] of Object.entries(REFS)) {
        secrets[key] = await client.secrets.resolve(ref);
    }
    return secrets;
}
```

`OP_SERVICE_ACCOUNT_TOKEN` is the bootstrap credential, a service-account token. The SDK is pre-1.0, so its API can change between releases. Use the `op run` route from the wrapper CLI section to skip the dependency.

**Doppler**

```ts
import { Converters, Envapter } from 'envapt';

export async function fetchSecrets(): Promise<Record<string, string>> {
    const url = new URL('https://api.doppler.com/v3/configs/config/secrets/download');
    url.searchParams.set('format', 'json');

    const project = Envapter.get('DOPPLER_PROJECT');
    if (project) url.searchParams.set('project', project);
    const config = Envapter.get('DOPPLER_CONFIG');
    if (config) url.searchParams.set('config', config);

    const token = Envapter.getRequired('DOPPLER_TOKEN', Converters.String);
    const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!response.ok) throw new Error(`Doppler answered ${response.status}`);
    return (await response.json()) as Record<string, string>;
}
```

A service token already identifies its project and config, so `DOPPLER_PROJECT` and `DOPPLER_CONFIG` are optional here. A personal token needs both. Check the auth scheme against Doppler's API reference, since older docs used HTTP Basic with the token as the username and an empty password. Doppler returns a 401 for a wrong scheme. With `doppler run` you don't need this code.

**Infisical**

```ts
import { InfisicalSDK } from '@infisical/sdk';
import { Converters, Envapter } from 'envapt';

export async function fetchSecrets(): Promise<Record<string, string>> {
    const client = new InfisicalSDK();
    await client.auth().universalAuth.login({
        clientId: Envapter.getRequired('INFISICAL_CLIENT_ID', Converters.String),
        clientSecret: Envapter.getRequired('INFISICAL_CLIENT_SECRET', Converters.String)
    });

    const { secrets } = await client.secrets().listSecrets({
        projectId: Envapter.getRequired('INFISICAL_PROJECT_ID', Converters.String),
        environment: Envapter.get('INFISICAL_ENVIRONMENT', 'prod')
    });
    return Object.fromEntries(secrets.map((secret) => [secret.secretKey, secret.secretValue]));
}
```

Each secret has a `secretKey` and a `secretValue`. If the identity can't read values, Infisical masks them and sets `secretValueHidden` on each secret. Use `@infisical/sdk` v5 or later, since `infisical-node` is deprecated.

**Bitwarden**

```ts
import { BitwardenClient, DeviceType, LogLevel } from '@bitwarden/sdk-napi';
import { Converters, Envapter } from 'envapt';

export async function fetchSecrets(): Promise<Record<string, string>> {
    const client = new BitwardenClient(
        {
            apiUrl: 'https://api.bitwarden.com',
            identityUrl: 'https://identity.bitwarden.com',
            deviceType: DeviceType.SDK
        },
        LogLevel.Info
    );
    await client.auth().loginAccessToken(Envapter.getRequired('BW_ACCESS_TOKEN', Converters.String));

    const { data } = await client.secrets().list(Envapter.getRequired('BW_ORG_ID', Converters.String));
    const full = await client.secrets().getByIds(data.map((secret) => secret.id));
    return Object.fromEntries(full.data.map((secret) => [secret.key, secret.value]));
}
```

Fetch the values with `getByIds`, since `list()` returns identifiers only. The package ships native binaries and runs on Node. Bitwarden's npm description still calls it a beta release.