Credential Encryption and Secret Management#
Dify encrypts all provider and tool credentials at rest using envelope encryption with one RSA key provisioned per tenant at workspace creation. The key provider is pluggable via the KEY_PROVIDER_TYPE configuration:
| Key Provider | Description |
|---|---|
local (default) | Hybrid RSA-2048 + AES-128-EAX. Private key stored in the STORAGE_TYPE backend (local filesystem, S3, Azure Blob, etc.). |
azure-keyvault | Hybrid RSA (2048β4096 bit) + AES-128-EAX, with the RSA key pair kept in Azure Key Vault. Private key never leaves the vault. |
The architecture is built on three layers:
| File | Role |
|---|---|
api/libs/key_providers/base.py | BaseKeyProvider abstract interface (generate_key_pair, encrypt, decrypt) |
api/extensions/ext_key_provider.py | KeyProviderManager singleton; routes to the configured provider |
api/core/helper/encrypter.py | encrypt_token / decrypt_token wrappers (base64-encodes ciphertext, delegates to the active key provider) |
api/core/helper/provider_encryption.py | Schema-aware ProviderConfigEncrypter that selects which credential fields to encrypt |
Configuration#
Set the key provider type via KEY_PROVIDER_TYPE (default: local). Both providers use envelope encryption (a random AES-128 key encrypts the credential; the tenant's RSA key wraps the AES key). The ciphertext format begins with HYBRID: to distinguish it from legacy pure-RSA payloads.
local provider#
A 2048-bit RSA key pair is generated during tenant creation (TenantService.create_tenant). The public key is stored in the Tenant.encrypt_public_key column in the database. The private key is written to privkeys/{tenant_id}/private.pem via the storage abstraction, which supports local filesystem, S3, Azure Blob, GCS, and a dozen other backends depending on STORAGE_TYPE.
- Encrypt: generates a random 16-byte AES key, AES-EAX-encrypts the plaintext, RSA-OAEP-encrypts the AES key with the tenant's public key, concatenates
HYBRID:+enc_aes_key | nonce | tag | ciphertext. - Decrypt: loads the private key PEM from object storage (cached in Redis for 120 seconds), RSA-OAEP-decrypts the AES key, AES-EAX-decrypts the payload. Falls back to legacy pure-RSA decryption for ciphertexts without the
HYBRID:prefix.
The implementation is in api/libs/rsa.py, wrapped by RSAKeyProvider.
azure-keyvault provider#
The 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.
- Encrypt: generates a random 16-byte AES key, AES-EAX-encrypts the plaintext, calls Azure Key Vault's
wrap_keyAPI (RSA-OAEP-256) to wrap the AES key, concatenatesHYBRID:+envelope_version | metadata_len | metadata | wrapped_key_len | wrapped_key | nonce | tag | ciphertext. The metadata JSON recordskey_versionandwrap_algto support key rotation. - Decrypt: parses the envelope, extracts
key_version, calls Azure Key Vault'sunwrap_keyAPI against that specific version, AES-EAX-decrypts the payload.
Authentication uses DefaultAzureCredential (tries managed identity, environment variables, Azure CLI login in sequence).
Configuration:
| Variable | Required | Description |
|---|---|---|
AZURE_KEYVAULT_VAULT_URL | Yes | URL of your Azure Key Vault instance (e.g., https://<vault-name>.vault.azure.net) |
AZURE_KEYVAULT_KEY_SIZE | No | RSA key size in bits (default: 2048) |
AZURE_KEYVAULT_ROTATION_INTERVAL_DAYS | No | Auto-rotate each tenant's key every N days (minimum: 7). Old versions remain usable forever; new credentials automatically pick up the current version. Leave empty to manage rotation manually in Azure. Do not configure an expiry or expires_in trigger in the Azure portal rotation policy β old key versions must stay active for decrypting old ciphertext. |
The implementation is in api/libs/key_providers/azure_keyvault_key_provider.py.
How It Works#
Application code calls encrypt_token or decrypt_token, which delegate to key_provider_manager.provider.encrypt() and .decrypt(). The manager routes to the active provider (RSAKeyProvider or AzureKeyVaultKeyProvider) based on KEY_PROVIDER_TYPE. All ciphertext is base64-encoded before being stored in the database. A batch_decrypt_token helper reuses a single decryption context across multiple tokens to avoid redundant key loads (for the local provider, this means fetching the private key PEM once from Redis/object storage; for azure-keyvault, it means resolving the current key version once and reusing the same CryptographyClient).
Provider Credential Encryption Layer#
ProviderConfigEncrypter is the schema-aware layer that sits above encrypter.py. It takes a list of BasicProviderConfig field descriptors and a ProviderConfigCache, and only processes fields declared as ProviderConfigType.SECRET_INPUT.
Key methods:
encrypt(data)β deep-copies the credential dict and encrypts eachSECRET_INPUTfield in-place. Non-secret fields are left as-is.decrypt(data)β checks the Redis cache first; on a miss, decrypts allSECRET_INPUTfields and writes the result back to cache.mask_credentials(data)β replaces decrypted secret values with partial masks (first 2 + stars + last 2 chars) before the credentials are serialized back to API callers.mask_plugin_credentialsdelegates to this same method .
The factory create_provider_encrypter(tenant_id, config, cache) returns a (ProviderConfigEncrypter, ProviderConfigCache) tuple. Callers pass a live ProviderCredentialsCache for existing rows or a NoOpProviderCredentialCache during initial creation when no row ID yet exists.
Known Credential Leakage Issues#
Several open issues document places where secrets escape the encrypted store:
#39888 β API key echoed into logs and HTTP response
ExternalDatasetService.check_endpoint_and_api_key raises a ValueError that string-interpolates the raw api_key on 403 responses. The exception is then logged via current_app.logger.exception() and forwarded to the HTTP response body. Fix: replace the f-string with a static message. PR #39902 was opened to address this.
#37807 β OAuth CLI commands log SECRET_KEY and client secrets to stdout
The setup-system-tool-oauth-client, setup-system-trigger-oauth-client, and setup-datasource-oauth-client commands in api/commands/plugin.py call click.echo with the full SECRET_KEY value and raw client_params JSON (a CWE-532 violation). These values appear in shell history and CI logs. Fix: replace with non-sensitive progress messages.
Both bugs share the same root pattern: a code path that decrypts or receives a secret, then passes it to a logging or error-reporting call without masking.
External KMS Support#
The BaseKeyProvider abstraction allows operators to configure external key management without patching code. Implementations exist for:
localβ per-tenant RSA key pair, private key stored in theSTORAGE_TYPEbackend (seeRSAKeyProvider).azure-keyvaultβ per-tenant RSA key kept in Azure Key Vault, private key never leaves the vault (seeAzureKeyVaultKeyProvider).
Additional providers (AWS KMS, HashiCorp Vault, Google Cloud KMS) can be added by implementing the four abstract methods in BaseKeyProvider (generate_key_pair, encrypt, get_decrypt_decoding, decrypt_with_decoding) and registering the new provider type in KeyProviderManager.get_provider_factory().
Related community discussions:
- Issue #32328 requests support for external secret managers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) for centralized storage and automatic rotation, without Dify maintaining ciphertext in its own database. The key provider abstraction addresses the first part (AWS/Azure/Vault key management for envelope encryption); the second part (moving ciphertext out of Dify's database entirely) remains an open feature request.
- Issue #39278 proposes eliminating secrets from the agent's process environment entirely, replacing them with placeholder environment variables resolved only at the network boundary by a Go-based credential-injection proxy (
dify-agent-runtime/internal/core/proxy/). The proxy validates allowlisted domains, injects the real secret into request headers, and strips unresolved placeholders β preventing both encoding-bypass and network-exfiltration attacks that pattern-based redaction cannot stop. PR #39483 was submitted with six passing conformance fixtures.