Skip to content

Recipes

kurok edited this page Aug 31, 2026 · 3 revisions

Recipes & Use Cases

Worked examples for the situations this client is actually used in. Every snippet below was executed against a stubbed Vault while writing this page, so the method names, paths and return shapes are what the code really does.


1. Load application config at boot

The common case: fetch secrets once at startup, fail fast if Vault is unreachable, then hand plain values to the rest of the app so nothing else needs to know Vault exists.

const VaultClient = require('node-vault-client');

async function loadSecrets() {
    const vault = VaultClient.boot('main', {
        api:  { url: process.env.VAULT_ADDR },
        auth: { type: 'kubernetes', config: { role: 'orders-api' } },
    });

    const [db, stripe] = await Promise.all([
        vault.read('secret/orders/db'),
        vault.read('secret/orders/stripe'),
    ]);

    return {
        db:     { password: db.getValue('password'), user: db.getValue('user') },
        stripe: { key: stripe.getValue('secret_key') },
    };
}

// Let a failure here kill the process: a service that boots with no credentials
// only fails later, further from the cause.
const secrets = await loadSecrets();

2. node-config integration

If you already use node-config, you can keep secrets out of your config files and let the client fill them in. Create config/custom-vault-variables.js mapping config keys to '<vault path>#<key in that secret>':

// config/custom-vault-variables.js
module.exports = {
    db: {
        password: 'secret/orders/db#password',
        user:     'secret/orders/db#user',
    },
    stripe: { key: 'secret/orders/stripe#secret_key' },
};
const config = require('config');
const VaultClient = require('node-vault-client');

await VaultClient.boot('main', { /* ... */ }).fillNodeConfig();

config.get('db.password');  // now the value from Vault

Each distinct Vault path is read once, however many keys reference it, and the results are deep-merged into the existing config. A reference whose key is missing from the secret throws rather than silently yielding undefined. Requires the config package (a peer dependency, >=1 <4).

One sharp edge: setup failures are thrown synchronously, before the promise exists — a missing config package, or a missing/unreadable custom-vault-variables.js. fillNodeConfig().catch(handler) will not catch those, so await inside try/catch instead.

3. A pod on Kubernetes

No secrets in the manifest — the pod authenticates with its service-account JWT.

const vault = VaultClient.boot('main', {
    api:  { url: 'https://vault.internal:8200/' },
    auth: {
        type: 'kubernetes',
        config: {
            role: 'orders-api',
            // default; override if you project the token elsewhere
            tokenPath: '/var/run/secrets/kubernetes.io/serviceaccount/token',
        },
    },
});

The JWT is read from disk at each login, so when the kubelet rotates a projected token the next login picks up the new one — no restart, no stale-JWT failures after 90 days.

4. ECS / EC2 / Lambda with AWS IAM

Omit credentials and the client uses the AWS default provider chain, so the task role, instance profile or Lambda execution role is used automatically. Nothing secret goes in your configuration.

const vault = VaultClient.boot('main', {
    api:  { url: 'https://vault.internal:8200/' },
    auth: {
        type: 'iam',
        config: {
            role: 'orders-api',
            region: 'us-east-1',
            // iam_server_id_header_value: 'vault.example.com', // if the Vault role requires it
        },
    },
});

5. KV v2 without rewriting paths

Vault's KV v2 API puts data under secret/data/<path> and metadata under secret/metadata/<path>. You do not have to care. There are two ways to tell the client which mounts are v2:

Static map — no extra round-trip, works even if the detection endpoint is denied to your token:

api: {
    url: 'https://vault.example.com:8200/',
    engines: { secret: 2, legacy: 1 },
}

Auto-detection — ask Vault, and cache the answer per mount:

api: {
    url: 'https://vault.example.com:8200/',
    kv: { autoDetect: true },
}

Either way your code stays the same:

const lease = await vault.read('secret/app');   // GET /v1/secret/data/app  on a v2 mount
await vault.write('secret/app', { k: 'v' });    // POST with the { data: {...} } envelope

Two things worth knowing:

  • engines works without autoDetect. With autoDetect off, listed mounts use your map and every other mount is treated as v1 — no detection request is ever made. That makes engines the right choice when your token cannot read sys/internal/ui/mounts.
  • With autoDetect on, resolved mounts are cached in a bounded LRU (500 entries), so detection costs one request per mount, not one per read.

6. Vault Enterprise namespaces

api: {
    url: 'https://vault.example.com:8200/',
    namespace: 'team-a',
}

