BetterIAM
Secrets and keys

Secrets vault

Versioned secrets with scheduled rotation, check-outs of shared credentials, dynamic per-caller credentials, and values under your own KMS keys.

new@better-iam/server@better-iam/clisecrets-vault.mdvault.tsvault.tsvault.ts

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), and list filters 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

ActionAllows
iam:vault:readMetadata, never values: get, listVersions, listLeases, accessLog.
iam:vault:revealreveal: a stored value.
iam:vault:writeNew versions and stage labels: put, promote, setStage.
iam:vault:rotaterotate.
iam:vault:leasecheckout of a static secret and lease of a dynamic one.
iam:vault:managecreate, 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}:

Reveal staging secrets only, with MFA
{
  "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:

  • current is what callers get by default. put makes the new version current, and the old one becomes previous.
  • pending holds a value that is not live yet: put({ stage: 'pending' }), then promote({ version }). Rotation uses it too.
  • Up to 8 custom labels, such as canary, set with setStage (and removed with version: null). Labels are 1 to 32 lowercase letters, digits or hyphens, starting with a letter. current and previous move only through put, promote and rotate.
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 back

maxVersions (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.

Outside the transaction, with a timeout

Rotators and engines are called outside any database transaction, with a 30 second timeout (vault.callTimeoutMs).

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 than maxDurationMs after it began, and only while they still hold iam: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.expiresAt

An 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 to revoke and renew.
  • renewLease extends a lease up to lease.maxTtlMs from its start, telling the engine when it has renew.
  • revokeLease (the holder, or iam:vault:manage) revokes it at the engine. A revocation the engine refuses stays revoking and is retried by iam.vault.expireLeases, one minute later and then doubling up to six hours, for seven days. After that the lease is given up and recorded as vault: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 most limit (default 1000) of each kind per run.
  • An engine failure fails the call with ENGINE_FAILED. Once issue was 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 its leaseId. 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:encrypt on it as well as iam:vault:manage. Moving a secret off its key (to another key or back to the deployment secret) needs iam:kms:decrypt on the current one.
  • reveal and checkout need iam:kms:decrypt on the key besides the vault permission; put needs iam:kms:encrypt, and rotate both. The scheduler jobs and iam.vault are not asked.
  • Every use appears in the key's audit trail as iam:kms:encrypt or iam:kms:decrypt with metadata.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.js
  • vault-get prints the current value, or --version, --stage, or one --field of a json secret.
  • vault-put takes 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 pending stages it instead of making it current.
  • vault-run reveals 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 --prefix adds every static secret under a path, named after the rest of its path: prod/payments/db-password becomes DB_PASSWORD, and a json secret adds one variable per field. --env wins over --prefix. The command inherits the terminal, its exit status becomes vault-run's, and BETTER_IAM_TOKEN is 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:

JobCLIHow oftenDoes
iam.vault.rotateDue()vault-rotate-duehourlyRotates due secrets and retries failed rotations; records vault:rotation-due for manual ones.
iam.vault.expireLeases()vault-expire-leasesevery few minutesEnds expired check-outs and leases, retries failed revocations, deletes lease history past retention.
iam.vault.purgeDeleted()vault-purge-deleteddailyDeletes 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

CodeStatusMeaning
SECRET_PENDING_DELETION409The secret is scheduled for deletion; restore it first.
CHECKOUT_REQUIRED409The secret is handed out only through check-outs.
SECRET_CHECKED_OUT409Someone else holds an exclusive secret, or the caller already holds a check-out of it.
VERSION_DISABLED409The version is disabled.
VERSION_DESTROYED410The version's value was destroyed.
ROTATION_FAILED502The rotator failed; the pending version waits for the next attempt.
ENGINE_FAILED502The dynamic secret engine failed to issue or renew a credential.
KEY_STATE_INVALID409The 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.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page