Security & Encryption
Botmem encrypts sensitive data at rest and uses JWT tokens for API authentication. This page documents the security architecture.
Credential Encryption
Connector credentials (OAuth tokens, API keys) are encrypted at rest using AES-256-GCM.
How It Works
- Signup — a random 32-byte recovery key is generated and shown to the user once (base64-encoded)
- Storage — only the SHA-256 hash of the recovery key is stored in the database (
users.recovery_key_hash) - Caching — the recovery key is cached in memory and in Redis (encrypted with
APP_SECRET, 30-day TTL) for convenience - Encryption — when storing connector credentials, the recovery key is used as the AES-256-GCM encryption key
- Decryption — when reading credentials, the cached recovery key decrypts them
Recovery Key Flow
User signs up
→ Server generates 32-byte random key
→ SHA-256(key) stored in users table
→ Key cached in memory + Redis (encrypted)
→ Key returned to user (shown once)
User connects a service (e.g., Gmail OAuth)
→ OAuth tokens encrypted with recovery key
→ Encrypted blob stored in accounts table
Server restart (cache cold)
→ User submits recovery key via POST /api/user-auth/recovery-key
→ Server verifies SHA-256 hash matches
→ Key re-cached in memory + Redis
→ Credentials can be decrypted againWhy Not Password-Based Encryption?
Previous versions derived the encryption key from the user's password. This meant password changes or resets would invalidate all encrypted data. The recovery key is independent of the password — you can change your password without affecting encryption.
JWT Authentication
Token Types
| Token | Purpose | Lifetime | Storage |
|---|---|---|---|
| Access token | API authentication | 15 minutes | Client memory / Authorization header |
| Refresh token | Obtain new access tokens | 7 days | HTTP-only cookie |
API key (bm_sk_...) | Programmatic access | No expiry | Client-managed |
Token Flow
Login → access token (15m) + refresh cookie (7d)
→ Access token in Authorization: Bearer header
→ When expired: POST /user-auth/refresh with cookie
→ New access token returnedSecrets
Four secrets are used for different purposes:
| Variable | Purpose |
|---|---|
APP_SECRET | Encrypting recovery keys in Redis cache |
JWT_ACCESS_SECRET | Signing access tokens |
JWT_REFRESH_SECRET | Signing refresh tokens |
OAUTH_JWT_SECRET | Signing OAuth state parameters |
All must be changed from their default values in production. The server validates this on startup.
CORS
CORS is configured via the FRONTEND_URL environment variable. Only the specified origin is allowed to make cross-origin requests. In development, this defaults to http://localhost:12412.
Generating Secrets
All four secrets should be cryptographically random. Use openssl to generate them:
# Generate a 32-byte base64 secret (suitable for all four variables)
openssl rand -base64 32Set each secret independently — do not reuse the same value across APP_SECRET, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and OAUTH_JWT_SECRET.
Rate Limiting
The API uses @nestjs/throttler for rate limiting. Default configuration applies globally to all endpoints. Adjust thresholds via environment variables or the settings API for your deployment's needs.
PII Handling
What gets encrypted at rest (AES-256-GCM with the user's recovery key):
- Encrypted: connector OAuth tokens, API keys, refresh tokens, and any credentials stored in the
accounts.authContextandconnectorCredentialscolumns - Not encrypted: memory text, entity extractions, embeddings, contact display names, and search index data in PostgreSQL search index
The recovery key itself is never stored in plaintext — only its SHA-256 hash is persisted. The key is cached in-memory and in Redis (encrypted with APP_SECRET) for active sessions.
Production Security Checklist
- [ ] All four secrets changed from defaults (
APP_SECRET,JWT_ACCESS_SECRET,JWT_REFRESH_SECRET,OAUTH_JWT_SECRET) - [ ]
NODE_ENV=productionset - [ ] HTTPS enabled (via Caddy or another reverse proxy)
- [ ]
FRONTEND_URLset to your production domain - [ ] PostgreSQL password is strong and not the default
- [ ] Redis is not exposed to the public internet
- [ ] PostgreSQL search index is not exposed to the public internet
- [ ] Backups configured for PostgreSQL and Redis