A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.
npm install --save node-vault-client
Node.js >= 18 — the client uses the native fetch API.
Type declarations ship with the package, so no separate install is needed:
import VaultClient = require('node-vault-client');
const client = new VaultClient({
api: { url: 'http://127.0.0.1:8200' },
auth: { type: 'appRole', config: { role_id: 'roleId', secret_id: 'secretId' } },
});
const lease = await client.read('secret/app');
const password = lease.getValue<string>('password');auth is a discriminated union on type, so each backend only accepts its own
configuration, and the three mutually-exclusive JWT sources (jwt, jwtPath,
jwtProvider) are enforced at compile time — as is the mutually-exclusive pair
distributedClaimAccessToken / distributedClaimAccessTokenProvider.
Types that appear in signatures are exported in type space under the VaultClient
namespace — VaultClient.Lease, VaultClient.VaultOptions, VaultClient.AuthToken
and the per-backend config interfaces. They are types only; the classes behind them
are not exported at runtime.
If you previously installed the third-party @types/node-vault-client, remove it —
it stops at the 1.x API and its declarations take precedence in some setups:
npm uninstall @types/node-vault-client
const VaultClient = require('node-vault-client');
const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'appRole', // one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt'
config: { role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61' }
},
});
vaultClient.read('secret/tst').then(lease => {
console.log(lease.getData()); // read() resolves to a Lease; use getData()/getValue(key)
}).catch(e => console.error(e));const vaultClient = VaultClient.boot('main', {
api: {
url: 'https://vault.example.com:8200/',
namespace: 'some_namespace', // Optional. X-Vault-Namespace header (canonical location; auth.config.namespace is honored as a legacy fallback)
},
auth: {
type: 'iam',
mount: 'aws', // Optional. Vault AWS auth mount point ("aws" by default)
config: {
role: 'my_iam_role',
iam_server_id_header_value: 'https://vault.example.com:8200/', // Optional. X-Vault-AWS-IAM-Server-ID header
region: 'eu-central-1', // Optional. AWS STS region (see below)
credentials: { // Optional. Resolved from the AWS provider chain when omitted
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
},
},
});By default the signed GetCallerIdentity request targets the global STS endpoint
sts.amazonaws.com and the SigV4 credential scope is bound to us-east-1. Set
config.region to sign against a regional STS endpoint instead — the request is then sent to
sts.<region>.amazonaws.com and the signature scope is bound to that region. This is required
when Vault's sts_region / sts_endpoint is configured for a non-us-east-1 region (e.g.
eu-central-1); otherwise STS rejects the replayed request with
SignatureDoesNotMatch — Credential should be scoped to a valid region. Omitting region
preserves the previous (global-endpoint) behavior.
const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'appRole',
mount: 'approle', // Optional. Vault AppRole auth mount point ("approle" by default)
config: {
role_id: '637c065f-c644-5e12-d3d1-e9fa4363af61', // Required. RoleID of the AppRole
secret_id: '...', // Optional. Required when bind_secret_id is enabled
},
},
});const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'token',
mount: 'token', // Optional. Vault token auth mount point ("token" by default)
config: {
token: 's.xxxxxxxxxxxxxxxxxxxxxxxx', // Required. Vault token
},
},
});const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'kubernetes',
mount: 'kubernetes', // Optional. Vault Kubernetes auth mount point ("kubernetes" by default)
config: {
role: 'my_k8s_role', // Required. Role configured in the Vault Kubernetes auth backend
tokenPath: '/var/run/secrets/kubernetes.io/serviceaccount/token', // Optional. Defaults to the in-pod service-account token path
},
},
});const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'jwt',
mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
config: {
role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider`
},
},
});Exactly one of three mutually exclusive config keys supplies the JWT:
jwt— a literal token string. Use it for CI jobs and other processes that are guaranteed to finish before the token expires. Caveat: the value is fixed at construction, so if the client re-authenticates (its Vault-issued token TTL runs out while the process is still up) it re-sends that exact same JWT — which works only until the IdP-issued token itself expires, after which Vault rejects every further login attempt. Do not usejwtin a long-running process.jwtPath— path to a file containing the JWT, re-read on every login (mirrors Kubernetes auth'stokenPath). Use it for rotated projected tokens, such as a Kubernetes projected service-account token the kubelet refreshes on disk, so each login picks up whatever is currently on disk instead of a token captured once at startup.jwtProvider— an (optionally async) function, called fresh at login time (never at construction, never cached), returningstring | Promise<string>. Use it when the JWT has to be minted per login — GitHub Actions'core.getIDToken(), a cloud metadata endpoint, a SPIFFE/SPIRE workload API.
This is server-side configuration rather than a client option, but it is the most common reason a
first login fails. Vault requires a jwt role to bind the audience your token carries, and it does
not catch the omission when the role is created — as long as the role has some other bound
constraint (bound_subject, bound_claims, ...) it is accepted, and the problem only surfaces at
login:
VaultHttpError: 400 - {"errors":["audience claim found in JWT but no audiences bound to the role"]}
A wrong (rather than missing) audience fails with error validating token: invalid audience (aud) claim: audience claim does not match any expected audience. So bind the audience the token
actually has:
vault write auth/jwt/role/my-app \
role_type=jwt user_claim=sub bound_audiences=my-audience token_policies=my-policyA role with no bound constraint at all is rejected when you create it (must have at least one bound constraint when creating/updating a role), so that case is self-correcting. An aud array
is fine — Vault matches bound_audiences against any entry.
Vault also does not require an exp claim: a token minted without one is accepted and never
expires. If you write your own jwtProvider, give the tokens it mints a short exp.
Skip this unless you log in with Microsoft Entra ID (Azure AD) tokens and the Vault role sets
groups_claim. Vault's distributed_claim_access_token parameter
"only applies to the Azure (Entra ID) provider";
on every other IdP these two keys do nothing at all.
Azure does not always put group membership in the token. Past 200 groups it sends OIDC
distributed claims
instead — _claim_names/_claim_sources pointing at the Microsoft Graph API rather than the group
names themselves — and a mount configured with fetch_groups skips the claim entirely and always
asks Graph. Either way Vault has to call Graph itself, and the JWT you logged in with is not a
credential for that call: it needs a separate Graph access token, sent on the login request as the
optional distributed_claim_access_token. Leave it out on a mount/role set up this way and the
login fails at the group-fetch step — after the JWT has already validated, so the error names the
group lookup rather than anything about your token.
Two optional, mutually exclusive config keys supply it. Passing both raises
InvalidArgumentsError at construction; passing neither leaves the login request byte-for-byte
what it was before these keys existed.
distributedClaimAccessToken— a literal Graph access token. It carries exactly the caveat the literaljwtabove does, for the same reason: the value is fixed at construction and every re-login re-sends it, so it works until that token expires. Entra access tokens last about an hour, which is shorter than most processes — fine for a one-shot script, wrong for a service.distributedClaimAccessTokenProvider— an (optionally async) function, called fresh at login time (never at construction, never cached), returningstring | Promise<string>. Acquire the Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint — and every login gets a current one. Resolving to a non-string or an empty string raisesInvalidArgumentsErrorwithout sending a login request.
const vaultClient = VaultClient.boot('main', {
api: { url: 'https://vault.example.com:8200/' },
auth: {
type: 'jwt',
config: {
role: 'my-app',
jwtProvider: () => getEntraIdToken(),
distributedClaimAccessTokenProvider: () => getGraphAccessToken(),
},
},
});On the Vault side, fetch_groups lives in provider_config on the mount's config, not on the
role — which is where people tend to look for it:
vault write auth/jwt/config \
oidc_discovery_url=https://login.microsoftonline.com/<tenant-id>/v2.0 \
provider_config='{"provider":"azure","fetch_groups":true}'
vault write auth/jwt/role/my-app \
role_type=jwt user_claim=sub bound_audiences=<application-client-id> \
groups_claim=groups token_policies=my-policyfetch_groups is optional — the distributed-claim path is taken without it whenever Azure omits
the groups claim. groups_claim on the role is not: with it unset Vault never resolves groups at
all, so a Graph token you pass is accepted and never used.
permissions:
id-token: writeconst core = require('@actions/core');
const VaultClient = require('node-vault-client');
const vaultClient = VaultClient.boot('ci', {
api: { url: process.env.VAULT_ADDR },
auth: {
type: 'jwt',
mount: 'gha', // matches wherever the JWT method was mounted, e.g. `vault auth enable -path=gha jwt`
config: { role: 'ci', jwtProvider: () => core.getIDToken('vault') },
},
});core.getIDToken('vault') mints a token whose aud is vault, so the role has to bind that
audience or the login fails with audience claim found in JWT but no audiences bound to the role:
vault write auth/gha/role/ci \
role_type=jwt user_claim=sub bound_audiences=vault \
bound_claims='{"repository":"my-org/my-repo"}' token_policies=ciPass the same string to core.getIDToken() and to bound_audiences. Calling getIDToken() with
no argument uses GitHub's default audience instead, which then will not match.
role is optional here too — omit it to use the mount's default_role. mount and
api.namespace behave exactly as they do for the other four backends.
Vault's JWT method also offers an interactive oidc login (a browser redirect for a human user).
This library implements only the non-interactive jwt flow: a service client doing headless
background renewal has no browser to redirect to, so oidc is deliberately not supported.
Whenever Vault issues a renewable token, the client arms a background timer and renews it at half its remaining lifetime, for as long as the client lives. That is the default and suits long-running services.
Set renewal: false on the auth block (beside type, not inside config) to turn it off:
auth: {
type: 'appRole',
renewal: false,
config: {
role_id: '637c065f-...',
secret_id: '...',
},
}These keys sit on auth rather than in auth.config on purpose: config is the backend's own
credential bag and may already carry keys of your own, so reserving names inside it could break an
existing caller. Same reasoning that put the namespace at api.namespace.
With renewal off, the client keeps using the token until it expires and then simply logs in again on the next call — no background timer at all. Two reasons to want that:
- Short-lived processes. The renewal timer keeps the Node.js event loop alive, so a script that
finishes its work does not exit on its own; you have to call
close(). Withrenewal: falsethere is no timer to hold it open. - You would rather re-authenticate than renew — for example where the auth backend can always
mint a fresh token (
jwtProvider, Kubernetes, IAM) and you prefer a clean login over extending an existing lease.
Renewal off means the token is allowed to expire, and what happens next depends on whether your backend can obtain a fresh credential unaided:
| backend | on expiry with renewal: false |
|---|---|
kubernetes, iam, jwt with jwtPath/jwtProvider |
clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
appRole |
re-login replays the same secret_id. Fine for a reusable one; with Vault's recommended hardening (secret_id_num_uses=1, or a short secret_id_ttl) the second login is rejected |
jwt with a literal jwt |
replays the same JWT, so it works only until the IdP-issued token expires |
token |
cannot re-authenticate at all. The client raises AuthTokenExpiredError from then on, permanently |
Two consequences worth stating plainly:
- For
tokenauth this is a behaviour change, not a no-op. A renewable token handed totokenauth is renewed indefinitely today; withrenewal: falseit expires and every later call rejects for the life of the process.close()does not reset it — recovery meansVaultClient.clear(name)and booting again. - For
appRoleand literal-jwt, a login that can no longer succeed is retried on every subsequent call, since a failed login clears the cached token. That is a failing request perread(), with no backoff. PreferjwtPath/jwtProvider, a reusablesecret_id, or leaving renewal on.
Expiring a token also revokes its leases. Vault revokes every lease created by a token when
that token expires, and this client does not renew secret leases — only the auth token. If you read
dynamic credentials (database/creds/*, cloud credentials) whose lease outlives the auth token,
leaving renewal on is what currently keeps them alive. KV reads are unaffected.
Two further keys, also on auth, shape how renewal happens. Both are optional, and leaving them
out reproduces the behaviour the client has always had.
| key | default | meaning |
|---|---|---|
renewalFraction |
0.5 |
How much of the token's remaining lifetime to wait before renewing, as a fraction in (0, 1). 0.5 renews at the halfway point. |
renewalIncrement |
(unset) | Seconds of extra TTL to ask for, sent as increment to auth/token/renew-self. Unset means Vault applies the token's own period. |
auth: {
type: 'kubernetes',
renewalFraction: 0.25, // renew after a quarter of the remaining lifetime
renewalIncrement: 3600, // ask Vault for another hour each time
config: { role: 'my-app' },
}Lower renewalFraction values renew earlier and more often, which buys headroom if Vault is briefly
unreachable. A failed renewal is retried on the same rule against the same token, so the waits
shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
at 0.5 (1800s, 900s, 450s, …) versus 27 at 0.25, both bottoming out at a 1-second floor just
before expiry. Higher values renew later and talk to Vault less.
renewalIncrement is a request, not a guarantee: Vault grants at most the token's max TTL and may
return less. Both keys are validated at construction — a renewalFraction outside (0, 1) or a
non-positive/non-integer renewalIncrement raises InvalidArgumentsError rather than failing later
inside a background timer.
- VaultClient
- new VaultClient(options)
- instance
- .fillNodeConfig()
- .read(path) ⇒
Promise.<Lease> - .list(path) ⇒
Promise.<Lease> - .write(path, data) ⇒
Promise.<Object> - .delete(path) ⇒
Promise.<Object> - .update(path, data) ⇒
Promise.<Object> - .request(method, path, [data]) ⇒
Promise.<Object> - .deleteVersions(path, versions) ⇒
Promise.<Object> - .undeleteVersions(path, versions) ⇒
Promise.<Object> - .destroyVersions(path, versions) ⇒
Promise.<Object> - .readMetadata(path) ⇒
Promise.<Object> - .deleteMetadata(path) ⇒
Promise.<Object> - .close()
- static
- .boot(name, options) ⇒
VaultClient - .get(name) ⇒
VaultClient - .clear([name])
- .boot(name, options) ⇒
- Lease
Return contract: read() and list() resolve to a
Lease — use its accessors to extract the secret data. Every other data-plane method
(write, delete, update, request and the KV v2 helpers) resolves to the raw parsed Vault
response body, which may be empty/undefined for 204 No Content responses.
Client constructor function.
| Param | Type | Default | Description |
|---|---|---|---|
| options | Object |
||
| options.api | Object |
||
| options.api.url | String |
the url of the vault server | |
| [options.api.apiVersion] | String |
v1 |
|
| [options.api.requestOptions] | Object |
extra options merged into every HTTP request (see Custom transport) | |
| [options.api.namespace] | String |
Optional. Vault namespace, sent as the X-Vault-Namespace header on every request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; auth.config.namespace is still honored for backward compatibility. |
|
| [options.api.kv.autoDetect] | boolean |
false |
auto-detect the KV version of each mount on first use (see KV v2 & generic backends) |
| [options.api.engines] | Object |
{} |
static mount-to-version map, e.g. { secret: 2, legacy: 1 } (see KV v2 & generic backends) |
| options.auth | Object |
||
| options.auth.type | String |
one of: 'appRole' | 'token' | 'iam' | 'kubernetes' | 'jwt' | |
| [options.auth.mount] | String |
Vault auth backend mount point; default varies per method (e.g. "aws" for iam, "approle", "token", "kubernetes", "jwt") | |
| options.auth.config | Object |
auth configuration variables | |
| [options.auth.config.namespace] | String |
Optional. Legacy location for the Vault namespace (see api.namespace). Sent as the X-Vault-Namespace header on every request for every auth type. |
|
| [options.auth.renewal] | boolean |
true |
Set false to never renew the Vault token in the background; the token is used until it expires and the next call re-authenticates. See Token renewal. |
| [options.auth.renewalFraction] | number |
0.5 |
How much of the token's remaining lifetime to wait before renewing, as a fraction in (0, 1). |
| [options.auth.renewalIncrement] | number |
Seconds of extra TTL to request on each renewal, sent as increment to auth/token/renew-self. Vault caps it at the token's max TTL. |
|
| [options.logger] | Object | false |
Logger that must implement all five of "error", "warn", "info", "debug" and "trace" — an object missing any one of them is silently ignored and the default logger is used instead. The default logger writes to console, except debug, which is discarded so that sensitive data is never printed. Pass false to disable logging entirely. |
options.api.requestOptions is shallow-merged into every underlying fetch() call, so you
can route traffic through a proxy/SOCKS agent or trust a self-signed / internal-CA Vault.
Pass an undici dispatcher (request semantics like method
and body always win; headers are merged with per-request headers taking precedence):
const { Agent, ProxyAgent } = require('undici');
// Trust an internal/self-signed CA (preferred over disabling verification)
const vaultClient = VaultClient.boot('main', {
api: {
url: 'https://vault.internal:8200/',
requestOptions: {
dispatcher: new Agent({ connect: { ca: require('fs').readFileSync('/etc/ssl/internal-ca.pem') } }),
},
},
auth: { type: 'token', config: { token: '...' } },
});
// Route through an HTTP proxy / SOCKS agent
const proxied = VaultClient.boot('proxied', {
api: { url: 'https://vault.example.com:8200/', requestOptions: { dispatcher: new ProxyAgent('http://proxy:8080') } },
auth: { type: 'token', config: { token: '...' } },
});For the self-signed-CA case you can also use the process-wide NODE_EXTRA_CA_CERTS=/path/ca.pem
env var with no code change. Only disable verification
(new Agent({ connect: { rejectUnauthorized: false } })) in throwaway/dev setups — it removes
MITM protection.
The undici you install must be interface-compatible with the one Node bundles
(process.versions.undici). A dispatcher from a mismatched major is rejected by the global
fetch() before the request leaves the process — on Node 24, undici@8 fails immediately with
TypeError: fetch failed and cause.code of invalid onRequestStart method. Node 22 and 24
bundle undici 7.x, so pin undici@^7.
There is no default timeout. A Vault that accepts the connection and never answers will leave
read() (and every other call) pending indefinitely. Set one through the dispatcher:
const { Agent } = require('undici');
const vaultClient = VaultClient.boot('main', {
api: {
url: 'https://vault.example.com:8200/',
requestOptions: { dispatcher: new Agent({ headersTimeout: 5000, bodyTimeout: 5000 }) },
},
auth: { type: 'token', config: { token: '...' } },
});A failed request rejects with TypeError: fetch failed, with cause.code set to
UND_ERR_HEADERS_TIMEOUT or UND_ERR_BODY_TIMEOUT.
Do not put signal: AbortSignal.timeout(ms) in requestOptions. It is created once and
shared by every request the client makes, so it works for the first call and then aborts all
later ones with TimeoutError. Pass a fresh signal per call, or use the dispatcher above.
Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
active node. Node's fetch() strips only Authorization, Cookie and Proxy-Authorization when
a redirect crosses origins — X-Vault-Token is not on that list and is forwarded. A host that
can answer for api.url can therefore redirect the client and receive its Vault token, so treat
api.url (and the DNS and any load balancer in front of it) as trusted infrastructure.
Populates Vault's values to NPM "config" module
Resolves once the npm config module has been populated from Vault. Note that setup failures are thrown synchronously, not returned as a rejected promise: a missing config peer dependency and an unreadable <NODE_CONFIG_DIR>/custom-vault-variables.js both throw VaultError before the promise is created, so use await or wrap the call in try/catch rather than relying on .catch() alone.
Kind: instance method of VaultClient
Read secret from Vault
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
Retrieves secrets list
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
Writes data to Vault
Resolves to the raw parsed Vault response body, which may be empty/undefined for
204 No Content responses.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path used to write data |
| data | object |
data to write |
Deletes a secret
On KV v2 mounts this sends DELETE to the data/ path, soft-deleting the latest version.
On KV v1 / non-KV mounts this sends DELETE to the raw path. Resolves to the raw parsed
Vault response body, which may be empty/undefined for 204 No Content responses.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
Updates (merge-patches) a KV v2 secret
Sends PATCH with Content-Type: application/merge-patch+json, merging data into the
existing secret without overwriting keys that are not listed. KV v2 merge-patch operation —
KV v1 mounts do not support PATCH and Vault returns 405 there. Resolves to the raw
parsed Vault response body.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
| data | object |
keys to merge into the existing secret |
Raw request — escape hatch for any Vault backend
Sends the literal API path with no KV path rewriting and no response unwrapping, and resolves to the parsed response body. Use it for non-KV backends (e.g. Transit) or when you have already constructed the complete Vault API path.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| method | string |
HTTP method (e.g. GET, POST) |
| path | string |
literal API path, sent as-is |
| [data] | object |
request body |
Soft-deletes specific versions of a KV v2 secret
KV v2 only — rejects with UnsupportedOperationError on non-v2 mounts. Resolves to the raw
parsed Vault response body.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
| versions | Array.<number> |
version numbers to soft-delete |
Undeletes (restores) soft-deleted versions of a KV v2 secret
KV v2 only — rejects with UnsupportedOperationError on non-v2 mounts. Resolves to the raw
parsed Vault response body.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
| versions | Array.<number> |
version numbers to restore |
Permanently destroys specific versions of a KV v2 secret
The destroyed version data cannot be recovered. KV v2 only — rejects with
UnsupportedOperationError on non-v2 mounts. Resolves to the raw parsed Vault response body.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
| versions | Array.<number> |
version numbers to destroy |
Reads KV v2 metadata for a secret
Resolves to the raw parsed Vault response body; the metadata document (current_version, the versions map, timestamps, etc.) is under its data property, e.g. (await client.readMetadata('secret/foo')).data.current_version.
KV v2 only — rejects with UnsupportedOperationError on non-v2 mounts.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
Deletes all metadata and version history for a KV v2 secret (permanent)
KV v2 only — rejects with UnsupportedOperationError on non-v2 mounts. Resolves to the raw
parsed Vault response body, which may be empty/undefined for 204 No Content responses.
Kind: instance method of VaultClient
| Param | Type | Description |
|---|---|---|
| path | string |
path to the secret |
Release resources held by this client.
This client performs lease renewal for renewable auth tokens by arming a background timer.
That timer keeps the Node.js event loop alive, so a short-lived script (e.g. a one-off
read) never exits on its own. Call close() once you are done with the client to cancel
the timer and let the process exit. It is null-safe and safe to call multiple times. The
client may still be used afterwards — the next operation that fetches a renewable token
will arm a new refresh timer.
const vaultClient = VaultClient.boot('main', { /* ... */ });
const secret = await vaultClient.read('secret/tst');
console.log(secret);
vaultClient.close(); // process can now exitKind: instance method of VaultClient
close() also settles a renewal that is already in flight: a request that lands after the call is
discarded rather than arming a fresh timer, so the client stops holding the event loop open. The
same applies when a token is replaced by a new login — a renewal still running against the previous
token cannot overwrite the newer one or cancel its timer.
The object returned by read() and list() (they resolve to Promise<Lease>). Use its
accessors to extract the secret data:
getValue(key)⇒String— value for a single key. ThrowsRequested key does not existwhen the key is absent.getData()⇒Object— a deep-cloned copy of the whole secret data object.isRenewable()⇒boolean— whether the underlying lease is renewable.getMetadata()⇒Object|undefined— KV v2 version metadata (version,created_time,deletion_time,destroyed,custom_metadata), orundefinedon KV v1 and non-KV mounts. See Lease.getMetadata().
Boot an instance of Vault
The instance will be stored in a local hash. Calling Vault.boot multiple times with the same name will return the same instance.
options are used only when the instance is first created. A later call for a name that already
exists returns the existing instance and ignores the options passed to it; when those differ from
the ones it was booted with, the client logs a warning rather than letting the difference pass
silently. Use VaultClient.get(name) to fetch an existing instance, or
VaultClient.clear(name) before booting to replace one.
Kind: static method of VaultClient
Returns: VaultClient
| Param | Type | Description |
|---|---|---|
| name | String |
Vault instance name |
| options | Object |
options for Vault#constructor. Required on every call, including for a name that was already booted — use VaultClient.get(name) to fetch an existing instance. |
Get an instance of Vault
The instance will be stored in a local hash. Calling Vault.pop multiple times with the same name will return the same instance.
Kind: static method of VaultClient
Returns: VaultClient
| Param | Type | Description |
|---|---|---|
| name | String |
Vault instance name |
Clear named Vault instance
If no name passed all named instances will be cleared.
Kind: static method of VaultClient
| Param | Type | Description |
|---|---|---|
| [name] | String |
Vault instance name, all instances will be cleared if no name were passed |
By default the client behaves exactly as before (KV v1 / raw passthrough). To enable transparent
KV v2 support set api.kv.autoDetect: true or supply a static api.engines map. Either
option activates path-rewriting and response-unwrapping; callers do not need to know the engine
version.
| Option | Type | Default | Description |
|---|---|---|---|
api.kv.autoDetect |
boolean |
false |
Auto-detect the KV version of each mount on first use via GET sys/internal/ui/mounts/<path>. |
api.engines |
Object |
{} |
Static mount-to-version map, e.g. { secret: 2, legacy: 1 }. Overrides detection; use this when the token lacks permission on sys/internal/ui/mounts. |
Both options can be combined: engines acts as an override — matching mounts skip detection
while unmatched mounts are auto-detected (when autoDetect: true).
const client = VaultClient.boot('main', {
api: {
url: 'https://vault.example.com:8200/',
kv: { autoDetect: true },
},
auth: { type: 'token', config: { token: '...' } },
});
// Works transparently on both KV v1 and KV v2 mounts
const lease = await client.read('secret/my-app/config');
console.log(lease.getData()); // the secret object
console.log(lease.getMetadata()); // KV v2 version metadata (undefined on v1)const client = VaultClient.boot('main', {
api: {
url: 'https://vault.example.com:8200/',
engines: { secret: 2, legacy: 1 },
},
auth: { type: 'token', config: { token: '...' } },
});These methods require a KV v2 mount and throw UnsupportedOperationError on v1 / non-KV mounts. They also require the mount to be resolved as v2: with neither api.kv.autoDetect: true nor an api.engines entry covering the mount, every path resolves as v1 passthrough with no detection call, and these methods fail with UnsupportedOperationError (Mount "secret" is not a KV v2 engine.) even against a genuine KV v2 mount.
// Soft-delete specific versions
await client.deleteVersions('secret/foo', [1, 2]);
// Restore soft-deleted versions
await client.undeleteVersions('secret/foo', [1]);
// Permanently destroy versions
await client.destroyVersions('secret/foo', [1, 2]);
// Read version metadata. The full Vault envelope is returned, so the metadata
// fields live under `.data` (meta.data.current_version, meta.data.versions, ...)
const meta = await client.readMetadata('secret/foo');
// Delete all metadata and version history (permanent)
await client.deleteMetadata('secret/foo');// PATCH a subset of keys without overwriting others (KV v2)
await client.update('secret/foo', { password: 'new-value' });
// Sends PATCH secret/data/foo with Content-Type: application/merge-patch+json// Soft-delete the latest version on KV v2; DELETE on v1/passthrough
await client.delete('secret/foo');For any Vault backend that does not benefit from KV path rewriting use request(). It sends the
literal path with no rewriting or response normalisation and returns the parsed body directly.
// Encrypt with Transit engine — path must not be rewritten
const result = await client.request('POST', 'transit/encrypt/my-key', {
plaintext: Buffer.from('hello').toString('base64'),
});
console.log(result.data.ciphertext);getMetadata() is additive — existing code is unaffected.
const lease = await client.read('secret/my-app/db');
lease.getData(); // the secret values
lease.getMetadata(); // { version, created_time, deletion_time, destroyed, custom_metadata }
// undefined on KV v1 / passthrough mountsWhen autoDetect: true or api.engines is set, the client rewrites logical paths to the
correct KV v2 API paths automatically (e.g. secret/foo → secret/data/foo for reads).
Callers must pass logical paths — do not include the internal KV v2 segments (data/,
metadata/, delete/, undelete/, destroy/) in the path argument:
// Correct — logical path only
await client.read('secret/my-app/config');
// Wrong — double-rewrite: 'secret/data/foo' becomes 'secret/data/data/foo' on the wire
await client.read('secret/data/foo');If you need to send a fully-literal Vault API path (e.g. when working with non-KV backends or
when you have already constructed the complete path), use request() which bypasses all path
rewriting:
// Literal path, no rewriting
await client.request('GET', 'secret/data/foo');- Each canonical mount is detected once and then cached for the life of the
VaultClientinstance. The cache is a bounded LRU with a fixed cap of 500 mounts (not configurable through client options); a long-lived client that touches more than 500 distinct mounts evicts the least-recently-used entries, and an evicted mount is detected again on its next use. - Concurrent first-touch requests for the same mount share a single in-flight detection promise.
- The detection endpoint used is
GET sys/internal/ui/mounts/<path>(readable by any authenticated token). - When the token lacks permission on that endpoint, set
api.enginesto skip detection.
Every error the client raises extends VaultError. The package entry point exports the
VaultClient class only, so the classes themselves are imported from node-vault-client/src/errors:
const errors = require('node-vault-client/src/errors');
try {
await client.readMetadata('secret/app');
} catch (err) {
if (err instanceof errors.UnsupportedOperationError) {
// the mount did not resolve as KV v2 - see "Path requirements" above
return null;
}
throw err;
}| Class | Extends | When thrown |
|---|---|---|
VaultError |
Error |
Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no api.engines override was provided. |
VaultHttpError |
VaultError |
Vault answered with a non-2xx status. Carries the status as statusCode and the parsed body as error. |
InvalidArgumentsError |
VaultError |
Bad arguments or configuration: boot() called without options, an unknown instance name passed to get(), an unsupported auth.type, invalid renewal options, or an invalid node-config substitution map. |
InvalidAWSCredentialsError |
InvalidArgumentsError |
auth.config.credentials was supplied but is not a usable accessKeyId / secretAccessKey pair. |
AuthTokenExpiredError |
VaultError |
The Vault token expired and the backend cannot obtain a new one. token auth can never re-authenticate; other backends reach this only with renewal: false and a credential they cannot replay. |
UnsupportedOperationError |
VaultError |
A v2-only method (deleteVersions, undeleteVersions, destroyVersions, readMetadata, deleteMetadata) was called against a mount that did not resolve as KV v2. |
Contributions are welcome! Please read the contributing guide to get started, and note that this project requires a DCO sign-off on every commit.
Not sure where to start? See SUPPORT.md.
This project adheres to the Contributor Covenant Code of Conduct.
To report a security vulnerability, please follow our Security Policy.
Licensed under the Apache License 2.0.