X-Vault-Namespace is applied by the API layer to every request — login, auth/token/lookup-self and renewal included, for all four auth backends. That was a real bug once (a backend that skipped namespacing on lookup-self), and there is now a conformance suite that fails if any backend drops the header on any request.

The namespace is per client, and request() takes no headers argument, so to talk to two namespaces boot two clients:

const teamA = VaultClient.boot('team-a', { api: { url, namespace: 'team-a' }, auth });
const teamB = VaultClient.boot('team-b', { api: { url, namespace: 'team-b' }, auth });

For backward compatibility auth.config.namespace is still honoured, but api.namespace is the canonical place.

7. Picking up rotated credentials

The client keeps its own auth token alive automatically. Rotating secret material (a database password that your ops rotate every 24h) is a different question — re-read on an interval and swap the value in place:

let dbPassword = (await vault.read('secret/orders/db')).getValue('password');

setInterval(async () => {
    try {
        dbPassword = (await vault.read('secret/orders/db')).getValue('password');
    } catch (err) {
        // Keep serving with the previous value; a Vault blip should not take the app down.
        log.warn({ err }, 'secret refresh failed, keeping the previous value');
    }
}, 15 * 60 * 1000).unref();

unref() matters: without it the interval keeps the process alive at shutdown.

8. Changing one key without clobbering the rest

write() replaces the whole secret. update() sends an HTTP PATCH with Content-Type: application/merge-patch+json, so only the keys you pass change:

await vault.write('secret/app', { A: '1', B: '2' });
await vault.update('secret/app', { B: '3' });     // A is still '1'

Requires a Vault that supports merge-patch on KV v2 (and a policy granting patch).

9. KV v2 versions: read, delete, undelete, destroy

const meta = await vault.readMetadata('secret/app');
meta.data.current_version;                         // note the .data — this is the raw Vault envelope
meta.data.versions;                                // { '1': {...}, '2': {...} }

await vault.deleteVersions('secret/app', [3]);     // soft delete — recoverable
await vault.undeleteVersions('secret/app', [3]);   // bring it back
await vault.destroyVersions('secret/app', [1, 2]); // permanent
await vault.deleteMetadata('secret/app');          // remove the secret and all versions

These are v2-only and fail fast on a v1 mount, before any HTTP request is made:

UnsupportedOperationError: Operation "deleteVersions" is only supported on KV v2 mounts.
Mount "legacy" is not a KV v2 engine.

Gotcha worth knowing. That decision is made by mount resolution, not by the server. If you configure neither api.kv.autoDetect: true nor an api.engines entry covering the mount, every path resolves as v1 with no detection call — so these methods fail with the message above even against a genuine KV v2 mount. The error names your mount and asserts it is not v2, which reads like a Vault problem when it is really a missing line of client config. If you use the v2 helpers, configure one of the two (see recipe 5).

10. Endpoints the client does not wrap

request() is the escape hatch. It sends the path literally — no KV path rewriting — with authentication and namespace headers already applied:

await vault.request('GET',  '/sys/health');
await vault.request('POST', '/pki/issue/my-role', { common_name: 'svc.internal' });
await vault.request('POST', '/transit/encrypt/my-key', { plaintext: b64 });

So PKI, Transit, database credentials and anything else Vault exposes remain reachable without dropping to raw HTTP and re-implementing auth.

11. Error handling

Errors are typed, so you can branch on them instead of matching strings:

const {
    VaultError,               // base class
    VaultHttpError,           // non-2xx from Vault; has .statusCode and .error
    InvalidArgumentsError,    // bad configuration or arguments
    InvalidAWSCredentialsError,
    AuthTokenExpiredError,
    UnsupportedOperationError, // e.g. a v2-only call on a v1 mount
} = require('node-vault-client/src/errors');

try {
    await vault.read('secret/app');
} catch (err) {
    if (err instanceof VaultHttpError && err.statusCode === 403) {
        // policy problem — surface it, retrying will not help
    } else if (err instanceof VaultHttpError && err.statusCode >= 500) {
        // Vault is unwell — back off and retry
    } else {
        throw err;
    }
}

12. Testing without a Vault

The client talks to Vault through global.fetch, so tests can stub it — no container, no network:

import sinon from 'sinon';
import VaultClient from 'node-vault-client';

const BODIES = {
    '/v1/auth/token/lookup-self': { data: { id: 'tok', accessor: 'acc', ttl: 0, renewable: false } },
    '/v1/secret/data/app':        { data: { data: { DB_PASSWORD: 's3cret' } } },
};

