Credential Encryption and Secret Management#
Overview#
Dify encrypts all provider and tool credentials at rest using envelope encryption scoped per tenant. A random AES-128-EAX key encrypts the credential; the tenant's RSA key wraps the AES key. Ciphertext starts with the HYBRID: prefix to distinguish it from legacy pure-RSA payloads. The key provider is pluggable via KEY_PROVIDER_TYPE.
Architecture#
Four layers compose the encryption stack:
| Layer | File | Role |
|---|---|---|
| Key provider interface | api/libs/key_providers/base.py | BaseKeyProvider abstract class (generate_key_pair, encrypt, get_decrypt_decoding, decrypt_with_decoding) |
| Key provider manager | api/extensions/ext_key_provider.py | KeyProviderManager singleton; routes to configured provider via KEY_PROVIDER_TYPE |
| Token helpers | api/core/helper/encrypter.py | encrypt_token / decrypt_token / batch_decrypt_token; base64-encodes ciphertext; delegates to manager |
| Schema-aware layer | api/core/helper/provider_encryption.py | ProviderConfigEncrypter; only encrypts fields declared ProviderConfigType.SECRET_INPUT |
Application code should only call encrypt_token / decrypt_token in encrypter.py, never api/libs/rsa.py directly β an .importlinter rule enforces this contract .
Key Providers#
local (default)#
A 2048-bit RSA key pair is generated per tenant at workspace creation. The public key is stored in Tenant.encrypt_public_key. The private key is written to privkeys/{tenant_id}/private.pem via the STORAGE_TYPE backend (local FS, S3, GCS, Azure Blob, etc.) and cached in Redis for 120 seconds .
Encrypt path : generates 16-byte AES key β AES-EAX-encrypts plaintext β RSA-OAEP-wraps AES key β concatenates HYBRID: + enc_aes_key | nonce | tag | ciphertext.
Decrypt path : strips prefix β RSA-OAEP-unwraps AES key β AES-EAX-decrypts. Falls back to pure-RSA for ciphertexts without HYBRID:.
Implementation: api/libs/rsa.py, wrapped by RSAKeyProvider.
azure-keyvault#
RSA key pair (default 2048-bit) is created in Azure Key Vault, named dify-tenant-{tenant_id}. The key name is stored in Tenant.encrypt_public_key; the private key never leaves the vault. Uses Azure Key Vault's wrap_key / unwrap_key APIs (RSA-OAEP-256). The ciphertext envelope includes metadata JSON with key_version and wrap_alg to support key rotation. Authentication uses DefaultAzureCredential.
| Config Variable | Required | Notes |
|---|---|---|
AZURE_KEYVAULT_VAULT_URL | Yes | e.g. https://<vault>.vault.azure.net |
AZURE_KEYVAULT_KEY_SIZE | No | Default: 2048 |
AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS | No | Min 7 days; old versions stay usable for legacy ciphertext |
Implementation: api/libs/key_providers/azure_keyvault_key_provider.py.
Schema-Aware Encryption (ProviderConfigEncrypter)#
ProviderConfigEncrypter selectively encrypts/decrypts only fields declared as SECRET_INPUT in the provider's credential schema .
encrypt(data)β deep-copies the credential dict; encrypts eachSECRET_INPUTfield in-place .decrypt(data)β checks the Redis cache first; on miss, decrypts allSECRET_INPUTfields and writes back to cache .mask_credentials(data)β replaces decrypted secrets with partial masks (first2 + *** + last2) before returning to API callers .
The factory create_provider_encrypter(tenant_id, config, cache) returns a (ProviderConfigEncrypter, ProviderConfigCache) tuple. Use NoOpProviderCredentialCache during initial creation before a row ID exists.
Batch decryption uses batch_decrypt_token to load the private key once and reuse it across multiple tokens .
Workflow-Level Credential Handling#
- Per-node credential override (PR #28850, PR #37078): LLM nodes in workflows can carry their own API key instead of inheriting the global tenant credential.
- Secret-type environment variables (PR #37048): Workflow execution redacts secrets from node outputs, logs, and traces via substring matching (minimum 8 characters). DSL export containing secrets is restricted to workspace owners/admins only. Secret variables are also filtered from Answer/End node UIs in the builder.
Known Credential Leakage Issues#
| Issue | Description | Root Pattern |
|---|---|---|
| #39888 β API key in logs/response | ExternalDatasetService.check_endpoint_and_api_key string-interpolates raw api_key into a ValueError on 403 responses, which surfaces in logger.exception() and the HTTP response body. | Decrypted secret passed to logging/error call without masking |
| #37807 β OAuth CLI secrets to stdout | setup-system-tool-oauth-client and related commands in api/commands/plugin.py click.echo the full SECRET_KEY and raw client_secret (CWE-532). Appears in shell history and CI logs. | Decrypted secret passed to logging/error call without masking |
External KMS / Secret Manager Support#
The BaseKeyProvider abstraction is the extension point. Implementations currently ship for local and azure-keyvault. To add a new provider (AWS KMS, HashiCorp Vault, Google Cloud KMS), implement the four abstract methods and register the type in KeyProviderManager.get_provider_factory() .
Community requests for moving ciphertext out of Dify's database entirely (AWS Secrets Manager, HashiCorp Vault as a secret store β not just a key store) remain open (Issue #32328). A more radical proposal (Issue #39278) eliminates secrets from the agent's process environment entirely using a Go-based outbound proxy that resolves placeholder env vars at the network boundary, domain-pins injection, and strips unresolved placeholders.