## 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>
58 lines
3.7 KiB
Plaintext
58 lines
3.7 KiB
Plaintext
---
|
|
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.
|