Skip to content

Secrets & Encryption

Doable uses three required secrets and several optional integration secrets. This page explains what each does, where it's used, and how to rotate it.

The three required secrets

Secret Used for What rotation breaks
JWT_SECRET Signing access + refresh JWTs All active sessions log out
ENCRYPTION_KEY Encrypting OAuth tokens, integration credentials, custom secrets All stored OAuth/integration tokens become unreadable — users must reconnect
INTERNAL_SECRET API ↔ WS signed internal calls Live WebSocket connections drop until both sides restart

Generate each with:

openssl rand -hex 32

The Docker setup script (docker/setup.sh) and the bare-metal installer (setup-server.sh) generate them automatically. Don't reuse the same value across all three.

Where they're stored

Never in the database. Always in:

  • .env (bare-metal, gitignored)
  • docker/.env (Docker, gitignored)
  • The host's environment (e.g. systemd Environment= directives, Kubernetes Secrets)

Encryption details

Stored secrets (OAuth tokens, third-party API keys per workspace, custom env vars) are encrypted with ENCRYPTION_KEY using AES-256-GCM (authenticated encryption — tampering is detected).

Helpers: services/api/src/security/ and services/api/src/lib/.

Rotation playbook

Rotating JWT_SECRET

  1. Generate the new value: NEW=$(openssl rand -hex 32).
  2. Update .env / docker/.env.
  3. Restart the API and WS services.
  4. All users log out and back in.

If you want zero-downtime rotation, support both the old and new secret for a refresh window — patch services/api/src/middleware/ to accept either, deploy, then remove the old one after the refresh-token TTL.

Rotating ENCRYPTION_KEY

This is harder because existing data is encrypted with the old key.

  1. Add a new env var ENCRYPTION_KEY_NEXT with the new value. (You'll need to extend the encryption helper to try both keys for decryption.)
  2. Run a one-shot script that decrypts every encrypted column with the old key and re-encrypts with the new one.
  3. Promote ENCRYPTION_KEY_NEXTENCRYPTION_KEY, drop the old.
  4. Restart services.

Doable doesn't ship this script today — you'll need to write it once for your data shape if you ever need to rotate.

Rotating INTERNAL_SECRET

  1. Update both API and WS env vars to the new value.
  2. Restart both. WebSocket clients will drop and reconnect within a few seconds.

Optional secrets

Secret Provider Notes
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET Stripe Rotate from the Stripe dashboard.
GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_SECRET OAuth providers Rotate from each provider's console. Existing user accounts keep working.
S3_SECRET_KEY AWS / R2 / MinIO Standard IAM rotation.
ANTHROPIC_API_KEY, OPENAI_API_KEY AI providers Rotate freely; sessions in flight may fail until restart.

What's NOT a secret

  • JWT_ISSUER, CORS_ORIGINS, NEXT_PUBLIC_* URLs — public.
  • Database username, hostname, port — not sensitive on their own; the password is.
  • DATABASE_URL — contains the DB password, so treat the whole string as a secret.

Avoiding accidental commits

.env files are in .gitignore. To prevent commits anyway:

git update-index --assume-unchanged .env docker/.env

For CI: use the platform's secret store (GitHub Actions secrets, Vercel env vars, Fly secrets, etc.).

Checking what's leaked into the client bundle

Anything prefixed NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time. Never put a secret behind that prefix.

Audit before deploy:

grep -r "NEXT_PUBLIC_" apps/web/src

If you see a secret-looking value, it's a bug — stop and fix.

See also