beforeEach(() => {
    sinon.stub(global, 'fetch').callsFake((url) => Promise.resolve(new Response(
        JSON.stringify(BODIES[new URL(url).pathname] ?? {}),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
    )));
});

afterEach(() => {
    global.fetch.restore();
    VaultClient.clear();          // drop the singleton between tests
});

it('reads the password', async () => {
    const vault = VaultClient.boot('test', {
        api:  { url: 'https://vault.test/', engines: { secret: 2 } },
        auth: { type: 'token', config: { token: 'tok' } },
        logger: false,
    });
    const lease = await vault.read('secret/app');
    expect(lease.getValue('DB_PASSWORD')).to.equal('s3cret');
});

VaultClient.clear() in afterEach is the part people miss — boot() returns a singleton, so without it your second test gets the first test's client.

13. Graceful shutdown

The renewal timer holds the event loop open. On shutdown:

process.on('SIGTERM', () => {
    vault.close();     // stops renewal timers for this instance
    server.close();
});

VaultClient.clear() with no arguments does the same for every booted instance, which is handy in tests.

14. Authenticating from GitHub Actions with OIDC

Availability: the JWT backend is on master and ships in the next release; it is not in npm 2.1.2.

No long-lived secret in the repo: the workflow requests a short-lived OIDC token from GitHub, and jwtProvider mints a fresh one on every login (including re-logins after the Vault token's TTL expires).

permissions:
  id-token: write
const core = require('@actions/core');
const VaultClient = require('node-vault-client');

const vault = VaultClient.boot('ci', {
    api: { url: process.env.VAULT_ADDR },
    auth: {
        type: 'jwt',
        mount: 'gha',        // wherever the JWT method was mounted, e.g. `vault auth enable -path=gha jwt`
        config: {
            role: 'ci',                                  // optional; falls back to the mount's default_role
            jwtProvider: () => core.getIDToken('vault'),  // audience must match the role's bound_audiences
        },
    },
});

jwtProvider is called fresh at every login, never cached, so a token minted for one job step is never reused past its own short lifetime. Compare this with Recipe 3: both re-fetch a token per login, but Kubernetes re-reads a file (jwtPath) while GitHub Actions mints a new one from an API (jwtProvider).

15. Tuning or disabling token renewal

Availability: the three renewal* keys are not in npm 2.1.2 yet — they land with the next release.

By default a renewable token is renewed in the background at half its remaining lifetime, forever. That is right for a long-running service and wrong for two other shapes.

A script that should exit when it is done. The renewal timer keeps the Node.js event loop alive, so a finished script hangs unless it calls close(). Turning renewal off removes the timer:

const vault = VaultClient.boot('job', {
    api:  { url: process.env.VAULT_ADDR },
    auth: { type: 'kubernetes', renewal: false, config: { role: 'batch-job' } },
});

const secrets = (await vault.read('secret/batch')).getData();
await doWork(secrets);
// no close() needed: nothing is holding the loop open

close() remains the explicit way to stop renewal on a client you keep — see recipe 13. Use renewal: false when you never wanted the timer in the first place.

A long-lived service that wants more headroom. A failed renewal is retried on the same rule against the same token, so the waits shrink as the remaining lifetime does. Renewing earlier buys more attempts before expiry:

auth: {
    type: 'iam',
    renewalFraction: 0.25,   // ~27 attempts for a 1h token, vs ~12 at the 0.5 default
    renewalIncrement: 3600,  // and ask for another hour each time
    config: { role: 'orders-api' },
}

renewalIncrement is a request, not a guarantee — Vault caps it at the token's max TTL.

Do not turn it off blindly

renewal: false means the token is allowed to expire, so it only behaves well where the backend can mint a fresh credential by itself — kubernetes, iam, and jwt via jwtPath/jwtProvider.

  • With appRole the client replays the same secret_id; if the role is hardened with secret_id_num_uses=1 the second login is rejected, and the doomed login is then retried on every call with no backoff.
  • With token auth there is nothing to re-authenticate with: once the token expires every call raises AuthTokenExpiredError, permanently. close() does not reset it — you need VaultClient.clear(name) and a fresh boot().
  • Leases die with the token. Vault revokes every lease a token created when that token expires, and this client renews only the auth token. If you read dynamic credentials whose lease outlives the auth token, renewal being on is what keeps them alive today.

Bad values are rejected at construction rather than failing later inside a background timer: renewal must be a boolean (a string 'false' from an env-var config throws), renewalFraction must be in (0, 1), and renewalIncrement a positive integer.