Secrets and keys
What the deployment secret protects, how previousSecrets and rotate-secrets rotate it without signing anyone out, and how assertion and protocol keys fit in.
Every deployment has one secret. It is the root of the encryption that protects stored factors and queued
messages, so it has to stay stable for the life of the deployment, and when it must change, it changes in stages
rather than all at once. This page explains what the secret covers, how to rotate it, and how the other keys
(, OAuth, , session JWT) relate to it.
What the secret protects
- Encryption: authenticator (TOTP) secrets, signing secrets, and undelivered email, SMS, and webhook payloads in the , including queued invitation emails.
- Challenge digests: password reset, email verification and change, passwordless links and codes, MFA sign-in, phone verification, and passkey ceremonies.
- Assertions: the key that signs stateless assertions is derived from it.
The secret must have at least 32 characters. Generate it from a cryptographic source and store it in your secret manager:
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"doctor warns with weak-secret when the value looks like a placeholder (for example it contains "change me",
"replace me", "example", or "placeholder") or has little variety.
Never replace the secret outright
Replacing the secret in one step makes every enrolled authenticator and every webhook unusable, and strands
queued messages. Always rotate through previousSecrets.
Rotating the deployment secret
Rotation runs in four stages. Deploy each stage to every instance, worker, and CLI configuration before the next one begins.
Introduce
Keep the old secret and add the new value to previousSecrets (at most five). Every process can now open
values sealed with either, while still sealing with the old one. Give downstream verifiers iam.assertionKeys(),
which lists both assertion keys.
Switch
Make the new value secret and move the old one to previousSecrets. New values are sealed with the new secret,
and old values and pending links keep working.
Re-seal
Run better-iam rotate-secrets (iam.rotateSecrets()). It re-seals stored authenticator secrets, webhook
secrets, and pending payloads with the new secret in short transactions and prints what it changed.
--dry-run only counts. Repeat until it reports done: true.
better-iam rotate-secrets --config better-iam.config.mjs --dry-run
better-iam rotate-secrets --config better-iam.config.mjsRetire
A day later, when emailed links and assertions issued under the old secret have expired, remove
previousSecrets everywhere and configure downstream verifiers with iam.assertionKey() alone. If the old secret
leaked, retire it as soon as rotate-secrets reports done: true, and never hand its assertion key to new
verifiers.
previousSecrets must list distinct values of at least 32 characters, none equal to secret. Configurations
created by better-iam init read it from BETTER_IAM_PREVIOUS_SECRETS (comma-separated), as the console does.
What rotate-secrets reports
Prop
Type
iam.rotateSecrets() also accepts batchSize (records per transaction, default 200, 1 to 2000) and limit (a
per-collection sample, which doctor uses).
Tracking a rotation with doctor
| Finding | Severity | Meaning |
|---|---|---|
secret-rotation-pending | warning | Stored values still need a previous secret. Run rotate-secrets. |
secret-rotation-unverified | warning | The sample found nothing left, but did not cover every record. Confirm with rotate-secrets --dry-run. |
previous-secrets-configured | info | previousSecrets is set and no stored value needs it any more. It can go. |
unreadable-secrets | error | Stored values open with no configured secret. |
unreadable-secrets is what happens when the secret was replaced without previousSecrets. The fix is to put the
old secret back into previousSecrets and rotate. rotate-secrets exits non-zero with UNREADABLE_SECRETS in the
same case.
Keys your application derives from the secret
Keys an application derives from secret itself need the same staged treatment. The console, for example,
derives its outbound SCIM provisioner's encryptionKey from it. Pass the keys derived from previousSecrets as
the provisioner's previousEncryptionKeys, then call provisioner.rotateKeys(), which re-seals stored downstream
tokens and reports { resealed, current, unreadable }.
Assertion keys
Services that verify stateless assertions hold a key derived from the secret, never the secret itself.
iam.assertionKey()returns the derived key (64 hexadecimal characters). A service holding only that key verifies withverifyAssertionand cannot recover the secret. Treat it as a shared secret between IAM and its services; it is never exposed over HTTP.iam.assertionKeys()returns the current key first, then those ofpreviousSecrets.verifyAssertion, the NestJS assertion module, and the Next.js edge verifier all accept the list, so hand it out during a rotation and go back to the single key afterwards.
import { verifyAssertion } from 'better-iam';
// IAM_ASSERTION_KEYS holds JSON.stringify(iam.assertionKeys()) while the secret rotates.
const claims = verifyAssertion(token, {
key: JSON.parse(process.env.IAM_ASSERTION_KEYS!) as string[],
audience: 'reports',
});Assertions cannot be revoked before they expire (at most one hour, five minutes by default), which is why the retire stage waits for them.
Other keys
These are separate, explicit inputs. Keep each stable, secret, backed up, and separate by purpose; losing an encryption key makes what it sealed unreadable.
| Key | Where | Rotation |
|---|---|---|
| OAuth provider signing JWKs | createOAuthProvider({ jwks }) | Roll over with a JWKS containing the active private key and the still-valid verification keys. |
| OAuth cookie keys | createOAuthProvider({ cookieKeys }) | Persistent; never regenerated at startup. |
| OAuth encryption key (32 bytes) | createOAuthProvider({ encryptionKey }) | Changing it requires re-encrypting stored protocol artifacts; automatic key migration is not provided. |
| SAML SP keys and certificates | createSamlService connections or serviceProvider | Supplied only through trusted deployment configuration. Roll IdP certificates by listing the previous and new ones in idpCertificates. |
| Session JWT keys | sts.jwt.signingKeys, verificationKeys | Move a retired key to verificationKeys (public only), where it verifies but never signs. |
Keep the OAuth keys identical across replicas and process restarts. No production key is ever generated inside a request handler. See Protocol mounts for where each one is configured.
Next steps
Better IAM is created by Sean Filimon
Last updated
Database operations
Migrations, transactions, durability, indexes, upgrades, backups, and PostgreSQL integration checks for the database behind Better IAM.
Protocol mounts
Mount the IAM HTTP handler and the OAuth, SAML, and SCIM protocol services in a host application, and what the handler enforces on every request.