Secret Storage
TeamWeb AI manages several types of sensitive credentials: LLM provider API keys, channel plugin tokens (Slack bot tokens, Telegram bot tokens), third-party service credentials (WordPress, Unsplash, analytics), and user passwords. Each is handled differently depending on where it’s configured and how it’s used.
Environment Variables
Core infrastructure secrets are stored as environment variables, loaded from a .env file or the deployment environment:
| Secret | Purpose |
|---|---|
SECRET_KEY | Flask session signing and CSRF token generation |
DATABASE_URL | PostgreSQL connection string |
REDIS_URL | Redis broker connection |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | S3-compatible media storage (optional) |
| SECRETS_ENCRYPTION_KEY | AES-256-GCM key for secret encryption — LLM provider API keys, OAuth refresh tokens, MCP credentials, channel plugin tokens, project plugin credentials, and system webhook signing secrets (base64-encoded 32 bytes). Required in every environment; the application refuses to start without it. |
These are never stored in the database and are only available to the web and worker processes.
SECRET_KEY in production. The default value in development is not secure. This key signs session cookies and CSRF tokens — if compromised, an attacker could forge sessions.Plugin Configuration Secrets
LLM provider API keys, channel tokens, and third-party service credentials are configured through the plugin system and stored in the database:
| Plugin Type | Storage Level | Database Table |
|---|---|---|
| LLM / Embedding providers | Global (multiple enabled; one is the default) | core_plugin_configs |
| Channel plugins (Slack, Telegram, Postmark, etc.) | Per assistant | assistant_plugins |
| Tool plugins (WordPress, Unsplash, etc.) | Per project | project_plugins |
Each plugin defines a configuration schema that can mark fields as secret. Secret fields receive special treatment in the UI:
- When displaying a configuration form, secret fields are shown as empty with a placeholder indicating a value is already stored
- On form submission, if a secret field is left empty and a value already exists, the existing value is preserved — preventing accidental overwrites
- Secret values are never rendered back into form fields or exposed in API responses
Encryption at rest — core_plugin_configs.config_json, assistant_plugins.config_json, and project_plugins.config_json all use the EncryptedJSON SQLAlchemy column type, so every secret field a plugin manifest declares (LLM API keys, channel bot tokens, Postmark inbound Basic Auth credentials, WordPress app passwords, third-party API tokens, etc.) is encrypted with AES-256-GCM before being written to the database. The same encryption applies to OAuth refresh tokens on the llm_oauth_tokens table (see LLM Providers for the OAuth flow).
Technical Details
Storage format — All plugin configuration (core_plugin_configs, assistant_plugins, project_plugins) is stored via EncryptedJSON, encrypted in place. Encrypted values are tagged with the prefix enc:v1: so migration code can detect already-encrypted rows and avoid double-encrypting on retry.
Masking implementation — When rendering a plugin configuration form, the system iterates over the schema and replaces any field with secret: true with an empty string in the display data. A separate has_secrets dictionary tracks which secret fields already have stored values, so the UI can show “Value stored” indicators without revealing the actual value. On POST, the save logic checks whether each secret field was left blank — if so, it keeps the existing value from the database rather than overwriting with an empty string.
Implications — Database access still needs to be protected (the encryption key is in the environment, not the DB — but someone who steals both the DB dump and the key can read everything). See Database Protection for how TeamWeb AI isolates the database from untrusted code execution.
Encrypted Secrets at Rest
A single AES-256-GCM key protects every “secret” field TeamWeb AI stores in the database:
- LLM provider API keys and OAuth client secrets on
core_plugin_configs - LLM provider OAuth access and refresh tokens on
llm_oauth_tokens - Channel plugin credentials on
assistant_plugins— Slack bot tokens and signing secrets, Telegram bot tokens and webhook secrets, Postmark server tokens, Postmark inbound Basic Auth credentials, etc. - Tool plugin credentials on
project_plugins— WordPress app passwords, Umami API tokens, Unsplash keys, etc. - MCP server env vars, HTTP headers, and OAuth client secrets on
mcp_servers - MCP OAuth access and refresh tokens on
mcp_oauth_tokens - HMAC signing secrets for outbound platform-event webhooks on
system_webhooks
Encryption Key
Set the SECRETS_ENCRYPTION_KEY environment variable to a base64-encoded 32-byte key:
# Generate a key
python -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"This key is required in every environment. The application — and any flask db ... command run against it — will refuse to start without it. There is no plaintext fallback.
What Is Encrypted
| Data | Column | Encryption Type |
|---|---|---|
| LLM provider config (API keys, OAuth client IDs/secrets) | core_plugin_configs.config_json | AES-256-GCM (JSON) |
| LLM OAuth access tokens | llm_oauth_tokens.access_token | AES-256-GCM (text) |
| LLM OAuth refresh tokens | llm_oauth_tokens.refresh_token | AES-256-GCM (text) |
| Channel plugin config (Slack/Telegram/Postmark tokens, Postmark inbound Basic Auth, etc.) | assistant_plugins.config_json | AES-256-GCM (JSON) |
| Tool plugin config (WordPress credentials, Umami API tokens, etc.) | project_plugins.config_json | AES-256-GCM (JSON) |
| MCP server environment variables | mcp_servers.env_json | AES-256-GCM (JSON) |
| MCP server HTTP headers | mcp_servers.headers_json | AES-256-GCM (JSON) |
| MCP OAuth client secrets | mcp_servers.oauth_client_secret | AES-256-GCM (text) |
| MCP OAuth access tokens | mcp_oauth_tokens.access_token | AES-256-GCM (text) |
| MCP OAuth refresh tokens | mcp_oauth_tokens.refresh_token | AES-256-GCM (text) |
| Outbound platform-event webhook signing secret | system_webhooks.secret | AES-256-GCM (text) |
Encryption and decryption are transparent to application code via custom SQLAlchemy column types (EncryptedJSON and EncryptedText). The encrypted format is enc:v1: + base64(nonce_12_bytes + ciphertext + tag_16_bytes). The magic prefix lets migration code detect already-encrypted values without access to the key.
User Passwords
User passwords are hashed using Werkzeug’s password hashing utilities before storage. Plaintext passwords are never stored or logged. The hashing uses a secure algorithm with salt — even with database access, recovering passwords requires a brute-force attack against the hash.
Session Security
TeamWeb AI uses Flask-Login for session management with server-side session state. Sessions are signed using the SECRET_KEY to prevent tampering. Public chat visitors receive a separate session with a unique identifier that is isolated from authenticated user sessions.
In production, session cookies are hardened with the following flags:
| Flag | Value | Purpose |
|---|---|---|
SESSION_COOKIE_SECURE | True | Cookie is only sent over HTTPS |
SESSION_COOKIE_HTTPONLY | True | Cookie cannot be read by JavaScript (mitigates XSS) |
SESSION_COOKIE_SAMESITE | Lax | Cookie is not sent on cross-site requests (mitigates CSRF) |
These flags are set automatically in the production configuration. In development mode they are not enforced, since local development typically uses plain HTTP.
Login Redirect Validation
When an unauthenticated user visits a protected page, they are redirected to the login form with a next parameter so they can be returned to the original page after login. This redirect target is validated to ensure it points to the same host — external URLs are rejected. This prevents open redirect attacks where a malicious link could send users to a phishing site after login.
CSRF Protection
All form submissions are protected by CSRF tokens via Flask-WTF. The CSRF middleware is applied globally, with targeted exemptions only for:
- Webhook endpoints — Inbound messages from Slack, Telegram, and Postmark are verified using channel-specific mechanisms (HMAC signatures, secret tokens) rather than CSRF tokens
- Public chat — Anonymous chat uses session-based identification instead of CSRF tokens
Webhook Verification
Each channel plugin implements its own request verification to ensure inbound webhooks are authentic:
| Channel | Verification Method |
|---|---|
| Slack | HMAC-SHA256 signature using the signing secret, verified against the X-Slack-Signature header with timestamp replay protection |
| Telegram | Secret token comparison via X-Telegram-Bot-Api-Secret-Token header, using hmac.compare_digest to avoid timing attacks. The bare per-instance URL is rejected at the route level — only the per-assistant URL /webhooks/channels/telegram/<assistant_id>/inbound is accepted. |
| Postmark | HTTP Basic Auth credentials (username:password) configured per assistant in the postmark plugin’s inbound_basic_auth field. The inbound webhook URL on Postmark’s side must include the credentials in https://user:pass@<host>/webhooks/postmark/inbound form. The handler verifies the Authorization: Basic … header with hmac.compare_digest; assistants without inbound_basic_auth configured reject all inbound mail with 401 (fail-closed). |
Unverified requests are rejected before any message processing occurs. After verification, the system also checks that the sender is a registered, active user — unknown senders receive a 403 response.
Best Practices
- Use strong, unique values for
SECRET_KEYandPOSTGRES_PASSWORDin production - Restrict database access to only the application services that need it
- Use a secrets manager or encrypted environment variable store in production deployments
- Rotate API keys periodically, especially if you suspect a credential leak
- Review the Database Protection and Code Sandboxing pages to understand how TeamWeb AI isolates untrusted code from your credentials