Secrets vault
Versioned secrets with scheduled rotation, check-outs of shared credentials, dynamic per-caller credentials, and values under your own KMS keys.
Every application runs on secrets: database passwords, API keys for upstream services, signing keys, connection strings. They usually end up in a secrets manager with an access model of its own, while shared administrator passwords sit in a separate password vault, and nobody can say in one place who used which credential. When someone leaves, their account is closed, but the passwords they read keep working.
The vault keeps a 's secrets next to the identities and policies that decide who may use them. It covers four jobs that usually need separate products:
- A secrets manager. Named secrets with versions and stage labels (
current,previous,pending), scheduled rotation with generated values, and rotators that apply a new value to the system it unlocks. - A password vault for shared privileged accounts. Check-outs hand out a credential for a limited time, to one person at a time if you like, with a reason, and can rotate it when it comes back.
- Dynamic secrets. An engine mints a fresh credential per caller (a short-lived database role, a cloud token) with a lease, and takes it back when the lease ends.
- Encryption you control. Values are sealed with the deployment secret, or under the organization's own KMS key, so disabling that key makes them unreadable.
Access is decided like everything else in Better IAM: the iam:vault:* actions on iam/vault/secrets/{name}, with
the secret's tags available to policy .
Secrets and names
A secret lives in one tenant under a path-like name that is unique in the tenant:
await iam.api.vault.create(admin, {
tenantId,
name: 'prod/payments/db-password',
description: 'Primary payments database, app user',
tags: { environment: 'prod', team: 'payments' },
value: 'correct horse battery staple',
});
const { value, version } = await iam.api.vault.reveal(session, {
tenantId,
name: 'prod/payments/db-password',
});- Names are 1 to 16 segments separated by
/, at most 256 characters in all. Each segment has up to 64 letters, digits and_.-, and does not start with.or-. - Tags are up to 20 per secret. Tag names start with a letter and use letters, digits,
_and-; values are 1 to 256 characters. Policies see them (below), andlistfilters by them. - Values are at most 64 KiB by default (
vault.maxValueBytes), and a tenant holds at most 1000 secrets, pending deletions included (vault.maxSecretsPerTenant). A static secret may also start with a generated value (generate: true, see Rotation) or with no value yet.
A secret's format is text (the default) or json, which holds an object. Pass a json secret's value as fields
(or as a JSON string in value); reveal returns both value and the parsed fields.
await iam.api.vault.create(admin, {
tenantId,
name: 'prod/smtp',
format: 'json',
fields: { host: 'smtp.acme.test', username: 'mailer', password: 'mail-password' },
});Who may do what
| Action | Allows |
|---|---|
iam:vault:read | Metadata, never values: get, listVersions, listLeases, accessLog. |
iam:vault:reveal | reveal: a stored value. |
iam:vault:write | New versions and stage labels: put, promote, setStage. |
iam:vault:rotate | rotate. |
iam:vault:lease | checkout of a static secret and lease of a dynamic one. |
iam:vault:manage | create, update, delete, restore, setVersionState, destroyVersion, and ending others' leases. |
list returns only the secrets the caller may read (each one is decided like iam:vault:read), and needs no
permission of its own; neither do listMine (the caller's own live check-outs and leases) and generate.
Every action applies to iam/vault/secrets/{name}, so wildcards scope access by path, and a
gives each person a folder of their own:
{
"version": 1,
"statements": [
{
"sid": "PaymentsTeamReadsItsSecrets",
"effect": "allow",
"actions": ["iam:vault:read", "iam:vault:reveal"],
"resources": ["iam/vault/secrets/prod/payments/*"]
},
{
"sid": "PersonalSecrets",
"effect": "allow",
"actions": ["iam:vault:*"],
"resources": ["iam/vault/secrets/users/${principal.id}/*"]
}
]
}Conditions see the secret's attributes: resource.name, resource.kind (static or dynamic), resource.format,
resource.status, resource.createdBy, resource.checkoutRequired, resource.rotationEnabled,
resource.customerManagedKey, resource.engine, and each tag as resource.tag.{key}:
{
"effect": "allow",
"actions": ["iam:vault:reveal"],
"resources": ["iam/vault/secrets/*"],
"conditions": {
"StringEquals": { "resource.tag.environment": "staging" },
"Bool": { "principal.mfa": true }
}
}A name nobody uses yet has no attributes, so a tag condition never lets anyone create a secret: grant manage by path
for that. A new secret is decided again with its attributes (tags, kind, engine, settings), so a deny on
resource.engine refuses creating a secret bound to that engine. Every update is decided again too: the caller must
still be allowed to manage the secret afterwards, and the change may not open any vault action on it to them that they
did not have before. Re-tagging a prod secret as staging is refused for someone who may reveal only staging
secrets, however broad their manage.
Values stay with the person asking
Values never leave through "view as": reveal, checkout and lease are refused in
sessions, whatever the member may do. Agents acting for a person are
decided like any delegated session, so a delegation's confirm list can hold iam:vault:reveal back for the person to
approve call by call. Each approval opens one
call, including the iam:kms:decrypt that a secret under a customer-managed key needs.
' afterOperation hooks see the answers of reveal, checkout and rotate without
their values.
Versions and stages
Every change of value is a new version. Stage labels point at versions:
currentis what callers get by default.putmakes the new version current, and the old one becomesprevious.pendingholds a value that is not live yet:put({ stage: 'pending' }), thenpromote({ version }). Rotation uses it too.- Up to 8 custom labels, such as
canary, set withsetStage(and removed withversion: null). Labels are 1 to 32 lowercase letters, digits or hyphens, starting with a letter.currentandpreviousmove only throughput,promoteandrotate.
await iam.api.vault.put(admin, { tenantId, name, value: 'new value' }); // version 2 is current
await iam.api.vault.reveal(session, { tenantId, name, stage: 'previous' }); // version 1
await iam.api.vault.promote(admin, { tenantId, name, version: 1 }); // roll backmaxVersions (1 to 100, default 10) keeps the newest versions; older ones that no label points at are deleted. A
version can also be:
- disabled with
setVersionState: kept, but not revealed (VERSION_DISABLED) until it is enabled again; - destroyed with
destroyVersion, only from a session with (a sign-in within the last five minutes by default): its value is erased for good, labels on it are removed, and its record stays as history (VERSION_DESTROYED).
The current version can be neither: promote another version first.
Rotation
rotate replaces a static secret's value in three steps: it stages a new version as pending, lets the secret's
rotator apply it to the system the secret unlocks, and then makes it current.
await iam.api.vault.create(admin, {
tenantId,
name: 'prod/payments/db-password',
generate: true,
rotation: {
intervalDays: 30,
rotator: 'postgres',
generator: { length: 32, charset: 'ascii', exclude: '%' },
},
});rotation takes intervalDays (1 to 365; without it, rotation runs only on demand), rotator (a name from the
deployment's vault.rotators), and generator. Without a generator, rotation uses the default one. A json secret
names the field rotation replaces (rotation.field), and its other fields are carried over; a json secret with a
generator or a rotator must name it.
Generators draw from the operating system's CSPRNG. vault.generate returns a value without storing it, for a
password form, say.
Prop
Type
Rotators
Rotators are functions you configure on the deployment. A rotator receives the pending value (and fields for a
json secret), the value being replaced as previous (and previousFields), the new version number, and the secret's
tenantId, name and tags. Throwing fails the rotation.
const iam = betterIam({
// ...
vault: {
rotators: {
postgres: {
async rotate({ tenantId, name, value, tags }) {
await admin.query(`ALTER ROLE ${ident(tags.role!)} PASSWORD ${literal(value)}`);
},
},
},
},
});A failed rotation keeps the pending version, records the error (with the new and old values, each field of a json
secret, and their JSON-, URL- and base64-encoded spellings replaced by [redacted]), and fails with
ROTATION_FAILED. The next attempt, by hand or on schedule, gives the rotator the same value, so rotators must be
idempotent. Scheduled retries back off from one hour, doubling up to a day.
Only a version the rotation itself staged is ever retried. A value someone put under the pending label by hand is
never handed to the rotator, so iam:vault:write cannot choose the credential a rotator sets on the target system.
rotate({ value }) (or fields) chooses the new value and needs iam:vault:write and iam:vault:reveal besides
iam:vault:rotate, since the caller knows it. It is refused for secrets that only check-outs hand out, which always
rotate to generated values. A version disabled while the rotator runs is not made current.
Scheduled rotation
With intervalDays, a secret is due that many days after its last new value, or after its creation. A put counts as
a new value; rolling back with promote does not. iam.vault.rotateDue(), run hourly, rotates due secrets that have a
generator or a rotator, acting as deployment-operator. A due secret with neither, such as a key a vendor issues, is
recorded once per due date as vault:rotation-due: subscribe a to it to remind the
secret's owners. get and list show rotation.due, nextRotationAt and the last failure.
Check-outs for shared credentials
Some credentials are shared by a team: a break-glass root password, a vendor portal account. A check-out policy makes the vault hand them out one use at a time:
await iam.api.vault.create(admin, {
tenantId,
name: 'break-glass/db-root',
generate: true,
checkout: {
required: true, // reveal is refused (CHECKOUT_REQUIRED); only check-outs hand the value out
exclusive: true, // one holder at a time (SECRET_CHECKED_OUT)
maxDurationMs: 2 * 3_600_000,
rotateOnCheckin: true, // a returned password stops working
requireReason: true,
},
});
const out = await iam.api.vault.checkout(operator, {
tenantId,
name: 'break-glass/db-root',
reason: 'INC-1234: primary database unresponsive',
});
// out.value, out.leaseId, out.expiresAt
await iam.api.vault.checkin(operator, { tenantId, leaseId: out.leaseId });Prop
Type
Only secrets with a check-out policy can be checked out, so an iam:vault:lease grant meant for dynamic secrets never
hands out stored values. Nobody holds two check-outs of the same secret. While a check-out lasts, its holder may:
- reveal the version they checked out;
- renew it with
renewLease, by the length it was issued for, never more thanmaxDurationMsafter it began, and only while they still holdiam:vault:lease; - return it with
checkin.
The session that took a check-out may always return it, even after the holder's access was taken away. The holder's
other sessions (another sign-in, an agent acting for them, a narrower API key) act on it only while they may lease the
secret themselves. Anyone with iam:vault:manage can end someone else's check-out with revokeLease.
With rotateOnCheckin, the secret rotates once its last holder returns it, however the check-out ended: checked in,
ended with revokeLease, or expired (iam.vault.expireLeases, which also ends the check-outs of holders who are no
longer active). A json secret that rotates on check-in must name rotation.field. A rotation that fails after a return
is recorded and retried by iam.vault.rotateDue, and a scheduled rotation of such a secret waits while it is checked
out. get shows who holds a secret and until when.
Dynamic secrets
A dynamic secret stores no value. Each lease asks an engine, configured on the deployment, for a credential minted
for the caller:
const iam = betterIam({
// ...
vault: {
engines: {
postgres: {
async issue({ leaseId, config, ttlMs, holder }) {
const username = `v_${holder.id.slice(0, 8)}_${leaseId.slice(0, 8)}`;
const password = randomPassword();
const until = new Date(Date.now() + ttlMs).toISOString();
await admin.query(
`CREATE ROLE ${ident(username)} LOGIN PASSWORD ${literal(password)} VALID UNTIL ${literal(until)} IN ROLE ${ident(config.role)}`,
);
return { fields: { username, password }, handle: username };
},
async revoke({ handle }) {
await admin.query(`DROP ROLE IF EXISTS ${ident(handle!)}`);
},
},
},
},
});
await iam.api.vault.create(admin, {
tenantId,
name: 'prod/analytics/readonly',
kind: 'dynamic',
format: 'json',
engine: 'postgres',
engineConfig: { role: 'analytics_readonly' },
lease: { defaultTtlMs: 3_600_000, maxTtlMs: 8 * 3_600_000 },
});
const lease = await iam.api.vault.lease(analyst, { tenantId, name: 'prod/analytics/readonly' });
// lease.fields.username, lease.fields.password, lease.expiresAtAn engine's issue receives the leaseId, the secret's engineConfig (any JSON up to 8 KiB), the lease length
ttlMs, and the holder (id, kind, name, email). It returns the credential as value, or as fields for a
json secret, and optionally a handle. revoke and renew are optional. A dynamic secret's lease settings are
defaultTtlMs (1 minute to 30 days, default one hour) and maxTtlMs (1 minute to 30 days, default one day, or
defaultTtlMs when that is longer). Dynamic secrets take no value, rotation, checkout or kmsKey.
- The credential is returned once and never stored. The engine's
handle(at most 4096 characters) is sealed and handed back torevokeandrenew. renewLeaseextends a lease up tolease.maxTtlMsfrom its start, telling the engine when it hasrenew.revokeLease(the holder, oriam:vault:manage) revokes it at the engine. A revocation the engine refuses staysrevokingand is retried byiam.vault.expireLeases, one minute later and then doubling up to six hours, for seven days. After that the lease is given up and recorded asvault:revoke-failed.iam.vault.expireLeases()(every few minutes) revokes expired leases and the leases of holders who may no longer have them: people and agents who were deactivated or deleted, agents whose sponsor left, and leases an agent took for a person under a delegation that was revoked or ran out. It looks at every live lease on each run, however many there are, and ends at mostlimit(default 1000) of each kind per run.- An engine failure fails the call with
ENGINE_FAILED. Onceissuewas called, whatever went wrong afterwards (an error, a timeout, a credential or handle the vault refuses, the lease being revoked while it was issued, or a process that stopped before the issue finished), the lease is revoked at the engine by itsleaseId. Engines must therefore be able to find a credential by lease id when no handle came back. - Deleting a dynamic secret for good waits until its live leases are revoked.
Give credentials an expiry of their own
Engines should create credentials that expire by themselves (like VALID UNTIL above), so a revocation that keeps
failing cannot leave them working forever.
Customer-managed keys
By default, values are sealed with the deployment secret (AES-256-GCM, bound to the tenant, secret and version, so a
sealed value cannot be moved to another record). kmsKey (a key id or alias/{name}) puts a static secret's values
under one of the organization's own KMS encryption keys instead:
await iam.api.vault.create(admin, {
tenantId,
name: 'prod/signing-key',
value: pem,
kmsKey: 'alias/vault',
});
// Move an existing secret under the key (every kept version is re-encrypted), or back with kmsKey: null.
await iam.api.vault.update(admin, { tenantId, name: 'prod/smtp', kmsKey: 'alias/vault' });As with AWS KMS, the key's owner keeps a say over the values:
- Binding a secret to a key needs
iam:kms:encrypton it as well asiam:vault:manage. Moving a secret off its key (to another key or back to the deployment secret) needsiam:kms:decrypton the current one. revealandcheckoutneediam:kms:decrypton the key besides the vault permission;putneedsiam:kms:encrypt, androtateboth. The scheduler jobs andiam.vaultare not asked.- Every use appears in the key's audit trail as
iam:kms:encryptoriam:kms:decryptwithmetadata.via: 'vault'. - Disabling the key makes the values unreadable (
KEY_STATE_INVALID) until it is enabled again. Deleting it destroys them for good.
Customer-managed keys are enabled aes-256-gcm encryption keys of the tenant; values of any size are
envelope-encrypted with them.
Deleting secrets
delete schedules the deletion after a recovery window of 7 to 30 days (recoveryDays, default 30). Meanwhile the
secret cannot be read, changed or leased, its check-outs end, its name stays taken, and restore brings it back.
iam.vault.purgeDeleted() (daily) deletes secrets whose window has ended, with their versions, leases and access
records, once their live dynamic leases are revoked at their engine; a refused revocation keeps the secret for the next
run. recoveryDays: 0 deletes at once, and only from a session with recent authentication (RECENT_AUTH_REQUIRED
otherwise).
Server code
The deployment's own code reads secrets without a through iam.vault:
const db = await iam.vault.get(tenantId, 'prod/payments/db-password');
// Replace vault:// references in a configuration object; each secret is read once.
const config = await iam.vault.resolve(tenantId, {
smtp: { host: 'vault://prod/smtp#host', password: 'vault://prod/smtp#password' },
stripeKey: 'vault://prod/stripe/secret-key',
});get returns the current version of a static secret, or the version or stage you pass; a secret pending deletion
is NOT_FOUND. resolve replaces vault://{name} with a secret's value and vault://{name}#{field} with one field of
a json secret, anywhere in a JSON value.
Trusted code only
iam.vault is not reachable over HTTP. It skips check-out rules and records nothing, neither in the audit log nor in
the access log, so keep it to trusted code.
From the command line
With BETTER_IAM_TOKEN (and BETTER_IAM_TENANT or --tenant), the CLI reads and writes
secrets and starts programs with them:
better-iam vault-get prod/payments/db-password
printf %s "$NEW_PASSWORD" | better-iam vault-put prod/payments/db-password
better-iam vault-run --prefix prod/payments/ --env STRIPE_KEY=prod/stripe#secret -- node server.jsvault-getprints the current value, or--version,--stage, or one--fieldof a json secret.vault-puttakes the value from standard input or--value-env(the name of an environment variable), never from the command line, or generates one with--generate.--stage pendingstages it instead of making it current.vault-runreveals secrets and runs the command after--with them as environment variables, so they never touch disk or shell history.--env VAR=name[#field]maps variables one by one (comma-separated), and--prefixadds every static secret under a path, named after the rest of its path:prod/payments/db-passwordbecomesDB_PASSWORD, and a json secret adds one variable per field.--envwins over--prefix. The command inherits the terminal, its exit status becomesvault-run's, andBETTER_IAM_TOKENis removed from its environment unless--keep-token.
Deployment options and jobs
The vault option of betterIam() holds the rotators and engines, and the limits:
Prop
Type
A bad value fails construction with INVALID_CONFIG. Schedule the three jobs with the other
scheduled jobs. They act as deployment-operator and need no credential:
| Job | CLI | How often | Does |
|---|---|---|---|
iam.vault.rotateDue() | vault-rotate-due | hourly | Rotates due secrets and retries failed rotations; records vault:rotation-due for manual ones. |
iam.vault.expireLeases() | vault-expire-leases | every few minutes | Ends expired check-outs and leases, retries failed revocations, deletes lease history past retention. |
iam.vault.purgeDeleted() | vault-purge-deleted | daily | Deletes secrets past their recovery window. |
iam.sweepExpired() (the ) deletes access records older than
vault.accessRetentionDays, and iam.rotateSecrets() re-seals vault
values and engine handles when the deployment secret changes. Values under a customer-managed key are unaffected.
Audit and the access log
Every call is audited as its iam:vault:* action, and calls that change or return values also as vault:create,
vault:update, vault:delete, vault:restore, vault:purge, vault:reveal, vault:put, vault:promote,
vault:stage, vault:version-state, vault:destroy-version, vault:rotate (outcome deny when the rotator failed),
vault:rotation-due, vault:checkout, vault:checkin, vault:checkout-expired, vault:lease (deny when the
engine failed), vault:renew, vault:revoke, vault:lease-expired and vault:revoke-failed. Refused calls are
audited with outcome deny like any other.
accessLog answers "who used this secret" without searching the : reveals,
check-outs and returns, leases, renewals, revocations, new versions and rotations, newest first, with the person's
name, the session kind and the agent, if one acted.
Errors
| Code | Status | Meaning |
|---|---|---|
SECRET_PENDING_DELETION | 409 | The secret is scheduled for deletion; restore it first. |
CHECKOUT_REQUIRED | 409 | The secret is handed out only through check-outs. |
SECRET_CHECKED_OUT | 409 | Someone else holds an exclusive secret, or the caller already holds a check-out of it. |
VERSION_DISABLED | 409 | The version is disabled. |
VERSION_DESTROYED | 410 | The version's value was destroyed. |
ROTATION_FAILED | 502 | The rotator failed; the pending version waits for the next attempt. |
ENGINE_FAILED | 502 | The dynamic secret engine failed to issue or renew a credential. |
KEY_STATE_INVALID | 409 | The secret's customer-managed key is disabled or pending deletion. |
Other refusals use the common codes: ACCESS_DENIED, NOT_FOUND, CONFLICT for a name in use (pending deletions
included), LIMIT_EXCEEDED past the secret or stage label limits, RECENT_AUTH_REQUIRED, and INVALID_INPUT. The API
reference lists them per method.
Better IAM is created by Sean Filimon
Last updated
Key managementnew
Tenant keys that never leave the server. Encrypt, sign, compute MACs and mint JWTs through the keys API, with rotation, aliases, grants and audit.
Data protectionnew
Replace card numbers, SSNs, emails, phone numbers and free text with tokens, and read the values back only per profile and stated purpose.