Skip to content
Secret Storage

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:

SecretPurpose
SECRET_KEYFlask session signing and CSRF token generation
DATABASE_URLPostgreSQL connection string
REDIS_URLRedis broker connection
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEYS3-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.

Always set a strong, unique 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 TypeStorage LevelDatabase Table
LLM / Embedding providersGlobal (multiple enabled; one is the default)core_plugin_configs
Channel plugins (Slack, Telegram, Postmark, etc.)Per assistantassistant_plugins
Tool plugins (WordPress, Unsplash, etc.)Per projectproject_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 restcore_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.

If you lose the encryption key, every stored secret becomes unreadable. Back up the key securely and separately from database backups. Restoring a database backup without the matching key is equivalent to losing all credentials.

What Is Encrypted

DataColumnEncryption Type
LLM provider config (API keys, OAuth client IDs/secrets)core_plugin_configs.config_jsonAES-256-GCM (JSON)
LLM OAuth access tokensllm_oauth_tokens.access_tokenAES-256-GCM (text)
LLM OAuth refresh tokensllm_oauth_tokens.refresh_tokenAES-256-GCM (text)
Channel plugin config (Slack/Telegram/Postmark tokens, Postmark inbound Basic Auth, etc.)assistant_plugins.config_jsonAES-256-GCM (JSON)
Tool plugin config (WordPress credentials, Umami API tokens, etc.)project_plugins.config_jsonAES-256-GCM (JSON)
MCP server environment variablesmcp_servers.env_jsonAES-256-GCM (JSON)
MCP server HTTP headersmcp_servers.headers_jsonAES-256-GCM (JSON)
MCP OAuth client secretsmcp_servers.oauth_client_secretAES-256-GCM (text)
MCP OAuth access tokensmcp_oauth_tokens.access_tokenAES-256-GCM (text)
MCP OAuth refresh tokensmcp_oauth_tokens.refresh_tokenAES-256-GCM (text)
Outbound platform-event webhook signing secretsystem_webhooks.secretAES-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:

FlagValuePurpose
SESSION_COOKIE_SECURETrueCookie is only sent over HTTPS
SESSION_COOKIE_HTTPONLYTrueCookie cannot be read by JavaScript (mitigates XSS)
SESSION_COOKIE_SAMESITELaxCookie 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:

ChannelVerification Method
SlackHMAC-SHA256 signature using the signing secret, verified against the X-Slack-Signature header with timestamp replay protection
TelegramSecret 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.
PostmarkHTTP 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_KEY and POSTGRES_PASSWORD in 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