-
Notifications
You must be signed in to change notification settings - Fork 23
Recipes
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
- 2. node-config integration
- 3. A pod on Kubernetes
- 4. ECS / EC2 / Lambda with AWS IAM
- 5. KV v2 without rewriting paths
- 6. Vault Enterprise namespaces
- 7. Picking up rotated credentials
- 8. Changing one key without clobbering the rest
- 9. KV v2 versions: read, delete, undelete, destroy
- 10. Endpoints the client does not wrap
- 11. Error handling
- 12. Testing without a Vault
- 13. Graceful shutdown
- 14. Authenticating from GitHub Actions with OIDC
- 15. Tuning or disabling token renewal
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();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 VaultEach 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.
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.
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
},
},
});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: {...} } envelopeTwo things worth knowing:
-
enginesworks withoutautoDetect. WithautoDetectoff, listed mounts use your map and every other mount is treated as v1 — no detection request is ever made. That makesenginesthe right choice when your token cannot readsys/internal/ui/mounts. - With
autoDetecton, resolved mounts are cached in a bounded LRU (500 entries), so detection costs one request per mount, not one per read.
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.
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.
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).
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 versionsThese 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: truenor anapi.enginesentry 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).
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.
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;
}
}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.
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.
Availability: the JWT backend is on
masterand 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: writeconst 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).
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 openclose() 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.
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
appRolethe client replays the samesecret_id; if the role is hardened withsecret_id_num_uses=1the second login is rejected, and the doomed login is then retried on every call with no backoff. - With
tokenauth there is nothing to re-authenticate with: once the token expires every call raisesAuthTokenExpiredError, permanently.close()does not reset it — you needVaultClient.clear(name)and a freshboot(). - 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.
node-vault-client · Apache-2.0 · maintained by Namecheap