Key management
Tenant keys that never leave the server. Encrypt, sign, compute MACs and mint JWTs through the keys API, with rotation, aliases, grants and audit.
Applications that encrypt records or sign tokens need keys, and keys kept in environment variables or configuration files get copied into every service, backup and log that touches them. Anyone who reads one copy can decrypt everything or sign anything, and nothing records who used the key, or for what.
Better IAM includes a key management service (KMS) in the style of AWS KMS. Each
keeps its own cryptographic keys, and applications use them through the keys API group to encrypt, decrypt, sign,
verify and compute MACs. The key material never leaves the server. Every call is authorized by the same
and as the rest of the platform, and
written to the audit trail. Keys are tenant resources, so a policy can let a payments service decrypt only with keys
tagged team: payments, and only for records whose encryption context says app: billing.
const key = await iam.api.keys.create(credential, {
tenantId,
alias: 'alias/customer-records',
tags: { team: 'payments' },
rotationPeriodDays: 365,
});
const { ciphertext } = await iam.api.keys.encrypt(credential, {
tenantId,
keyId: 'alias/customer-records',
plaintext: 'card on file: 4242',
encryptionContext: { customer: 'cus_123' },
});
const { plaintext } = await iam.api.keys.decrypt(credential, {
tenantId,
ciphertext,
encryptionContext: { customer: 'cus_123' },
});Over HTTP the same calls are POST {basePath}/keys/{method}, and @better-iam/client exposes them as
client.keys.*.
Key kinds
A key's keySpec decides what it can do:
keySpec | keyUsage | What it does |
|---|---|---|
aes-256-gcm (default) | encrypt | encrypt and decrypt up to 4 KiB, generateDataKey, reEncrypt |
hmac-sha256, hmac-sha384, hmac-sha512 | mac | generateMac and verifyMac, and HS256, HS384 or HS512 tokens with signJwt |
ecc-p256, ecc-p384 | sign | ES256 and ES384 signatures and tokens |
ed25519 | sign | EdDSA signatures and tokens |
rsa-2048, rsa-3072, rsa-4096 | sign or encrypt | PS256-PS512 and RS256-RS512 signatures and tokens, or RSA-OAEP (SHA-256) for small values |
An RSA key needs an explicit keyUsage; every other spec has exactly one usage. A tenant keeps at most 1000 keys,
and a key at most 1000 versions, 50 aliases and 50 grants.
create (iam:kms:create) takes:
Prop
Type
Versions and aliases
Each key has versions. Encrypting, signing and computing MACs use the current version. Decrypting and verifying use whichever version produced the ciphertext, signature or token, so rotation never breaks existing data:
rotatecreates a new version on demand. It works on enabled keys only, and at most ten times a day per key (RATE_LIMITED). Asymmetric key pairs are generated off the event loop, so a slow RSA-4096 rotation does not stall other requests.rotationPeriodDaysrotates an enabled key automatically. The scheduler jobiam.kms.maintain()performs it and recordskms:key-rotate(actordeployment-operator). SettingrotationPeriodDays: nullinupdatestops automatic rotation.listVersionsshows each version's origin (create,rotateorautomatic) and, for asymmetric keys, a fingerprint of its public key.
A key has an id (a UUID) and any number of aliases of the form alias/{name}, where the name uses letters,
digits, slashes, underscores and hyphens. An alias names one key per tenant, and every call that takes a keyId
accepts an alias too. updateAlias points an alias at another key of the same kind and usage, so applications that
name the alias switch keys without a deploy. Moving an alias needs iam:kms:update on both keys. createAlias and
updateAlias take the key's id, not another alias, and deleteAlias removes the name but keeps the key.
Encryption
encrypt takes plaintext (UTF-8 text) or plaintextBase64 (bytes), up to 4 KiB, and returns a self-describing
ciphertext of the form kms1.{keyId}.{version}.{t|b}.{payload}.
The ciphertext carries the key and version, so decrypt needs only the ciphertext and the encryption context. Pass
keyId to decrypt as well if you want to insist on a particular key; a ciphertext from another key then fails with
INVALID_CIPHERTEXT. The t or b marker records whether the plaintext was text or bytes, and decrypt returns it
in the same form (plaintext or plaintextBase64). The marker is authenticated with the rest of the header.
Encryption context
An encryption context is up to 16 non-secret key/value pairs that are bound to the ciphertext as additional
authenticated data. Keys follow the same rules as tag keys, and values are non-empty strings of up to 1024
characters. decrypt must present exactly the same pairs; otherwise it fails with INVALID_CIPHERTEXT, and a
tampered ciphertext fails the same way. Contexts do three jobs:
- Integrity. A ciphertext copied to another record cannot be decrypted as that record.
- Authorization. Policies read the pairs as
resource.encryptionContext.{key}and their names, sorted, asresource.encryptionContextKeys. - Audit. Each call's audit event records the context.
Contexts are not secret
Encryption contexts are written to the audit trail and shown to policies, so they must never contain secrets. Use identifiers such as a customer ID or a table name.
Data keys (envelope encryption)
Direct encryption is limited to 4 KiB. For larger data, use envelope encryption:
Get a data key. generateDataKey (iam:kms:generate-data-key) returns a fresh random key as
plaintextBase64 (16, 24, 32 or 64 bytes; 32 by default) and the same key encrypted under the KMS key as
ciphertext.
Encrypt locally. Encrypt the data with the plaintext key, store the ciphertext next to it, and discard the
plaintext.
Decrypt the data key to read. decrypt (iam:kms:decrypt) the stored ciphertext with the same context to get
the data key back as plaintextBase64.
const dataKey = await iam.api.keys.generateDataKey(credential, {
tenantId,
keyId: 'alias/customer-records',
encryptionContext: { table: 'documents' },
});
// Encrypt with Buffer.from(dataKey.plaintextBase64!, 'base64'), then store dataKey.ciphertext beside the data.
const { plaintextBase64 } = await iam.api.keys.decrypt(credential, {
tenantId,
ciphertext: stored.wrappedKey,
encryptionContext: { table: 'documents' },
});includePlaintext: false returns only the encrypted data key, for a service that stores it for another service to
open later.
reEncrypt moves a ciphertext to another key (destinationKeyId) or context (sourceEncryptionContext,
destinationEncryptionContext) without the plaintext leaving the server. It needs iam:kms:decrypt on the source
key and iam:kms:encrypt on the destination key, and it is audited as both.
RSA keys with keyUsage: 'encrypt' use RSA-OAEP with SHA-256, with the ciphertext header and the context as the OAEP
label. They accept only small values: 190 bytes with a 2048-bit key, 318 with 3072 bits and 446 with 4096 bits. Use
data keys for anything larger. Because of the label, a value encrypted offline with the public key is not a KMS
ciphertext; encrypt through encrypt.
Signatures, MACs and tokens
signandverifywork on amessage(text) ormessageBase64of up to 64 KiB.algorithmdefaults to the key's first (ES256, ES384, EdDSA, or PS256 for RSA). ECDSA signatures are DER-encoded by default, andformat: 'jose'returns the rawr||sform that JWS uses.verifychecks againstkeyVersion(the current version by default) and returns{ valid }: a mismatch is not an error.generateMacandverifyMaccompute and check HMACs. The check is constant-time.publicKeyreturns an asymmetric version's key (the current one by default) as SPKI PEM and JWK.jwksreturns every version as a JWK Set. Anyone holding the public key can verify signatures without calling IAM.signJwtsigns a JWT with a signing key or a MAC key. The header is{ alg, kid, typ }, withkidset to{keyId}.{version}(the samekidthe JWKS uses) andtyptoJWTunlesstypesays otherwise.iatis added when absent,expcomes fromexpiresInSeconds(1 second to 1 year), and claims can be up to 48 KiB.verifyJwtfinds the version from thekid(passkeyIdfor a token without a KMSkid) and accepts only the key's own algorithms, soalg: noneand algorithm swaps are refused. Tokens with acritheader, or with anexpornbfthat is not a number, are refused as well. It checksexpandnbfwithclockToleranceSeconds(60 by default, at most 600), andaudandisswhen you passaudienceandissuer.
const { token } = await iam.api.keys.signJwt(credential, {
tenantId,
keyId: 'alias/service-tokens',
claims: { iss: 'https://billing.example.com', sub: 'job-42', aud: 'ledger' },
expiresInSeconds: 300,
});
const result = await iam.api.keys.verifyJwt(credential, {
tenantId,
token,
audience: 'ledger',
issuer: 'https://billing.example.com',
});
// { valid: true, claims, header, ... } or { valid: false, reason, ... }verifyJwt does not throw for a bad token. reason is signature, expired, not-yet-valid, audience,
issuer, algorithm, header or claims.
Policies see what a token names: resource.jwt (true), resource.jwt.typ, resource.jwt.sub, resource.jwt.iss,
and resource.jwt.aud (or resource.jwt.audiences for a list). A policy can therefore let a service mint tokens for
one issuer only, or mint tokens but never sign raw bytes.
Base64 inputs (signatures, MACs, ciphertexts, plaintextBase64) must be canonical: one byte string has exactly one
accepted spelling, so a cache keyed by the string cannot be sidestepped.
Permissions
KMS calls are authorized like every other IAM operation: a policy names the action and the resource.
| Action | Resource | Calls |
|---|---|---|
iam:kms:create | iam/kms | create |
iam:kms:read | iam/kms/{id} | get, list (per key), listVersions, listGrants, publicKey, jwks |
iam:kms:read | iam/kms | listAliases without keyId |
iam:kms:update | iam/kms/{id} | update, enable, disable, rotate, createAlias, updateAlias, deleteAlias |
iam:kms:update | iam/kms/alias/{name} | create with an alias, createAlias, updateAlias, deleteAlias |
iam:kms:delete | iam/kms/{id} | scheduleDeletion (also needs a recent sign-in), cancelDeletion |
iam:kms:grant | iam/kms/{id} | createGrant, revokeGrant |
iam:kms:encrypt | iam/kms/{id} | encrypt, and the destination of reEncrypt |
iam:kms:decrypt | iam/kms/{id} | decrypt, and the source of reEncrypt |
iam:kms:generate-data-key | iam/kms/{id} | generateDataKey |
iam:kms:sign, iam:kms:verify | iam/kms/{id} | sign, verify, and signJwt, verifyJwt with signing keys |
iam:kms:generate-mac, iam:kms:verify-mac | iam/kms/{id} | generateMac, verifyMac, and signJwt, verifyJwt with MAC keys |
Conditions can use these attributes of the key:
| Context key | Value |
|---|---|
resource.keyId | The key id |
resource.keySpec, resource.keyUsage | For example aes-256-gcm and encrypt |
resource.keyState | enabled, disabled or pending-deletion |
resource.aliases | The key's aliases (use ArrayContains) |
resource.tags.{key} | Each tag |
resource.createdBy | The identity that created the key |
resource.managedBy | pki or protection on a managed key |
resource.encryptionContext.{key} | Encrypt, decrypt and data key calls |
resource.encryptionContextKeys | The context's key names, sorted |
resource.algorithm | Signing, MAC and token calls |
{
"version": 1,
"statements": [
{
"effect": "allow",
"actions": ["iam:kms:encrypt", "iam:kms:decrypt", "iam:kms:generate-data-key"],
"resources": ["iam/kms/*"],
"conditions": {
"StringEquals": {
"resource.tags.team": "payments",
"resource.encryptionContext.app": "billing"
}
}
}
]
}For create, the attributes describe the key being requested: resource.keySpec, resource.keyUsage and the
requested resource.tags.{key}. A policy can therefore allow someone to create only keys that carry their team's
tag. update checks iam:kms:update a second time with the new tags, so a tag-scoped administrator cannot move a key
out of their own reach or into another team's.
Tags and aliases never add rights
Changing what a key looks like to policies never hands the caller rights on it. When tags change, or an alias is
added to a key, moved between keys or removed, every KMS action the caller may not take on the key as it is must stay
refused on the key as it would be. Otherwise someone who manages every key but may decrypt only one team's could
retag a key into that team, or remove the alias a deny names (ArrayContains on resource.aliases).
Alias names are a namespace of their own. Creating, moving or removing alias/{name} also needs iam:kms:update on
iam/kms/alias/{name} (the name is resource.alias). Someone who manages one key therefore cannot claim
alias/prod/payments and have others encrypt under their key. Grant alias prefixes explicitly, for example
iam/kms/alias/sandbox/*.
Who is refused
listevaluatesiam:kms:readby policy for each key and returns only the keys the caller may read, so a tag-scoped reader sees only their team's keys.- Sessions that view as someone else () cannot use KMS at all.
- Callers from another tenant (other than a root administrator) are refused before any key is looked up, so they cannot tell which keys or aliases exist.
- An agent acting for a person under a delegation that holds an action back (
confirm) uses up one confirmation per call, as everywhere else (confirming actions).
Managed keys
Keys another module created for its own use, or took over, carry managedBy (pki for a certificate authority's
signing key, protection for a data protection profile's key) and managedId. The KMS API refuses to encrypt,
decrypt, sign, generate data keys or MACs with them, or grant them (KEY_MANAGED, 409): only the module uses them,
for the decisions it makes. They stay visible, and can be tagged, rotated, disabled and deleted like any other key,
which is how their owner stops the module. Policies see resource.managedBy. See
Private CA and
Data protection for the modules that manage keys.
Grants
A grant allows one identity (a person, or agent) to perform named operations on one key. It lets a key's owner hand a workload access to a single key without writing a policy.
await iam.api.keys.createGrant(ownerCredential, {
tenantId,
keyId: 'alias/customer-records',
granteeId: billingWorker.id,
operations: ['decrypt'],
constraints: { encryptionContextSubset: { app: 'billing' } },
expiresAt: Date.now() + 30 * 86_400_000,
});Prop
Type
A grant only passes on what its creator holds, for as long as they hold it:
- Each granted operation must be allowed to the creator by policy when the grant is made (grants they hold do not count), and every use checks again that the creator could make that very call now, with the same key attributes and encryption context. When the creator's binding expires, their role is removed, their just-in-time activation lapses, they are offboarded, or a deny is added (including one conditioned on the encryption context), their grants stop working at once. The creator is checked as a plain session of their own, without MFA or session tags, so policies that require those fail closed.
- Grants are made with
iam:kms:grantfrom a user session or API key acting in its own right, by an identity of the key's tenant. Assumed roles, session tokens and delegated sessions cannot create grants. - A grant applies only when no policy decides the call and every boundary allows it. , tenant boundaries, permission boundaries, session policies and API-key scopes still apply.
- Grants serve user sessions and API keys acting in their own right. Assumed roles, session tokens, delegated agent sessions and impersonation never use them.
- Grants name identities only, so group membership keeps following the group-authority rules that protect every other grant of access.
- Calls allowed through a grant record
grantIdin their audit event. A caller that reads a key through areadgrant sees only its own grants inlistGrants. revokeGrantneedsiam:kms:grant. The grantee can give up its own grant withretireGrant, which needs no permission and is audited askms:grant-retire.- Grants lapse at
expiresAt.iam.kms.maintain()removes lapsed grants, and grants whose grantee or creator has been deleted.
Disabling and deleting keys
disablerefuses every cryptographic call with the key (KEY_STATE_INVALID, 409) untilenable. Everything encrypted under the key is unreadable in the meantime, which makes disabling a quick way to cut off access to data.scheduleDeletion(waitingDays7 to 30, default 30) needs a . It makes the key unusable and sets adeletionDate. Until that date,cancelDeletionbrings the key back disabled.- After the waiting period,
iam.kms.maintain()destroys the key's material, versions, aliases and grants and recordskms:key-destroy.
Deletion is crypto-shredding
Once a key is destroyed, nothing encrypted under it can be decrypted again. Disable the key for a while first to check that nothing still needs it.
Audit
Every call writes an audit event with the action (for example iam:kms:decrypt) on resource kms/{keyId} (kms
for tenant-wide calls). The event records the key version, the encryption context, the algorithm, and the grant that
allowed the call, if any. It never records a plaintext, a key, or a signature.
- Denials are recorded like any other denial. A call refused after it was authorized is recorded as a
denytoo, withmetadata.reason:invalid-ciphertext(a wrong context or a modified ciphertext, so repeated attempts show up in the trail),key-state(a disabled key or one pending deletion),key-managedorrefused. - The two events of one
reEncryptshare areEncryptId. listrecords oneiam:kms:readevent with the number of keys listed.-
afterOperationhooks see which key and version served a call, but never plaintexts, data keys or signed tokens.
How key material is protected
Key material is generated on the server with Node's crypto: random bytes for AES and HMAC keys, and PKCS#8 private
keys for ECDSA, Ed25519 and RSA. Each version is sealed with AES-256-GCM under the deployment secret, with the key
id and version as associated data, and stored in the kmsKeyVersions collection. Only asymmetric public keys are
stored in the clear.
When you rotate the deployment secret (previousSecrets and iam.rotateSecrets(), see
Rotating the deployment secret), KMS material
is re-sealed with everything else. Keys, ciphertexts and signatures do not change, and nobody has to re-encrypt
anything.
Protect the secret like a master key
Because the material is sealed under the deployment secret, anyone who has both the database and the secret can
recover the keys. If the secret that sealed a key is no longer configured, calls with the key fail with
KEY_MATERIAL_UNAVAILABLE.
Scheduling
Run iam.kms.maintain() hourly next to the other scheduler jobs. It rotates keys whose
automatic rotation is due, destroys keys whose deletion date has passed, removes lapsed and orphaned grants, and
returns { rotated, destroyed, grantsRemoved }. Pass { tenantId } to limit it to one tenant.
setInterval(() => void iam.kms.maintain(), 60 * 60 * 1000);Errors
| Code | Status | When |
|---|---|---|
KEY_STATE_INVALID | 409 | The key is disabled or pending deletion, or the state change does not apply |
KEY_MANAGED | 409 | The key belongs to a certificate authority or a data protection profile |
INVALID_CIPHERTEXT | 400 | Malformed or tampered ciphertext, wrong encryption context, or a different key |
KEY_MATERIAL_UNAVAILABLE | 500 | The deployment secret that sealed the key is no longer configured |
NOT_FOUND | 404 | Unknown key, alias, version or grant in this tenant |
CONFLICT | 409 | The alias is already in use |
LIMIT_EXCEEDED | 409 | Too many keys, versions, aliases or grants |
RATE_LIMITED | 429 | More than ten on-demand rotations of one key in a day |
ACCESS_DENIED | 403 | No policy or grant allows the call, or a grant would exceed its creator's rights |
Next steps
Every method with its permission, audit metadata and errors.
Data protectionTokenize card numbers and personal data under a tenant key.
Secrets vaultStore and rotate secrets, optionally encrypted under your own KMS key.
Policy conditionsOperators such as StringEquals and ArrayContains for resource attributes.
Better IAM is created by Sean Filimon
Last updated
Secrets and keysnew
Tenant keys, a secrets vault, tokenization of sensitive data, and a private certificate authority, all decided by the same policies and audit trail.
Secrets vaultnew
Versioned secrets with scheduled rotation, check-outs of shared credentials, dynamic per-caller credentials, and values under your own KMS keys.