FerrisKeyDocs

Secrets at rest

Some values in the FerrisKey database are usable by anyone who copies them: the private key that signs a realm’s tokens, the seeds behind users’ authenticator apps, and the password of the SMTP account that sends your mail. Set SECRETS_ENCRYPTION_KEYS and FerrisKey encrypts these values before it writes them, so a database dump or a stolen backup does not hand them over.

Leave the variable unset and they stay in clear. The API logs a warning at every start.

What is sealed

FerrisKey calls an encrypted value sealed. Only four columns are sealed today. Everything else keeps the format it always had, with or without a key.

SecretWhere it livesSealed
Realm signing private keysjwt_keys.private_keyYes
TOTP credentials (authenticator seeds)credentials.secret_data, for credentials of type otpYes
TOTP enrolments in progressotp_enrollments.secretYes
SMTP password of a realmsmtp_configs.passwordYes
Identity provider client secretsidentity_providers.config (JSON)No. Stored in clear, masked as *** when the API returns them
LDAP bind passworduser_federation_providers.config (JSON)No. Stored as given, masked in API responses
Client secretsclients.secretNo
Webhook signing secretswebhooks.secretNo

Masking only changes what the API shows. It does not change what is in the database, so anyone who can read the table can read an identity provider secret.

The LDAP bind password sits in a field named bind_password_encrypted, but nothing encrypts it: the LDAP client takes the stored value, decodes it as base64 if it can, and uses it as plain text otherwise. Treat it as a clear-text secret.

User passwords are a different case. They are stored as one-way hashes, not as secrets that can be read back, and the sealing sweep skips them.

Generate the keys

A key is 32 random bytes, written in base64. OpenSSL makes one:

openssl rand -base64 32

SECRETS_ENCRYPTION_KEYS holds one or more keys, each written id:base64 and separated by commas:

SECRETS_ENCRYPTION_KEYS=2026-10:<output of openssl rand -base64 32>

The id is a label you choose. It may contain letters, digits, - and _, and every id must be unique. FerrisKey writes it in front of each sealed value so it knows which key to open it with.

The server refuses to start if an entry has no :, if an id uses another character or appears twice, if the key is not valid base64, or if it does not decode to exactly 32 bytes.

Pass the variable through the environment. The --secrets-encryption-keys flag works too, but a flag shows up in the process list.

Docker Compose
services:
  api:
    environment:
      SECRETS_ENCRYPTION_KEYS: ${SECRETS_ENCRYPTION_KEYS}
Kubernetes Secret
apiVersion: v1
kind: Secret
metadata:
  name: ferriskey-secrets-encryption
type: Opaque
stringData:
  SECRETS_ENCRYPTION_KEYS: "2026-10:<output of openssl rand -base64 32>"
Helm values
api:
  env:
    - name: SECRETS_ENCRYPTION_KEYS
      valueFrom:
        secretKeyRef:
          name: ferriskey-secrets-encryption
          key: SECRETS_ENCRYPTION_KEYS

With Compose, the ${SECRETS_ENCRYPTION_KEYS} reference reads the value from your shell or from a .env file next to the Compose file. The Helm chart passes extra variables to the API container through api.env and api.envFrom; see the Helm chart page for the rest of the chart.

Keep the keys outside the database and back them up separately. A restored database is only readable with the keys that sealed it.

How a value is sealed

FerrisKey uses AES-256-GCM with a fresh random nonce for every value. A sealed value looks like this:

fk1:2026-10:<base64url of nonce, ciphertext and tag>

The column name and the row id are authenticated along with the data. A sealed value copied into another row or another column fails to open, and so does a value that was modified in place.

Turning it on for an existing deployment

Start the API with the variable set. Before it accepts requests, FerrisKey walks the four columns in batches of 200 and seals every value that is still in clear. New values are sealed as they are written from then on.

Two details about that sweep:

  • A value it cannot seal is logged at error level and skipped. The server still starts, and the next start tries that value again.
  • If the sweep fails on a database error, the server logs it and starts anyway. The unsealed values are retried at the next start.

Check the logs after the first start. A line sealed the stored secrets that were not under the active key reports how many values of each kind were sealed.

Rotating a key

The first key in the list seals, and every key in the list opens. To rotate, put the new key first and keep the old one after it:

SECRETS_ENCRYPTION_KEYS=2027-04:<new key>,2026-10:<old key>

At the next start FerrisKey re-seals every value that is not under the new first key. Once every replica has started on the new list, you can drop the old key.

With several replicas, do it in two rollouts. First append the new key at the end of the list and roll it out, so that every replica can open it. Then move it to the front and roll out again. If you put it first straight away, the first replica to start re-seals the values, and the replicas still running the old list cannot open them.

A key still in use cannot be dropped

Removing a key that still seals some values makes those values unreadable. The error reads the secret is sealed with key <id>, which is not in the key ring. Unsetting the variable altogether gives the secret is sealed but no encryption key is configured. Neither puts the values back in clear: signing a token with a sealed realm key, checking a TOTP code or sending mail with a sealed SMTP password fails until the key is back. Keep every old key in the list until a start with the new first key has re-sealed everything.