Encryption at Rest
AES-256-GCM protects Connection.clientSecret and Connection.idpCertificate at rest. Other credentials use hash-only storage; there is no key-rotation path yet.
How it works
RealmSSO's encrypt()/decrypt() helpers use AES-256-GCM from Node's built-in crypto module, keyed from the ENCRYPTION_KEY environment variable (required, minimum 32 characters). 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.
There is one encryption key for the whole deployment — no per-account key and no key-rotation support. The ciphertext envelope does now record which key encrypted a row: values are written as v2:<keyId>:iv:tag:ciphertext, where the key id is the first 12 hex characters of the SHA-256 of the derived key (values written before this landed are the 3-part iv:tag:ciphertext form and still decrypt). That makes the envelope self-describing, which is the prerequisite for rotation rather than rotation itself: decryption still holds exactly one key, so changing ENCRYPTION_KEY still makes every previously encrypted value undecryptable unless it is re-encrypted under the new key first — the difference is that you now get an error naming the missing key id instead of a bare authentication failure. Nothing in the product performs that re-encryption today.
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. A lost ENCRYPTION_KEY still makes stored secrets permanently unreadable; each affected customer has to re-enter credentials, because there is no rotation or bulk re-encrypt path.
| 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 |
What is still not covered
Key rotation is not implemented: there is a single global ENCRYPTION_KEY and a hardcoded salt. Plan key custody accordingly.
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.