Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ Production Compose derives `DATABASE_URL` and `BETTER_AUTH_URL` on its service d
| `DATABASE_URL` | PostgreSQL connection string |
| `BETTER_AUTH_SECRET` | Auth secret, at least 32 characters: `openssl rand -hex 32`. **Must be identical on the app and realtime services** |
| `BETTER_AUTH_URL` | Your app URL — must be the real public origin, not `localhost` |
| `ENCRYPTION_KEY` | Encryption key, exactly 64 hex characters (32 bytes): `openssl rand -hex 32` |
| `ENCRYPTION_KEY` | Encryption key, exactly 64 hex characters (32 bytes): `openssl rand -hex 32`. A key in any other format leaves [account token sealing](/platform/self-hosting/security#account-token-sealing) off |
| `INTERNAL_API_SECRET` | Internal API secret, at least 32 characters: `openssl rand -hex 32` |
| `NEXT_PUBLIC_APP_URL` | Public app URL |

`openssl rand -hex 32` prints 64 hex characters. Encryption keys require that format. The app skips its env schema validation; individual consumers can reject invalid values when first used. Realtime validates `BETTER_AUTH_SECRET` and `INTERNAL_API_SECRET` at boot and rejects values shorter than 32 characters.

<Callout type="error">
Retain `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` across upgrades and restores. There is no supported in-place key rotation procedure; losing a key makes values encrypted with it unreadable — workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets in the first case, user-generated Sim API keys in the second. Back them up separately from the database.
Retain `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` across upgrades and restores. There is no supported in-place key rotation procedure; losing a key makes values encrypted with it unreadable — workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, connected-account OAuth tokens, and deployment/chat secrets in the first case, user-generated Sim API keys in the second. Back them up separately from the database.
</Callout>

## Feature prerequisites and coordination [#strongly-recommended]
Expand All @@ -39,6 +39,7 @@ Production Compose derives `DATABASE_URL` and `BETTER_AUTH_URL` on its service d
| `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` |
| `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Defaults to the public base URL. The Trigger.dev worker ignores it and uses the public app URL. |
| `SIM_MCP_URL` | Public URL of the [Sim MCP server](/mcp/overview) when you serve it on its own host, e.g. `https://mcp.example.com/mcp`. Point that host at the app; Sim serves only the MCP endpoint and its OAuth metadata there, and stops serving `/api/mcp` on the app host so clients use one URL. Optional — defaults to `<NEXT_PUBLIC_APP_URL>/api/mcp` |
| `ENCRYPT_ACCOUNT_TOKENS` | Set to `false` to stop sealing connected-account OAuth tokens on write and during `migrate`. Sealing is on by default with a 64-hex `ENCRYPTION_KEY`; see [Account token sealing](/platform/self-hosting/security#account-token-sealing) |
| `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset |

## AI providers
Expand Down
15 changes: 14 additions & 1 deletion apps/docs/content/docs/platform/self-hosting/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Five secrets drive the security of a deployment. Generate each with `openssl ran
| Secret | Protects | Rotatable |
|---|---|---|
| `BETTER_AUTH_SECRET` | Session tokens | Yes — invalidates all sessions |
| `ENCRYPTION_KEY` | Workspace env vars, stored provider keys, MCP OAuth credentials, deployment/chat secrets | **No** — see below |
| `ENCRYPTION_KEY` | Workspace env vars, stored provider keys, MCP OAuth credentials, connected-account OAuth tokens, deployment/chat secrets | **No** — see below |
| `API_ENCRYPTION_KEY` | Encrypted stored API-key material when configured | Rotation needs re-encryption; existing hash-based authentication continues, but old encrypted material cannot be decrypted with a new key |
| `INTERNAL_API_SECRET` | Service-to-service calls | Yes — roll app and realtime together |
| `CRON_SECRET` | Background job endpoints | Yes — roll app and cron together |
Expand All @@ -52,6 +52,19 @@ Five secrets drive the security of a deployment. Generate each with `openssl ran

`BETTER_AUTH_SECRET` must be **identical** on the app and realtime services — they share sessions through the database, and a mismatch means realtime rejects every authenticated socket.

### Account token sealing

Sim seals the OAuth access and refresh tokens of connected accounts at rest with `ENCRYPTION_KEY`. Each token is encrypted and bound to its column, so a value copied from the database is unusable without the key.

- **On by default** whenever `ENCRYPTION_KEY` is exactly 64 hex characters (`openssl rand -hex 32`). Each process logs `Account token sealing: enabled` with a key id at startup, never the key itself.
- **Without a usable key**, tokens are stored in plain text and the app keeps working. Each process logs a single warning at startup: `Account token sealing: disabled (ENCRYPTION_KEY is not a 64-character hex key)`. Other features that use `ENCRYPTION_KEY` keep their current behavior.
- **Tokens stored earlier** are sealed by the migrations container during `migrate`, in small batches. The step needs `ENCRYPTION_KEY` on the migrations container: the Helm chart and the Compose files pass the app's value. Until the key is usable, the step does nothing and stays pending, so it runs on a later `migrate` once sealing is on. It also waits for a later `migrate` when it can't finish because rows are busy, rather than failing the upgrade.
- **Opt out** with `ENCRYPT_ACCOUNT_TOKENS=false` on the app, background workers, and migrations container. New writes are then stored in plain text, and tokens that are already sealed stay readable.

<Callout type="error">
Once tokens are sealed, do not roll back to a release older than v0.9.33. Those releases cannot read sealed tokens, so every connected account would have to be reconnected. Opting out does not unseal stored tokens.
</Callout>

### Storing them

Choose how to supply secrets:
Expand Down
9 changes: 8 additions & 1 deletion apps/docs/content/docs/platform/self-hosting/upgrades.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ PROFILE=gpu # or cpu
docker compose -f docker-compose.ollama.yml --profile "$PROFILE" up -d --build
```

Never roll back below v0.9.33 once account tokens are sealed: those releases cannot read them, and every connected account would have to be reconnected. See [Account token sealing](/platform/self-hosting/security#account-token-sealing).

**Rollback after an incompatible schema change** requires restoring the pre-upgrade database backup. This loses writes made since the snapshot. Stop all writers before restoring:

1. Stop traffic and all processes that can write to the database, including the app, realtime service, scheduled jobs, and any background workers.
Expand Down Expand Up @@ -270,6 +272,10 @@ Migrations are Drizzle SQL files plus registered script (data) migrations, appli

Migrations are **forward-only**. Take the backup described in the upgrade procedure before applying them; there are no down-migrations.

<Callout type="warn">
Upgrading from a release older than v0.9.33 with a rolling deploy leaves old pods running next to new ones for a while. Old pods cannot read sealed account tokens, so integrations can fail on them until the rollout finishes. The `migrate` backfill waits an hour after such an upgrade before it seals stored tokens, but tokens the new pods write are sealed straight away. To avoid that window, upgrade to v0.9.33 first, stop the old pods before starting the new ones, or set `ENCRYPT_ACCOUNT_TOKENS=false` for the upgrade and remove it once every pod runs the new release.
</Callout>

Migrations apply in sequence, so skipping several releases at once generally works mechanically. It is riskier — those combinations get less testing, and you take several releases' worth of behavior changes in one step. Upgrade in smaller increments where you can.

### How concurrent replicas are handled
Expand All @@ -289,6 +295,7 @@ These control the migrator and are set on the `migrations` container, not the ap
| Variable | Purpose |
|---|---|
| `MIGRATION_DATABASE_URL` | A **direct, non-pooled** database connection URL (DSN). Falls back to `DATABASE_URL`. Setting it also arms the backend-pid check that aborts the run if the session — and with it the advisory lock — is silently recycled mid-migration; with only `DATABASE_URL` set, that check is skipped |
| `ENCRYPTION_KEY`, `ENCRYPT_ACCOUNT_TOKENS` | Let the migrator seal account tokens stored before [account token sealing](/platform/self-hosting/security#account-token-sealing) was on. The Helm init container reads both from the app Secret, and the Compose `migrations` services pass the `.env` values. Without a usable key the step stays pending and does nothing |

<Callout type="warn">
Set `MIGRATION_DATABASE_URL` if anything sits between Sim and Postgres. Session advisory locks and session-level `SET`s do not survive PgBouncer transaction pooling — the lock silently does not hold, and consecutive statements can land on different backends. Point it at the database directly, bypassing the pooler.
Expand All @@ -299,7 +306,7 @@ Supply the migration URL separately from the app URL:

| Deployment | How to pass `MIGRATION_DATABASE_URL` |
|---|---|
| Helm | The init container reads only the database Secret. Include the key in a Secret supplied through `postgresql.auth.existingSecret` or `externalDatabase.existingSecret`; `app.env` does not reach it. The chart-managed database Secret carries only the password, and there is no dedicated values key for the migration URL |
| Helm | The init container reads the database Secret, plus only `ENCRYPTION_KEY` and `ENCRYPT_ACCOUNT_TOKENS` from the app Secret. Include the key in a Secret supplied through `postgresql.auth.existingSecret` or `externalDatabase.existingSecret`; `app.env` does not reach it. The chart-managed database Secret carries only the password, and there is no dedicated values key for the migration URL |
| Compose | Add the variable to the `migrations` service's explicit `environment:` list. Setting it in `.env` alone does not pass it to that service |
| Separate migration run | Run the migration image yourself with a direct DSN before upgrading |

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# DAYTONA_SHELL_SNAPSHOT_ID= # Mothership shell snapshot ref, required when Mothership runs code on Daytona

# Security (Required)
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables and connected-account OAuth tokens (ENCRYPT_ACCOUNT_TOKENS=false opts the tokens out)
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes
API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys
CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authenticates the scheduler against the background job endpoints (scheduled workflows, polling triggers, connector syncs)
Expand Down
11 changes: 8 additions & 3 deletions apps/sim/background/seal-account-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ function emptyAccountTable() {
describe('runSealAccountTokens', () => {
beforeEach(() => {
vi.stubEnv('ENCRYPTION_KEY', ENCRYPTION_KEY)
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'true')
databaseMockFns.mockResolveDbUrl.mockReturnValue('postgresql://fixture/sim')
mockPostgres.mockReset()
mockPostgres.mockImplementation(emptyAccountTable)
Expand All @@ -48,12 +47,18 @@ describe('runSealAccountTokens', () => {
expect(mockPostgres).not.toHaveBeenCalled()
})

it('refuses to seal while this worker writes plaintext tokens, before connecting', async () => {
it('seals by default once the worker holds a usable key', async () => {
await expect(
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
).resolves.toEqual({ keyId: WORKER_KEY_ID, scanned: 0, pending: 0, updated: 0, foreignKey: 0 })
})

it('refuses to seal while this worker opts out of sealing, before connecting', async () => {
vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'false')

await expect(
runSealAccountTokens({ mode: 'seal', dryRun: false, expectedKeyId: WORKER_KEY_ID })
).rejects.toThrow('ENCRYPT_ACCOUNT_TOKENS is not enabled')
).rejects.toThrow('Refusing to seal: ENCRYPT_ACCOUNT_TOKENS is false')
expect(mockPostgres).not.toHaveBeenCalled()
})

Expand Down
10 changes: 6 additions & 4 deletions apps/sim/background/seal-account-tokens.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolveDbUrl } from '@sim/db'
import { sealAccountTokens } from '@sim/db/script-migrations/0037_seal_account_tokens'
import { isAccountTokenWriteSealingEnabled, readEncryptionKey } from '@sim/db/sealed-text'
import { accountTokenSealingDisabledReason, readEncryptionKey } from '@sim/db/sealed-text'
import { createLogger } from '@sim/logger'
import { sealingKeyId } from '@sim/security/encryption'
import { task } from '@trigger.dev/sdk'
Expand Down Expand Up @@ -31,7 +31,8 @@ function assertIntegerInRange(name: string, value: number | undefined, min: numb
* Runs the account-token sealing backfill (`0037_seal_account_tokens`) from a worker, which already
* holds the database and the fleet's `ENCRYPTION_KEY`. Refuses before connecting when the payload
* names a key id other than this worker's, so a pass can never seal rows under a key the fleet
* cannot open, and refuses `seal` while this worker's writers still store plaintext.
* cannot open, and refuses `seal` while this worker's writers still store plaintext (sealing opted
* out with `ENCRYPT_ACCOUNT_TOKENS=false`).
*
* Opens its own single connection rather than reusing the `@sim/db` pool: the pass binds JS arrays
* through the postgres.js tag, which needs the array type map the shared pool skips
Expand All @@ -50,9 +51,10 @@ export async function runSealAccountTokens(payload: SealAccountTokensPayload) {
if (expectedKeyId !== keyId) {
throw new Error(`ENCRYPTION_KEY has key id ${keyId}, not the expected ${expectedKeyId}`)
}
if (mode === 'seal' && !isAccountTokenWriteSealingEnabled()) {
const disabledReason = accountTokenSealingDisabledReason()
if (mode === 'seal' && disabledReason) {
throw new Error(
'Refusing to seal: ENCRYPT_ACCOUNT_TOKENS is not enabled on this worker, so writers still store plaintext'
`Refusing to seal: ${disabledReason} on this worker, so writers still store plaintext`
)
}

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const env = createEnv({
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Seal OAuth access/refresh tokens on write (read by @sim/db); takes effect only with a 64-hex ENCRYPTION_KEY. Enable only once every app, worker, and background-task deployment runs a release that reads sealed tokens
ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Opt-out: OAuth access/refresh tokens are sealed on write (read by @sim/db) and backfilled by migrate whenever ENCRYPTION_KEY is 64 hex characters; false or 0 writes plaintext. Sealed tokens need v0.9.33+ on every process that reads them
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
INTERNAL_JWT_SECRET: z.string().min(32).optional(), // Dedicated signing key for internal JWTs (falls back to INTERNAL_API_SECRET); separating limits blast radius if one leaks

Expand Down
17 changes: 15 additions & 2 deletions apps/sim/lib/oauth/credential-revocation.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ async function rawTokens() {
return row
}

/** Sealing is the default with the fixture's hex key; `false` is the operator opt-out. */
function sealWrites(enabled: boolean) {
process.env.ENCRYPT_ACCOUNT_TOKENS = enabled ? 'true' : 'false'
if (enabled) Reflect.deleteProperty(process.env, 'ENCRYPT_ACCOUNT_TOKENS')
else process.env.ENCRYPT_ACCOUNT_TOKENS = 'false'
}

async function resolveToken(rowId = accountId) {
Expand Down Expand Up @@ -490,7 +492,7 @@ describe('OAuth refresh chains with sealed account tokens', () => {
})
})

it('keeps writing readable tokens when sealing is requested without a usable key', async () => {
it('keeps writing readable tokens when ENCRYPTION_KEY cannot seal them', async () => {
sealWrites(true)
process.env.ENCRYPTION_KEY = 'not-hex'.repeat(6)
providerAnswer = REFRESHED
Expand All @@ -499,6 +501,17 @@ describe('OAuth refresh chains with sealed account tokens', () => {
expect(await rawTokens()).toMatchObject({ access_token: 'fresh-access' })
})

it('writes plaintext tokens when the operator opts out of sealing', async () => {
sealWrites(false)
providerAnswer = REFRESHED

await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true })
expect(await rawTokens()).toMatchObject({
access_token: 'fresh-access',
refresh_token: 'issued-refresh',
})
})

it('rotates a chain stored before sealing was enabled', async () => {
sealWrites(true)
providerAnswer = REFRESHED
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ services:
working_dir: /app/packages/db
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio}
# Seals account tokens stored before sealing was on; a no-op until ENCRYPTION_KEY is
# 64 hex characters. ENCRYPT_ACCOUNT_TOKENS=false opts out here and on the app.
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- ENCRYPT_ACCOUNT_TOKENS=${ENCRYPT_ACCOUNT_TOKENS:-}
depends_on:
db:
condition: service_healthy
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.ollama.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ services:
dockerfile: docker/db.Dockerfile
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio}
# Seals account tokens stored before sealing was on; a no-op until ENCRYPTION_KEY is
# 64 hex characters. ENCRYPT_ACCOUNT_TOKENS=false opts out here and on the app.
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- ENCRYPT_ACCOUNT_TOKENS=${ENCRYPT_ACCOUNT_TOKENS:-}
depends_on:
db:
condition: service_healthy
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ services:
working_dir: /app/packages/db
environment:
- 'DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?is required. For a new install set it in .env to the output of: openssl rand -hex 24. An install created before it was required uses the password postgres - set POSTGRES_PASSWORD=postgres to keep its data.}@db:5432/${POSTGRES_DB:-simstudio}'
# Seals account tokens stored before sealing was on; a no-op until ENCRYPTION_KEY is
# 64 hex characters. ENCRYPT_ACCOUNT_TOKENS=false opts out here and on the app.
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- ENCRYPT_ACCOUNT_TOKENS=${ENCRYPT_ACCOUNT_TOKENS:-}
depends_on:
db:
condition: service_healthy
Expand Down
2 changes: 1 addition & 1 deletion helm/sim/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: sim
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
type: application
version: 1.11.9
version: 1.11.10
appVersion: "v0.8.26"
kubeVersion: ">=1.25.0-0"
home: https://sim.ai
Expand Down
Loading
Loading