Azure Key Vault
Node library to handle Azure Key Vault secrets by project, environment and group (when a vault is shared), as nested JSON. Every failure is classified and logged at its level, and the library says who it signed in as, so a missing vault, an identity without access and a missing secret never look the same.
ESM only, Node.js 22.12 or later.
How To Use 💡
import { AzureKeyVault } from '@achs/azure-key-vault';
// DefaultAzureCredential: environment variables, workload or
// managed identity, Azure CLI ("az login")...
const keyVault = new AzureKeyVault({
vaultUrl: 'https://my-key-vault.vault.azure.net',
project: 'my-project',
group: 'web',
env: 'dev',
});
await keyVault.setAll({
SECRET1: 'my secret 1',
otherConfig: { SECRET2: 'my secret 2' },
});
const secrets = await keyVault.getFor({
SECRET1: null,
otherConfig: { SECRET2: null },
SECRET3: 'default for secret 3',
});
// { SECRET1: 'my secret 1', otherConfig: { SECRET2: 'my secret 2' }, SECRET3: 'default for secret 3' }
// typed with the shape of the input: secrets.otherConfig.SECRET2
Options
| Option | Description |
|---|---|
vaultUrl | (string) vault URL, i.e. https://my-key-vault.vault.azure.net |
project | (string) project the secrets belong to |
group | (string) secrets group |
env | (string) environment (dev, qa, prod...) |
credential | (TokenCredential) any @azure/identity credential, wins over credentials |
credentials | (object) service principal { clientId, clientSecret, tenantId }: with clientSecret, all three; without it, DefaultAzureCredential takes tenantId and clientId (workload or managed identity) as hints |
logger | (object) logger with trace, debug, info, warn and error (console, tslog...) |
logLevel | (string) lowest level written: trace, debug, info, warn, error or silent |
strict | (boolean, default false) a vault or access failure throws instead of being logged only |
concurrency | (number, default 8) requests at once in getFor, getAll and the *All functions |
timeoutMs | (number) time limit of each operation, retries included |
dnsCheck | (boolean, default true) resolve the vault host before the first request (see below) |
expiryWarningDays | (number, default 30) warn about secrets expiring within these days; 0 turns it off |
clientOptions | (SecretClientOptions) options of the secrets client: retries, proxy, user agent... |
client | (SecretClient) secrets client, for tests |
A service principal with only some of its values throws a TypeError, naming the missing ones, and
so do invalid concurrency, timeoutMs or expiryWarningDays values. The library never writes
process.env.
Secret names
A key maps to the secret project-group-env-key, lowercase, with _ and spaces turned into -
(toSecretName(scope, key) gives it).
:(or--) nests a key:car:props:nameis{ car: { props: { name } } }.- A last segment starting with
$is shared by every group (app) of the project, in the same environment:parent:$global_varmaps toproject-env-parent--global-var. It is not shared with other projects. Without a group,KEYand$KEYmap to the same secret. - A key that does not map to a valid secret name (letters, digits and dashes, up to 127) fails
before any request; a scoped package name as group (
@scope/name) does too, so setAZURE_GROUP. - The scope (project, group, environment) compares like names do, whatever case or spaces it was
written with (
normalizeScope):My Appandmy-appare one group, and tags are written normalized.
Tags
Every secret carries tags that rebuild its key and scope (project, group, env, name, path),
plus:
| Tag | Value |
|---|---|
managed-by | @achs/azure-key-vault/<version> that wrote it |
updated-by | who wrote it: a user's upn, service-principal:<appId>, managed-identity:<name> |
serialized | 1 for a JSON value (kept for versions before contentType) |
A JSON value (anything but a string) also has contentType: application/json, and is parsed when
either says so. updated-by comes from the identity the library signed in with (it asks for a token
before its first write); with an injected client it is left out.
Logging and traceability 🔎
Every failure is classified (AzureKeyVaultErrorKind) and logged at its level, naming the vault and
the secrets it affects. Values are never logged, only secret names.
| Kind | Meaning | Level |
|---|---|---|
vault-not-found | the vault host does not resolve (wrong URL, or the DNS/VPN that has it) | error |
vault-unreachable | no answer from the vault (network, proxy, TLS) | error |
timeout | an operation took longer than timeoutMs | error |
unauthenticated | no usable identity (not signed in, wrong service principal, wrong tenant) | error |
forbidden | the identity lacks the permission, or the vault firewall rejects it | error |
throttled | the vault throttles requests (429) and retries are exhausted | error |
invalid-secret-name | a key that does not map to a valid secret name | error |
invalid-value | a secret tagged as JSON whose value does not parse, or an undefined value | error |
unknown | anything else | error |
secret-not-found | the secret does not exist | warn |
secret-disabled | the secret is disabled | warn |
What each level shows:
- error / warn: one line per cause, with every secret it affects. A
forbiddenline names the identity the vault saw (appid/oid), the permission and role the operation needs (Key Vault Secrets Userto read and list,Key Vault Secrets Officerto write) and who the library signed in as. - warn also: secrets read that are expired, not valid yet (Key Vault gives them anyway) or about
to expire (
expiryWarningDays). - info: who the library signed in as (
authenticated as user [email protected] (tenant ...), from the first token: user, service principal or managed identity), the summary of every operation over many secrets (read 3 of 5 secrets, 1 not found or disabled, 1 failed), and the result ofcheck(). - debug: each secret read, written, deleted, purged or restored.
- trace: the original Azure SDK error of each cause.
keyVault.identity gives the identity once known (only when the library builds the client).
Where the logs go
- An injected
loggerreceives every level and filters itself, so a host application keeps one log level for everything.@achs/envpasses its own tslog logger this way. SetlogLevelto filter it here too. - Without a
logger, the library writes to the console with a[akv]prefix, fromlogLevel, else theAKV_LOG_LEVELenvironment variable, elsewarn.
const keyVault = new AzureKeyVault({
vaultUrl,
project: 'my-project',
env: 'dev',
logger: parentLogger.getSubLogger({ name: 'akv' }),
});
Failures and strict
- Reads and maintenance (
get,getFor,getAll,keys,versions,delete,purge,restoreand the*Allfunctions) log their failures and go on: a value that could not be read keeps its default (ornull). Withstrict: true, an error level failure throws anAzureKeyVaultErroronce everything is logged; a missing or disabled secret only warns. - Writes (
set,setAll,rollback) andgetInfoalways throw anAzureKeyVaultError.
import { AzureKeyVaultError } from '@achs/azure-key-vault';
try {
await keyVault.getFor(schema);
} catch (error) {
if (error instanceof AzureKeyVaultError)
console.error(error.kind, error.vaultUrl, error.secretNames);
}
classifyError(error) gives the kind of any error the Azure SDK throws.
check()
Diagnoses a vault with a single request for a secret that should not exist: it passes when the vault host resolves and answers, the identity authenticates and may read secrets, and it says who signed in. It never throws.
const { ok, kind, message, identity } = await keyVault.check();
// { ok: false, kind: 'forbidden', identity: { type: 'service-principal', appId: '...' }, message: 'https://...: the identity appid=... lacks the "get" secret permission (...), signed in as service principal appid=...' }
Network: DNS check, VPN and proxies
Before its first request, the library resolves the vault host once with the system DNS, the one
every request uses (a VPN's DNS included): a host that does not resolve fails at once as
vault-not-found, instead of after the retries of each request. It never contradicts the requests,
unlike a check against public DNS servers, which a VPN may block. It is skipped when a proxy resolves
the host instead (clientOptions.proxyOptions, or HTTPS_PROXY/HTTP_PROXY/ALL_PROXY), with an
injected client, and with dnsCheck: false.
concurrency caps the requests at once (a shared vault throttles all of its callers), timeoutMs
bounds each operation, and clientOptions reaches the Azure client (retries, proxy). The user agent
carries @achs/azure-key-vault/<version>, so the vault's diagnostic logs tell who called.
Functions 🧰
| Function | Description |
|---|---|
get(key, { version }?) | secret value (parsed when stored as JSON), null when not read; a version if given |
getInfo(key) | secret with its properties; throws on failure |
set(key, value) | inserts or updates a secret; a value that is not a string is stored as JSON |
delete(key) | deletes a secret: the deleted secret, null on failure |
purge(key) | purges a deleted secret: whether it was purged |
restore(key) | restores a deleted secret: its properties, null on failure |
versions(key) | versions of a secret, newest first, without values; null on failure |
rollback(key, version) | writes a previous version (value and tags) as the current one |
keys() | keys of the project group, sorted, without values (needs list) |
getFor(secrets, override?) | reads the secrets of an object whose values are defaults, typed with its shape |
getAll() | every secret of the project group (lists the whole vault: needs list) |
setAll(secrets) | inserts or updates the secrets of an object, in order |
deleteAll(skipShared = true) | deletes every secret of the project group |
purgeAll(skipShared = true) | purges every deleted secret of the project group |
restoreAll(skipShared = true) | restores every deleted secret of the project group |
check() | diagnoses the vault (see above) |
audit({ allProjects }?) | audits the project (or the whole vault) without reading values (see below) |
getFor reads only the keys whose default is empty (null, '', 0, false), or every key with
override; a secret stored as JSON (an array, an object, a number) comes back parsed.
const secrets = await keyVault.getFor({
'$global-var': null,
'my-secret': null,
'my-secret-2': 'default value',
'my-secret-group1': { 'my-secret-3': null },
'my-array-secret': null,
});
Audit
audit() lists the vault once (names, tags and dates; never values) and reports, for the project or
with allProjects for the whole vault, a summary (secrets, managed by the library, tagged, untagged,
projects, groups, by environment) and these findings, logged at their level:
| Finding | Level | Meaning |
|---|---|---|
untagged | warn | secrets without the library tags (invisible to getAll and keys) |
shadowed | warn | a key both own and shared ($): @achs/env injects them as one variable |
scope-variants | warn | one scope written in different ways (case, spaces): its secrets share names |
name-mismatch | warn | a name that is not the one its tags give (renamed, or tagged by hand) |
expired | warn | expired secrets |
expiring | info | secrets expiring within expiryWarningDays |
not-yet-valid | info | secrets whose notBefore is in the future |
disabled | info | disabled secrets |
unnormalized-scope | info | scope tags with capitals or spaces (normalized on their next write) |
other-env | info | project audit with an environment: the project's secrets in other ones |
auditSecrets(properties, options) runs the same checks over secret properties listed elsewhere.
Testing with the mock
createAzureKeyVaultMock(options, { faults }) gives a handler over an in-memory vault shared by every
mock, that fails like Azure does (a missing secret is a secret-not-found). faults injects failures
by key, or for every request with '*'; resetAzureKeyVaultMock() empties the vault.
const vault = createAzureKeyVaultMock(
{ project: 'app', env: 'dev' },
{ faults: { DB_PASS: 'forbidden', '*': 'timeout' } },
);
Command 💻
The akv command runs the same operations. Logs go to stderr (at info unless --log says
otherwise) and output to stdout.
| Option | Description |
|---|---|
--uri | (required) vault URL |
--project | (required) project name |
--group | secrets group |
--env | environment |
--spn | service principal (application) id |
--password | service principal secret |
--tenant | tenant id |
--log | trace, debug, info (default), warn, error or silent |
--strict | a vault or access failure fails the command |
--concurrency | requests at once |
--timeout | time limit of each operation, in milliseconds |
--no-dns-check | skips resolving the vault host before the first request |
--key | secret key (get, set, versions, rollback) |
--value | secret value of set (else read from stdin) |
--version | secret version (get, rollback) |
--file | input JSON file (relative to the working directory) |
--output | output JSON file (relative to the working directory) |
--override | getfor reads keys with a default value too |
--json | check and audit print their result as JSON |
--all | audit covers the whole vault (no --project needed) |
| Command | Description |
|---|---|
check | diagnoses the vault and who signed in |
audit | audits the project, or the vault with --all; --strict fails on warnings |
get | prints a secret value (JSON when it is not a string) |
set | writes a secret; pipe the value (--value would stay in the shell history) |
list | prints the keys of the project group, one per line |
versions | prints the versions of a secret as JSON, newest first |
rollback | writes a previous version as the current one |
getfor | writes --output with the secrets of the --file structure (values are defaults) |
getall | writes --output with every secret of the project group |
publish | creates or updates the secrets of --file |
clear | deletes every secret of the project group |
restore | restores every deleted secret of the project group |
Exit codes: 0 success, 2 vault not found, unreachable or timed out, 3 no usable identity, 4
permission denied, 1 anything else. Reads only fail the command with --strict (or when get,
list or versions read nothing).
akv check --uri=https://my-key-vault.vault.azure.net --project=my-project --env=dev --json
printf '%s' "$DB_PASS" | akv set --uri=... --project=my-project --env=dev --key=DB_PASS
akv getfor --uri=... --project=my-project --env=dev --file=structure.json --output=secrets.json --log=debug
runCli(argv) runs the same command from code, and exitCode(kind) maps a failure kind to its code.
Development 🧿
pnpm test:cov # Vitest with coverage (100% thresholds)
pnpm lint # ESLint
pnpm typecheck # TypeScript
pnpm format # Prettier
pnpm build # Vite library build into dist/
Changelog 📄
For last changes see CHANGELOG.md.
Built with 🛠️
- @azure/identity - Azure identity provider.
- @azure/keyvault-secrets - Azure Key Vault secrets client.