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:
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¶
- Generate the new value:
NEW=$(openssl rand -hex 32). - Update
.env/docker/.env. - Restart the API and WS services.
- 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.
- Add a new env var
ENCRYPTION_KEY_NEXTwith the new value. (You'll need to extend the encryption helper to try both keys for decryption.) - Run a one-shot script that decrypts every encrypted column with the old key and re-encrypts with the new one.
- Promote
ENCRYPTION_KEY_NEXT→ENCRYPTION_KEY, drop the old. - 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¶
- Update both API and WS env vars to the new value.
- 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:
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:
If you see a secret-looking value, it's a bug — stop and fix.