From 05f31c183799bf9a06f8cd35d4961396d2535290 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 19 May 2026 08:45:40 +0200 Subject: [PATCH] docs(self-host): document ENCRYPTION_KEY, FALLBACK_ENCRYPTION_KEY and key rotation procedures (#20611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Documents the new at-rest encryption envelope (`ENCRYPTION_KEY` / `FALLBACK_ENCRYPTION_KEY`) introduced in v2.5+ and clarifies its relationship to the legacy `APP_SECRET`-as-encryption-key path. - Adds a new dedicated **Key rotation** guide covering manual / Enterprise-cron JWT signing-key rotation, signing-key revocation, and the online `ENCRYPTION_KEY` rotation procedure (including the new \`secret-encryption:rotate\` CLI shipped in a follow-up PR). - Updates the docker-compose quickstart to generate a dedicated \`ENCRYPTION_KEY\` from day 1. - Mentions the v2.5+ enc:v2 backfill in the upgrade guide. English-only — the localized mirrors will be picked up by i18n CI. ## Test plan - [ ] Mintlify build passes locally / in CI - [ ] Sidebar entry renders under **Self-Host → Key rotation** - [ ] Internal links to /developers/self-host/capabilities/key-rotation resolve from setup.mdx, docker-compose.mdx and upgrade-guide.mdx --------- Co-authored-by: github-actions --- packages/twenty-docker/.env.example | 4 +- packages/twenty-docker/docker-compose.yml | 8 ++- packages/twenty-docker/scripts/install.sh | 2 +- .../self-host/capabilities/docker-compose.mdx | 9 ++- .../self-host/capabilities/key-rotation.mdx | 57 +++++++++++++++++++ .../self-host/capabilities/setup.mdx | 17 +++++- .../self-host/capabilities/upgrade-guide.mdx | 12 ++++ 7 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index 4ac34c337d..e0a4593cfe 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -9,7 +9,9 @@ TAG=latest SERVER_URL=http://localhost:3000 # Use openssl rand -base64 32 for each secret -# APP_SECRET=replace_me_with_a_random_string +# ENCRYPTION_KEY=replace_me_with_a_random_string +# FALLBACK_ENCRYPTION_KEY= # set to the previous ENCRYPTION_KEY during a rotation +# APP_SECRET= # legacy: only required for instances that pre-date ENCRYPTION_KEY STORAGE_TYPE=local diff --git a/packages/twenty-docker/docker-compose.yml b/packages/twenty-docker/docker-compose.yml index 627808948c..0b0d172d52 100644 --- a/packages/twenty-docker/docker-compose.yml +++ b/packages/twenty-docker/docker-compose.yml @@ -20,7 +20,9 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY} + APP_SECRET: ${APP_SECRET:-} # MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED} # CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED} # AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID} @@ -73,7 +75,9 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + FALLBACK_ENCRYPTION_KEY: ${FALLBACK_ENCRYPTION_KEY} + APP_SECRET: ${APP_SECRET:-} # MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED} # CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED} # AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID} diff --git a/packages/twenty-docker/scripts/install.sh b/packages/twenty-docker/scripts/install.sh index 2ae7bb4e12..58c376981b 100755 --- a/packages/twenty-docker/scripts/install.sh +++ b/packages/twenty-docker/scripts/install.sh @@ -91,7 +91,7 @@ fi # Generate random strings for secrets echo "# === Randomly generated secret ===" >> .env -echo "APP_SECRET=$(openssl rand -base64 32)" >> .env +echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env echo "" >> .env echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 32)" >> .env diff --git a/packages/twenty-docs/developers/self-host/capabilities/docker-compose.mdx b/packages/twenty-docs/developers/self-host/capabilities/docker-compose.mdx index ef70ed16a2..f44688ca51 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/docker-compose.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/docker-compose.mdx @@ -47,22 +47,25 @@ Follow these steps for a manual setup. curl -o .env https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-docker/.env.example ``` -2. **Generate Secret Tokens** +2. **Generate an Encryption Key** Run the following command to generate a unique random string: + ```bash openssl rand -base64 32 ``` - **Important:** Keep this value secret / do not share it. + **Important:** Keep this value secret / do not share it. Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database (OAuth tokens, application variables, TOTP secrets, etc.). 3. **Update the `.env`** Replace the placeholder value in your .env file with the generated token: ```ini - APP_SECRET=first_random_string + ENCRYPTION_KEY=random_string ``` + See the [Key rotation guide](/developers/self-host/capabilities/key-rotation) for instructions on rotating it without downtime. + 4. **Set the Postgres Password** Update the `PG_DATABASE_PASSWORD` value in the .env file with a strong password without special characters. diff --git a/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx b/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx new file mode 100644 index 0000000000..e5d164c6ff --- /dev/null +++ b/packages/twenty-docs/developers/self-host/capabilities/key-rotation.mdx @@ -0,0 +1,57 @@ +--- +title: Key rotation +icon: "rotate" +--- + +Twenty has two independent key families: + +- **JWT signing keys** — asymmetric ES256 keypairs (`kid`-tagged) stored in `core."signingKey"`, used to sign and verify access / refresh tokens. +- **At-rest encryption key** — `ENCRYPTION_KEY`, used to encrypt OAuth tokens, application variables, signing-key private keys, sensitive config values and TOTP secrets inside an `enc:v2:` envelope. + +`APP_SECRET` is a legacy secret kept for backward compatibility: when `ENCRYPTION_KEY` is unset it acts as the at-rest encryption / session cookie fallback, and it still verifies pre-existing HS256 access tokens. It will be deprecated. + +## JWT signing keys + +Each key carries a `publicKey` (kept indefinitely so it can verify previously issued tokens), an encrypted `privateKey` (used only while the key is current), an `isCurrent` flag (exactly one row at a time) and an optional `revokedAt`. + +### Rotate the current key + +- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire. +- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`. + + The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+. + +### Revoke a key (leak / emergency only) + +**Settings → Admin Panel → Signing keys → Revoke** on a non-current row. Wipes the encrypted private material, sets `revokedAt`, and rejects every existing token signed under that `kid`. + +## Rotate `ENCRYPTION_KEY` + +The `secret-encryption:rotate` command described below ships in v2.6+. + +Every encrypted value is wrapped as `enc:v2::`, where `` is an 8-hex prefix derived from the raw key. Rotation is online and resumable. + +1. **Generate a new key**: `openssl rand -base64 32`. +2. **Configure both keys side-by-side** in `.env`, then restart: + ```ini + ENCRYPTION_KEY=NEW_VALUE + FALLBACK_ENCRYPTION_KEY=OLD_VALUE + ``` + New writes use the new key, existing rows still decrypt via the fallback. +3. **Re-encrypt existing rows**: + ```bash + docker exec -it {server_container} yarn command:prod secret-encryption:rotate + ``` + The command walks six sites (`connected-account-tokens`, `application-variable`, `application-registration-variable`, `signing-key-private-keys`, `sensitive-config-storage`, `totp-secrets`). A SQL filter skips rows already on the new ``, so the command is idempotent: interrupt and re-run as needed. Exits non-zero if any row fails — re-run to retry. + + | Flag | Description | + | --- | --- | + | `-s, --site ` | Limit to a single site. | + | `-b, --batch-size ` | Rows per batch (default `200`, max `5000`). | + | `-d, --dry-run` | Decrypt + re-encrypt in memory, skip the `UPDATE`. | + +4. **Drop the fallback** once `--dry-run` shows zero remaining rows: remove `FALLBACK_ENCRYPTION_KEY` and restart. + +## Legacy `APP_SECRET` support + +Older instances that never set `ENCRYPTION_KEY` use `APP_SECRET` as the at-rest encryption key (and as the session-cookie secret, derived from it). This path is preserved for backward compatibility but is **deprecated** — set a dedicated `ENCRYPTION_KEY` and follow the rotation procedure above to migrate off it. `APP_SECRET` itself stays in use to verify legacy HS256 access tokens. diff --git a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx index b4de463802..c3f50e3818 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx @@ -42,11 +42,26 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # default Each variable is documented with descriptions in your admin panel at **Settings → Admin Panel → Configuration Variables**. -Some infrastructure settings like database connections (`PG_DATABASE_URL`), server URLs (`SERVER_URL`), and app secrets (`APP_SECRET`) can only be configured via `.env` file. +Some infrastructure settings like database connections (`PG_DATABASE_URL`), server URLs (`SERVER_URL`), and secrets (`ENCRYPTION_KEY`, `FALLBACK_ENCRYPTION_KEY`) can only be configured via `.env` file. [Complete technical reference →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts) +## Encryption keys + +Twenty uses two env-only encryption keys: + +| Variable | Purpose | Required | +| --- | --- | --- | +| `ENCRYPTION_KEY` | Primary key used to encrypt secrets at rest (OAuth tokens, application variables, signing-key private keys, TOTP secrets, sensitive config values). | Yes for new installs (legacy installs may instead rely on `APP_SECRET` — see below) | +| `FALLBACK_ENCRYPTION_KEY` | Verification-only key. Set during a rotation to the *previous* `ENCRYPTION_KEY` so existing rows remain decryptable. | Only during rotation | + +For backward compatibility, if `ENCRYPTION_KEY` is unset, Twenty falls back to `APP_SECRET` for at-rest encryption — matching the legacy behaviour for older deployments. New installs should always set a dedicated `ENCRYPTION_KEY`. + +Generate values with `openssl rand -base64 32` and store them somewhere safe (a secrets manager, sealed config, etc.). Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database. + +To rotate `ENCRYPTION_KEY` without downtime, see the [Key rotation guide](/developers/self-host/capabilities/key-rotation). + ## 2. Environment-Only Configuration ```bash diff --git a/packages/twenty-docs/developers/self-host/capabilities/upgrade-guide.mdx b/packages/twenty-docs/developers/self-host/capabilities/upgrade-guide.mdx index 32ca69dabc..065afc8729 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/upgrade-guide.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/upgrade-guide.mdx @@ -31,6 +31,18 @@ Starting from **v1.22**, Twenty supports cross-version upgrades. You can jump di For example, upgrading from v1.22 straight to v2.0 is fully supported. +## Upgrading to v2.5+ — at-rest encryption envelope + +Starting in **v2.5**, Twenty stores at-rest secrets (OAuth tokens, application variables, signing-key private keys, sensitive config values, TOTP secrets) inside a versioned `enc:v2:` envelope encrypted with `ENCRYPTION_KEY` (or `APP_SECRET` if `ENCRYPTION_KEY` is unset). + +The first boot on v2.5 runs slow upgrade commands that **backfill** existing rows into the new envelope. They are idempotent — interrupting and restarting the server resumes from where it left off — but they can take a while on large databases. You can monitor progress with `upgrade:status`. + +You should set a dedicated `ENCRYPTION_KEY` **before** the v2.5 upgrade so the backfill writes rows under it from the start. Switching keys after the backfill requires a [rotation](/developers/self-host/capabilities/key-rotation). + +## Rotating secrets and signing keys + +For day-to-day operational tasks like rotating `ENCRYPTION_KEY`, rotating the JWT signing key, or revoking a leaked signing key, see the dedicated [Key rotation guide](/developers/self-host/capabilities/key-rotation). + ## Checking upgrade status The `upgrade:status` command lets you inspect the current state of your instance and workspace migrations. It is useful for debugging upgrade issues or when filing a support request.