Encryption: at rest, in transit, backups and keys#
What Whoobe encrypts itself, what the infrastructure must encrypt, and how to answer an enterprise security questionnaire about it. Short version: Whoobe encrypts the secrets it stores in its database (field level), can connect to Postgres over TLS, and produces encrypted backups; encrypting the database files themselves is done by the disk/volume or the managed database service underneath, not by Whoobe.
The four levels#
| Level | Protects against | Who provides it | Status |
|---|---|---|---|
| 1. Disk / volume encryption (LUKS, cloud volume encryption) | Theft of a disk, a snapshot or decommissioned hardware | The infrastructure | You enable it; checklist below |
| 2. TLS in transit (app to Postgres, app to object storage, browser to proxy) | Sniffing on the network | Whoobe (configuration) + the proxy | DB_SSL (below); HTTPS at the proxy (enable-tls.sh) |
| 3. Database engine encryption (TDE) | Same as level 1, inside the engine | Not in community PostgreSQL. Managed services (RDS, Cloud SQL, Azure Database) encrypt the storage; Percona/EDB/Crunchy ship TDE | Out of Whoobe's scope |
| 4. Field-level encryption | A DBA, a leaked dump, a stolen backup | Whoobe | For secrets: done (set all the keys). For page/data content: not done (see "What is not encrypted") |
Most enterprise checklists ("database encryption at rest") are satisfied by level 1 or by the managed database's own encryption, plus level 2 and encrypted backups. Ask which of them the customer actually requires before building more.
What Whoobe encrypts in the database (field level)#
AES-256-GCM, one key per area, from the environment (never from the database). The value stored is the ciphertext; the screens never show a secret again after it is saved.
| Data | Variable | Notes |
|---|---|---|
| Two-factor (TOTP) secrets of users | TOTP_ENCRYPTION_KEY |
|
| Gateway endpoint credentials (auth of the external service) | GATEWAY_AUTH_ENCRYPTION_KEY |
|
| Credentials of migration sources | MIGRATION_SOURCE_ENCRYPTION_KEY |
|
| Outgoing mail: SMTP password and provider API keys | MAIL_SETTINGS_ENCRYPTION_KEY |
F-054; an old plaintext password is encrypted at startup |
| AI provider keys (text, image, translation), Figma token, push provider (OneSignal) key | INTEGRATION_SECRETS_ENCRYPTION_KEY |
F-055; values saved before the key was set are encrypted at the next start; without the key they stay in clear text and a warning is logged |
An installation that has not set INTEGRATION_SECRETS_ENCRYPTION_KEY keeps those credentials in clear text (finding F-055): set it
(provision-workspace.sh does for new workspaces) and restart; existing values are encrypted at the start.
User passwords are not encrypted but hashed with argon2id (one-way); password-reset tokens are stored as SHA-256 hashes. Session ids are stored as SHA-256 hashes of the cookie value (F-056), so a leaked copy of the database or of a backup does not allow session hijacking.
provision-workspace.sh generates all these keys per workspace into .env.<slug> (mode 600).
What is not encrypted#
Pages, snippets, data objects, form submissions, user profiles (name, e-mail), the audit and mail logs, and the files in object storage are stored as they are: the application has to search, filter and index them. Encrypting them at field level would break search, bindings and sorting, and is not planned. If a customer needs it for specific personal-data fields (for instance form submissions), the way is an opt-in per data class, with the limits above; ask before building. Until then, levels 1 to 3 are the answer.
Level 1: encrypt the disk or volume#
Do it where the data lives, not in Whoobe:
- Cloud VM (AWS, OCI, GCP, Azure): create the block volumes encrypted (provider-managed or customer-managed key) and
keep Docker's data root (
/var/lib/docker) and the backup directory on them. Snapshots of an encrypted volume are encrypted. - Own server / VM without cloud encryption: LUKS (
cryptsetup) on the partition that holds/var/lib/docker, or on a dedicated one mounted there. The key is released at boot (passphrase, TPM or a network key server such as Tang/Clevis). - Managed PostgreSQL (RDS, Cloud SQL, Azure, Aiven ...): "encryption at rest" is a switch at creation time; it cannot be
turned on later for an existing instance on some providers, so decide early. Point Whoobe at it with
DB_HOSTandDB_SSL. - Object storage: with a cloud S3, enable default bucket encryption (SSE-S3 or SSE-KMS). With Garage the data directory is on the encrypted volume (do not rely on server-side encryption features of these two without checking the version you run).
How to verify, for an audit: lsblk -f showing crypto_LUKS under the data path, or the provider's console/CLI showing the
volume as encrypted. Keep that evidence with the deployment record.
Level 2: TLS to Postgres#
Inside a workspace the database is only reachable on its private Docker network and the connection is plain: that is normal for a container next to its client. TLS is for a database on another host or a managed service.
Environment (any service that reads DB_*: core-api, asset-processor, the CLI tools):
| Variable | Meaning |
|---|---|
DB_SSL |
disable (default), require (encrypted; the server's certificate is not checked) or verify-full (encrypted and the certificate chain and host name are checked). verify-ca is accepted and treated as verify-full. |
DB_SSL_CA |
The CA that signed the server's certificate: a path to a PEM file or the PEM text. Needed for a private CA or a provider that publishes its own (RDS global-bundle.pem...); not needed when the certificate comes from a public CA. |
DATABASE_URL with ?sslmode=require (or verify-full) works too; DB_SSL wins if both are set. An unreadable
DB_SSL_CA or an unknown value stops the start: it never falls back to a plain connection. The startup log says
tls: off | encrypted, certificate not checked | verified.
Use verify-full for anything that crosses a network you do not control. require only defeats passive sniffing.
For drizzle-kit (migrations from a developer machine) put ?sslmode=require in DATABASE_URL; it does not read DB_SSL_CA.
On the server side (self-managed Postgres): ssl = on, ssl_cert_file, ssl_key_file and, to force it, a
hostssl line instead of host in pg_hba.conf. Verified here against PostgreSQL 16 with a private certificate:
plain, require and verify-full (with and without the CA) behave as described.
Other links: browser to proxy is HTTPS (enable-tls.sh, Let's Encrypt); proxy to the apps is on the host's loopback
or the private network; apps to object storage use the URL you give (https:// for any S3 outside the host).
Encrypted backups#
scripts/backup-workspace.sh never writes a clear-text backup: it refuses to run without a recipient.
# once, on the machine that will RESTORE (not on the server): age-keygen -o backup-key.txt (prints the public key)
export BACKUP_AGE_RECIPIENT=age1... # public key, or a file with one per line (several people can decrypt)
./scripts/backup-workspace.sh client-a --out-dir /var/backups/whoobe --keep 14
It writes, mode 600 in a mode 700 directory:
client-a-<UTC stamp>.sql.gz.age-pg_dump | gzip | age, nothing touches the disk in clear;client-a-<UTC stamp>.env.age- the workspace's.env.<slug>(its only copy of the credentials and of every encryption key; without it a restored database cannot decrypt its own secrets).--no-envskips it.
With GnuPG instead of age: BACKUP_GPG_RECIPIENT=<key id or e-mail> (public key in the keyring) gives .gpg files.
The decryption key (age identity, GPG secret key) must not live on the server: whoever takes the server's backups must not be able to read them. Keep it in a password manager/HSM/KMS and with a second person.
Check and restore:
AGE_IDENTITY=backup-key.txt ./scripts/restore-workspace-db.sh --check client-a-20261008T120000Z.sql.gz.age # decrypts and tests, restores nothing
AGE_IDENTITY=backup-key.txt ./scripts/restore-workspace-db.sh client-a client-a-20261008T120000Z.sql.gz.age # asks you to type the slug; drops and recreates the database
Test a restore on a scratch workspace before you need it (a backup nobody has restored is a hope, not a backup), and
schedule it: 0 3 * * * cd /opt/whoobe/whoobe_cms && BACKUP_AGE_RECIPIENT=age1... ./scripts/backup-workspace.sh client-a --out-dir /var/backups/whoobe --keep 14 in cron.
Copy the files off the host (object storage with versioning/object lock, another site): they are safe to store there
because they are encrypted. Retention and offsite copy are yours to set.
Assets in object storage are not in these files: back them up with the provider's tools (versioning/replication for a
cloud S3; rclone for Garage into an encrypted location). See creating workspaces.
Keys#
- Each key is 32 random bytes in hex:
openssl rand -hex 32. Never reuse one for two areas; never commit one. - Losing a key means losing the data it encrypts (mail credentials, TOTP secrets, gateway credentials): users must enrol two-factor
again and credentials must be typed again. Back up
.env.<slug>together with the database (the script does). - Rotation is not automated yet. Changing a key without re-encrypting makes the existing values unreadable. The current procedure is manual: decrypt with the old key and save again from the screens (mail, gateway endpoints), and have users re-enrol TOTP. A rotation command with key versions and a KMS/Vault source for the keys are the next steps if a customer needs them.
- The keys live in the environment of the containers (
.env.<slug>, mode 600). Anyone who can read it, or run commands in the container, has them. For stricter setups pass them from a secret manager into the environment at start.
For a security questionnaire#
| Question | Answer |
|---|---|
| Is data encrypted at rest? | Disk/volume or managed-database encryption (level 1/3) on the customer's or provider's infrastructure; secrets additionally AES-256-GCM at field level (gateway, mail, migration sources, MFA, AI/Figma/push keys; the last group needs INTEGRATION_SECRETS_ENCRYPTION_KEY). |
| Is it encrypted in transit? | HTTPS at the edge; TLS to Postgres with DB_SSL=verify-full; HTTPS to object storage and to the external providers (mail, gateway). |
| Are backups encrypted? | Yes: backup-workspace.sh (age or GPG), public-key encryption, the decryption key is not on the server. |
| Who holds the keys? | The operator: environment variables per workspace (field level), the volume/KMS key (level 1), the backup private key (offline). |
| Key rotation? | Manual today (see Keys). |
| Are passwords stored? | Only as argon2id hashes. |
Last updated