Secret Management
The IDK provides a pluggable secret resolution system that keeps sensitive values out of your configuration files. Instead of embedding passwords, API keys, or tokens directly, you store secret references that are resolved at runtime through registered providers.
How It Works
The secret system is built around three concepts:
- SecretProvider: a component that knows how to fetch a secret from a specific backend (environment variables, files, an in-memory map, or cloud vaults provided by the EDK).
- SecretProviderRegistry: a registry that holds all available providers and routes resolution requests to the right one.
- Interpolation syntax: configuration values use
${secret:provider:path}placeholders that the property interpolator resolves automatically.
Secrets are never logged. The DefaultSecretRedactionPolicy automatically redacts any resolved value whose key matches common sensitive patterns (password, secret, token, key, credential, auth).
Secret Reference Syntax
Any configuration value can contain a secret placeholder:
${secret:<provider>:<path>}
${secret:<provider>:<path>:<key>}
| Segment | Description |
|---|---|
provider | The providerId of the registered SecretProvider (e.g. env, file, map) |
path | The secret path; interpretation depends on the provider |
key | (Optional) A specific key within a structured secret |
Secret placeholders can be mixed with regular property interpolation:
db.url=jdbc:postgresql://${db.host}:5432/${db.name}?password=${secret:env:DB_PASSWORD}
Built-in Providers
The IDK ships with three providers that are registered automatically in the SecretProviderRegistry.
Environment Variable Provider
Resolves secrets from environment variables. This is the default provider.
| Property | Value |
|---|---|
| Provider ID | env |
| Always available | Yes |
| Caching | None, reads the variable on every resolution |
Reference format:
# Read the DATABASE_PASSWORD environment variable
db.password=${secret:env:DATABASE_PASSWORD}
When a key segment is provided it is used as the variable name instead of path:
# Also reads DATABASE_PASSWORD
db.password=${secret:env:credentials:DATABASE_PASSWORD}
File Provider
Reads the contents of a file and returns the trimmed result. This is the standard approach for Kubernetes-mounted secrets.
| Property | Value |
|---|---|
| Provider ID | file |
| Always available | Yes |
| Caching | None, reads the file on every resolution |
Reference format:
# Read from a Kubernetes secret mount
db.password=${secret:file:/run/secrets/db-password}
# Read from an arbitrary path
api.key=${secret:file:/etc/myapp/api-key}
Map Provider
An in-memory map of secrets, primarily intended for testing and local development. Secrets are organised as a nested map: the outer key is the path, and the inner map holds key/value pairs. When no key is specified, the default key "value" is used.
| Property | Value |
|---|---|
| Provider ID | map |
| Always available | Yes |
| Caching | N/A, values live in memory |
Programmatic setup:
val secrets = mapOf(
"db-credentials" to mapOf(
"username" to "admin",
"password" to "s3cret"
),
"api-key" to mapOf(
"value" to "key-12345" // accessed when no key is specified
)
)
val provider = MapSecretProvider(secrets)
Reference format:
# Returns "s3cret" - extracts the "password" key from path "db-credentials"
db.password=${secret:map:db-credentials:password}
# Returns "key-12345" - uses default key "value"
api.key=${secret:map:api-key}
SecretProvider Interface
All providers implement the SecretProvider interface from the com.sphereon.core.api.conf package:
interface SecretProvider {
val providerId: String
val isAvailable: Boolean
suspend fun getSecret(
path: String,
key: String? = null,
scope: ConfigLevel = ConfigLevel.APP,
scopeIdentifier: String? = null,
options: SecretOptions = SecretOptions(),
resolver: PropertyResolver? = null
): IdkResult<SecretValue, SecretError>
suspend fun invalidateCache(path: String? = null)
suspend fun healthCheck(): IdkResult<ProviderHealth, SecretError>
}
Key parameters:
| Parameter | Purpose |
|---|---|
path | The secret path (environment variable name, file path, map key, vault path, etc.) |
key | Optional key within a structured secret |
scope | Configuration scope: APP, TENANT, or PRINCIPAL |
scopeIdentifier | Identifier for the scope (e.g. tenant ID) |
options | Resolution options such as timeout, bypassCache, and cacheOverride |
resolver | An optional PropertyResolver for reading provider-specific configuration at resolution time |
SecretValue
A successful resolution returns a SecretValue:
data class SecretValue(
val value: String,
val providerId: String,
val path: String,
val key: String?,
val resolvedAt: Instant,
val expiresAt: Instant?,
val version: String? = null
)
SecretOptions
Control resolution behaviour per call:
data class SecretOptions(
val timeout: Duration = 5.seconds,
val cacheOverride: Boolean = false,
val bypassCache: Boolean = false
)
SecretError
Errors carry a structured code for programmatic handling:
| Code | Meaning |
|---|---|
SECRET_NOT_FOUND | The path or key does not exist |
PROVIDER_UNAVAILABLE | The provider is not registered or not operational |
ACCESS_DENIED | Insufficient permissions |
TIMEOUT | Resolution exceeded the configured timeout |
SECRET_ERROR | General/unexpected error |
Registry and Resolution
SecretProviderRegistry
The registry is the central coordination point. It ships pre-loaded with the three built-in providers and defaults to the env provider when no provider is specified in a reference.
val registry = SecretProviderRegistry() // env, file, map registered by default
// Register an additional provider (e.g. from the EDK)
registry.register(myVaultProvider)
// Resolve directly
val result = registry.resolve(
providerId = "env",
path = "DB_PASSWORD",
key = null
)
Integration with Property Interpolation
In most applications you don't call the registry directly. Instead, wire it into the PropertyResolverFactory and let interpolation handle everything:
// Convert registry to a SecretResolver
val secretResolver = registry.toSecretResolver()
// Create a property resolver with secret support
val resolver = PropertyResolverFactory.withInterpolation(
propertySources = propertySources,
secretResolver = secretResolver
)
// Any property value containing ${secret:...} is resolved automatically
val password = resolver.getPropertyAsString("db.password")
A convenience factory is also available:
val secretResolver = createDefaultSecretResolver(
additionalSecrets = mapOf(
"test-db" to mapOf("password" to "test-pass")
)
)
Scope-Based Access Control
The property interpolator enforces scope boundaries when resolving secrets:
| Secret path prefix | Minimum required scope |
|---|---|
tenant/* or tenants/* | TENANT or higher |
principal/* or principals/* | PRINCIPAL or higher |
| Everything else | APP |
This prevents a tenant-scoped configuration from accessing app-level secrets and vice versa.
Additional Secret Providers
The EDK provides production-ready secret providers for AWS Secrets Manager, Azure Key Vault, and HashiCorp Vault with auto-registration, caching, and health checking.
Security Best Practices
Do not log secret values. The built-in DefaultSecretRedactionPolicy masks values automatically, but avoid passing resolved secrets to general-purpose logging:
// Don't do this
log.info("Password is $password")
// Do this instead
log.info("Resolved password from reference: $passwordRef")
Use the narrowest scope possible. Store tenant-specific secrets under a tenant/ prefix so they cannot leak across tenants:
tenant.api.key=${secret:vault:tenants/${tenant.id}/api-key}
Handle resolution failures gracefully. Never expose provider details in user-facing errors:
val password = registry.resolve("vault", "db-creds", "password")
.getOrElse { error ->
log.error("Secret resolution failed: ${error.code}")
throw ConfigurationException("Database credentials unavailable")
}