docs(self-host): document ENCRYPTION_KEY, FALLBACK_ENCRYPTION_KEY and key rotation procedures (#20611)
## 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 <github-actions@twenty.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
|
||||
|
||||
### 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`
|
||||
|
||||
<Note>The `secret-encryption:rotate` command described below ships in v2.6+.</Note>
|
||||
|
||||
Every encrypted value is wrapped as `enc:v2:<keyId>:<payload>`, where `<keyId>` 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 `<keyId>`, 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 <site>` | Limit to a single site. |
|
||||
| `-b, --batch-size <n>` | 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.
|
||||
@@ -42,11 +42,26 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # default
|
||||
|
||||
<Warning>
|
||||
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)
|
||||
</Warning>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user