Encryption at Rest
AES-256-GCM protects Connection.clientSecret and Connection.idpCertificate at rest, under a key ring that supports rotation without downtime. Other credentials use hash-only storage.
How it works
RealmSSO's encrypt()/decrypt() helpers use AES-256-GCM from Node's built-in crypto module. Each call generates a fresh random IV and captures GCM's authentication tag alongside the ciphertext, so a tampered or truncated value fails to decrypt rather than silently decrypting to garbage.
Keys come from a key ring, not a single variable. Two environment variables define it:
| Variable | Format | Meaning |
|---|---|---|
ENCRYPTION_KEYS | <id>:<key>,<id>:<key>… | The ring. A key is the AES-256 key itself — exactly 64 hex characters (32 raw bytes); generate one with openssl rand -hex 32. An id is lowercase letters and digits, hyphens allowed after the first character, up to 32 characters. |
ENCRYPTION_CURRENT_KEY_ID | k1 | The id — never the key — of the entry new rows are encrypted under. Every other entry in the ring is still read. |
The id is written into every value the key encrypts, so a stored row says which key wrote it. Values are v2:<keyId>:iv:tag:ciphertext.
ENCRYPTION_KEY (singular) is retired
It was replaced by the two variables above, and the server refuses to boot if it is still set — it is not ignored. A set-but-ignored key is how an operator edits a variable during a rotation, gets a process that starts, and learns nothing rotated.
Its value is not a ring key either. A ring key is 32 raw bytes, and the scrypt derivation that turned the old variable into one is gone along with the static salt it used. To keep reading rows written under it, derive that key once off-box and add it to the ring under the key id those rows already carry.
Rotation
A ring of one is the steady state. A ring of two is a rotation in progress, and it happens with no downtime and no window in which a row is unreadable:
- Add the new entry to
ENCRYPTION_KEYSand restart. Both keys now decrypt. - Move
ENCRYPTION_CURRENT_KEY_IDto the new id and restart. New rows are written under it; existing rows still carry the old id and still decrypt. - Sweep the rows already at rest onto the new key. The script is dry-run by default:
npx tsx src/scripts/reencrypt-to-current-key.ts - Remove the old entry only once the dry-run table shows no row under its id and no row under
NULL.
That table is the retirement gate — a query over the *_key_id sibling columns — and there is no other. Removing a key that any row still names makes those rows unreadable, and the gate exists so that is a deliberate act rather than an accident.
Two transpositions to avoid
ENCRYPTION_CURRENT_KEY_ID takes the id (k1), never the key. An entry is <id>:<key> in that order, never <key>:<id>.
Boot refuses either mistake, but only after the value has been read — so the refusal tells you the length of what it would not print rather than printing a key into your logs.
What is actually encrypted
Two Connection fields go through the helper on write: the OIDC clientSecret a customer's identity provider issues, and the SAML IdP signing idpCertificate. Both the vendor connections API and the customer admin portal encrypt on store. On re-provision, production code decrypts the stored values before handing them to Keycloak — decrypt() is not test-only.
API responses redact both fields so ciphertext is not echoed back to clients.
| Field | Model | At rest today |
|---|---|---|
clientSecret | Connection | AES-256-GCM ciphertext (encrypted) |
idpCertificate | Connection | AES-256-GCM ciphertext (encrypted) |
bearerToken | ScimConfig | Plain-text column that nothing writes — left NULL deliberately |
bearerTokenHash | ScimConfig | SHA-256 hash (lookup key, not reversible) |
keyHash | ApiKey | SHA-256 hash — the raw key is never stored at all |
Legacy 3-part values are refused, not read
Values written before the key ring landed are the 3-part iv:tag:ciphertext form. They name no key, so no ring member can be selected for them, and decrypt() refuses them rather than guessing. Anything still in that form must be re-encrypted before it can be read again.
What is still not covered
There is one ring per deployment, not one key per account. Plan key custody accordingly: losing every entry in the ring makes stored secrets permanently unreadable, and each affected customer has to re-enter credentials.
ScimConfig.bearerToken is a legacy plain-text column that no code writes: when a SCIM token is set, only its SHA-256 bearerTokenHash is persisted, and that hash is what SCIM request authentication looks up — the same never-store-the-raw-value pattern ApiKey uses. Do not populate bearer_token by hand; nothing reads it, and writing it would put a live credential in plain text for no benefit.
Database-level protection (disk encryption, encrypted backups, network encryption to Postgres) is a separate concern from this application-level field encryption and is the operator's responsibility to configure — see Self-Hosting Guide. For the realm-isolation model that keeps one account's connections from being reachable from another's at all, see Security Overview.