Skip to main content
Version: v0.25.0 (Latest)

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:

  1. 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).
  2. SecretProviderRegistry: a registry that holds all available providers and routes resolution requests to the right one.
  3. 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>}
SegmentDescription
providerThe providerId of the registered SecretProvider (e.g. env, file, map)
pathThe 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.

PropertyValue
Provider IDenv
Always availableYes
CachingNone, 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.

PropertyValue
Provider IDfile
Always availableYes
CachingNone, 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.

PropertyValue
Provider IDmap
Always availableYes
CachingN/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:

ParameterPurpose
pathThe secret path (environment variable name, file path, map key, vault path, etc.)
keyOptional key within a structured secret
scopeConfiguration scope: APP, TENANT, or PRINCIPAL
scopeIdentifierIdentifier for the scope (e.g. tenant ID)
optionsResolution options such as timeout, bypassCache, and cacheOverride
resolverAn 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:

CodeMeaning
SECRET_NOT_FOUNDThe path or key does not exist
PROVIDER_UNAVAILABLEThe provider is not registered or not operational
ACCESS_DENIEDInsufficient permissions
TIMEOUTResolution exceeded the configured timeout
SECRET_ERRORGeneral/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 prefixMinimum required scope
tenant/* or tenants/*TENANT or higher
principal/* or principals/*PRINCIPAL or higher
Everything elseAPP

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")
}