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.