Skip to content

Encrypted Config Property Delegate

Build a custom property delegate that reads configuration from environment variables (with a .env fallback), and for secret values transparently decrypts AES-256-GCM ciphertext on first access. The payoff: a property declared by encrypted("DATABASE_PASSWORD") reads exactly like a normal String, but the encrypted-at-rest plumbing is invisible at the call site.

This is the Kotlin answer to the pattern you’d hand-roll in TS with a getter or in Go with a Config.MustGet(key) helper — except the language itself gives you the delegation hook via getValue.

  1. A custom ReadOnlyProperty delegate for encrypted values.
  2. AES-256-GCM encryption using javax.crypto.
  3. Lazy decryption — values decrypted only on first read, then cached.
  4. Environment-variable loading with a .env file fallback.
  5. Type-conversion delegates for Int, Boolean (and Long).
  6. A configuration DSL using @DslMarker and the builder pattern.
  7. A small CLI to encrypt a value for storage.

The intended call sites look like this — a property delegated with by encrypted(...) and a builder-style DSL:

// Property delegation: reads like a plain String, decrypts under the hood
object AppConfig {
val databasePassword: String by encrypted("DATABASE_PASSWORD", encryptor)
val databaseHost: String by env("DATABASE_HOST", default = "localhost")
val serverPort: Int by envInt("SERVER_PORT", default = 8080)
}
// Or via the DSL
val config = secureConfig {
encryptionKey = System.getenv("CONFIG_ENCRYPTION_KEY") ?: "dev-key"
encrypted("database.password", envVar = "DATABASE_PASSWORD")
plain("database.host", envVar = "DATABASE_HOST", default = "localhost")
}
println(config["database.password"]) // decrypted value

A property delegate is an object that supplies the value for a property. When you write val x: String by something, the compiler rewrites every read of x into a call to something.getValue(thisRef, property). For a val, that’s all you need; a var would additionally call setValue(...). Implementing the kotlin.properties.ReadOnlyProperty<Any?, String> interface gives you exactly that getValue slot, and the property: KProperty<*> argument even hands you the property’s name — useful for deriving an env-var name when none is passed.

The closest TS/Go analogies:

ConcernTypeScriptGoKotlin
Lazy computed readget x() accessormethod Cfg.X()by delegate / by lazy
Reusable read logic across propsmixin / decoratorembedded structa ReadOnlyProperty class
Knowing the property’s own namen/a (manual)n/a (manual)property.name arg

A single Gradle module. The encryption lives in one file, the delegates in another, the DSL in a third, and a demo/CLI main ties it together.

  • Directoryencrypted-config-delegate/
    • build.gradle.kts JVM plugin, application, junit
    • settings.gradle.kts project name
    • Directorysrc/
      • Directorymain/kotlin/com/example/config/
        • Encryption.kt AES-256-GCM encrypt/decrypt
        • ConfigDelegates.kt the property delegates + env loader
        • SecureConfigDsl.kt @DslMarker builder
        • Main.kt demo + encrypt CLI
      • Directorytest/kotlin/com/example/config/
        • EncryptionTest.kt round-trip + tamper tests

The crypto primitive. AES-256-GCM is an authenticated cipher: it both encrypts and detects tampering, so a flipped byte or wrong key fails loudly instead of returning garbage. GCM needs a fresh random 12-byte IV (nonce) per encryption — we generate one, prepend it to the ciphertext, then Base64 the whole blob so it’s safe to store in an env var. A string key is hashed to exactly 32 bytes with SHA-256 to get the AES-256 key length.

src/main/kotlin/com/example/config/Encryption.kt
package com.example.config
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AES-256-GCM encryption utility.
*
* Encrypted format: Base64(IV + ciphertext + GCM tag)
* IV is 12 bytes (GCM recommended nonce size).
*/
class AesGcmEncryptor(key: ByteArray) {
companion object {
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12 // 96 bits
private const val GCM_TAG_LENGTH = 128 // bits
/**
* Create an encryptor from a string key.
* The key is hashed to exactly 32 bytes (AES-256).
*/
fun fromStringKey(key: String): AesGcmEncryptor {
val keyBytes = java.security.MessageDigest.getInstance("SHA-256")
.digest(key.toByteArray())
return AesGcmEncryptor(keyBytes)
}
}
private val secretKey: SecretKey = SecretKeySpec(key, ALGORITHM)
private val secureRandom = SecureRandom()
fun encrypt(plaintext: String): String {
val iv = ByteArray(GCM_IV_LENGTH)
secureRandom.nextBytes(iv)
val cipher = Cipher.getInstance(TRANSFORMATION)
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
// Prepend IV to ciphertext
val combined = ByteArray(iv.size + ciphertext.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
return Base64.getEncoder().encodeToString(combined)
}
fun decrypt(encrypted: String): String {
val combined = Base64.getDecoder().decode(encrypted)
// Extract IV from the beginning
val iv = combined.copyOfRange(0, GCM_IV_LENGTH)
val ciphertext = combined.copyOfRange(GCM_IV_LENGTH, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
val plaintext = cipher.doFinal(ciphertext)
return String(plaintext, Charsets.UTF_8)
}
}

This is the heart of the exercise. EnvLoader resolves a name from the real environment first, then a .env file (parsed lazily via by lazy). Then each delegate class implements ReadOnlyProperty<Any?, T> — supplying the getValue that the by keyword calls.

The interesting one is EncryptedDelegate: on first read it fetches the raw (encrypted) value, decrypts it, and caches the result in decryptedValue so subsequent reads are free. If decryption throws (e.g. the env var holds plaintext in local dev), it falls back to treating the raw value as plaintext rather than crashing.

Notice the EnvDelegate.getValue body uses property.name when no explicit env var is given — turning a property named serverPort into a lookup for SERVERPORT. That property: KProperty<*> argument is the delegation API handing you reflection-lite metadata about the property being read.

src/main/kotlin/com/example/config/ConfigDelegates.kt
package com.example.config
import java.io.File
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
// --- Environment Variable Loading ---
/**
* Loads environment variables with .env file fallback.
*/
object EnvLoader {
private val envFileValues: Map<String, String> by lazy {
loadEnvFile()
}
fun get(name: String): String? =
System.getenv(name) ?: envFileValues[name]
private fun loadEnvFile(): Map<String, String> {
val envFile = File(".env")
if (!envFile.exists()) return emptyMap()
return envFile.readLines()
.filter { it.isNotBlank() && !it.startsWith("#") }
.mapNotNull { line ->
val idx = line.indexOf('=')
if (idx > 0) {
val key = line.substring(0, idx).trim()
val value = line.substring(idx + 1).trim()
.removeSurrounding("\"")
.removeSurrounding("'")
key to value
} else null
}
.toMap()
}
}
// --- Plain Environment Variable Delegates ---
/**
* Property delegate that reads from an environment variable.
*/
class EnvDelegate(
private val envVar: String? = null,
private val default: String? = null,
private val required: Boolean = true
) : ReadOnlyProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
val name = envVar ?: property.name.uppercase().replace('.', '_')
return EnvLoader.get(name)
?: default
?: if (required) error("Required environment variable '$name' is not set")
else ""
}
}
/**
* Property delegate that reads an Int from an environment variable.
*/
class EnvIntDelegate(
private val envVar: String? = null,
private val default: Int? = null
) : ReadOnlyProperty<Any?, Int> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
val name = envVar ?: property.name.uppercase().replace('.', '_')
val raw = EnvLoader.get(name)
return raw?.toIntOrNull()
?: default
?: error("Required environment variable '$name' is not set or not a valid integer")
}
}
/**
* Property delegate that reads a Boolean from an environment variable.
*/
class EnvBoolDelegate(
private val envVar: String? = null,
private val default: Boolean = false
) : ReadOnlyProperty<Any?, Boolean> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Boolean {
val name = envVar ?: property.name.uppercase().replace('.', '_')
val raw = EnvLoader.get(name) ?: return default
return raw.lowercase() in listOf("true", "1", "yes", "on")
}
}
// --- Encrypted Property Delegate ---
/**
* Property delegate that reads an encrypted value from an environment variable,
* lazily decrypts it on first access, and caches the result.
*/
class EncryptedDelegate(
private val envVar: String,
private val encryptor: AesGcmEncryptor,
private val default: String? = null
) : ReadOnlyProperty<Any?, String> {
private var decryptedValue: String? = null
private var initialized = false
override fun getValue(thisRef: Any?, property: KProperty<*>): String {
if (!initialized) {
val encrypted = EnvLoader.get(envVar)
decryptedValue = when {
encrypted != null -> {
try {
encryptor.decrypt(encrypted)
} catch (e: Exception) {
// If decryption fails, treat raw value as plaintext (dev mode)
encrypted
}
}
default != null -> default
else -> error("Required encrypted variable '$envVar' is not set")
}
initialized = true
}
return decryptedValue!!
}
}
// --- Convenience Functions ---
fun env(name: String? = null, default: String? = null, required: Boolean = true) =
EnvDelegate(name, default, required)
fun envInt(name: String? = null, default: Int? = null) =
EnvIntDelegate(name, default)
fun envBool(name: String? = null, default: Boolean = false) =
EnvBoolDelegate(name, default)
fun encrypted(envVar: String, encryptor: AesGcmEncryptor, default: String? = null) =
EncryptedDelegate(envVar, encryptor, default)

The convenience functions at the bottom (env, envInt, encrypted, …) are what make the call site read nicely: by encrypted("DATABASE_PASSWORD", encryptor) returns an EncryptedDelegate, and the by keyword wires its getValue to the property.

The DSL is an alternative front end: instead of declaring each property with by, you describe the whole config in one secureConfig { ... } block. @DslMarker (here on @ConfigDsl) stops you from accidentally calling an outer builder’s methods inside a nested block — a Kotlin scoping safety feature. The builder collects encrypted(...) / plain(...) entries into a sealed ConfigEntry hierarchy, then build() constructs the AesGcmEncryptor and hands back a SecureConfig whose operator fun get resolves and caches each value on demand.

src/main/kotlin/com/example/config/SecureConfigDsl.kt
package com.example.config
// --- DSL for Secure Configuration ---
@DslMarker
annotation class ConfigDsl
@ConfigDsl
class SecureConfigBuilder {
var encryptionKey: String = ""
private val entries = mutableMapOf<String, ConfigEntry>()
fun encrypted(name: String, envVar: String, default: String? = null) {
entries[name] = ConfigEntry.Encrypted(envVar, default)
}
fun plain(name: String, envVar: String, default: String? = null) {
entries[name] = ConfigEntry.Plain(envVar, default)
}
internal fun build(): SecureConfig {
require(encryptionKey.isNotEmpty()) { "encryptionKey must be set" }
val encryptor = AesGcmEncryptor.fromStringKey(encryptionKey)
return SecureConfig(encryptor, entries.toMap())
}
sealed class ConfigEntry {
data class Encrypted(val envVar: String, val default: String?) : ConfigEntry()
data class Plain(val envVar: String, val default: String?) : ConfigEntry()
}
}
class SecureConfig internal constructor(
private val encryptor: AesGcmEncryptor,
private val entries: Map<String, SecureConfigBuilder.ConfigEntry>
) {
private val cache = mutableMapOf<String, String>()
operator fun get(name: String): String {
return cache.getOrPut(name) {
val entry = entries[name] ?: error("Config key '$name' not defined")
when (entry) {
is SecureConfigBuilder.ConfigEntry.Encrypted -> {
val raw = EnvLoader.get(entry.envVar)
when {
raw != null -> try {
encryptor.decrypt(raw)
} catch (_: Exception) {
raw // fallback to plaintext in dev
}
entry.default != null -> entry.default
else -> error("Required config '$name' (env: ${entry.envVar}) is not set")
}
}
is SecureConfigBuilder.ConfigEntry.Plain -> {
EnvLoader.get(entry.envVar)
?: entry.default
?: error("Required config '$name' (env: ${entry.envVar}) is not set")
}
}
}
}
fun getOrNull(name: String): String? {
return try { get(name) } catch (_: IllegalStateException) { null }
}
fun keys(): Set<String> = entries.keys
}
// --- Top-level DSL entry point ---
fun secureConfig(block: SecureConfigBuilder.() -> Unit): SecureConfig {
val builder = SecureConfigBuilder()
builder.block()
return builder.build()
}

The block: SecureConfigBuilder.() -> Unit parameter is a receiver lambda — the mechanism behind every Kotlin DSL. Inside secureConfig { ... }, this is the builder, so encrypted(...) and encryptionKey = ... resolve against it without qualification. operator fun get lets you index the config with config["key"] just like a Map.

main doubles as the encryption CLI (encrypt <value> [key]) and a demo that walks through raw encryption, the delegates, and the DSL. Note the local property delegations inside mainval port: Int by envInt(...) works on a local variable, not just a class property.

src/main/kotlin/com/example/config/Main.kt
package com.example.config
fun main(args: Array<String>) {
// CLI mode: encrypt a value
if (args.isNotEmpty() && args[0] == "encrypt") {
if (args.size < 2) {
println("Usage: encrypt <value> [encryption-key]")
return
}
val value = args[1]
val key = if (args.size > 2) args[2] else "default-dev-key-change-in-prod"
val encryptor = AesGcmEncryptor.fromStringKey(key)
val encrypted = encryptor.encrypt(value)
println("Encrypted value (Base64):")
println(encrypted)
println()
println("To use as environment variable:")
println("export MY_SECRET=\"$encrypted\"")
return
}
// Demo mode: show how delegates work
println("=== Encrypted Config Delegate Demo ===")
println()
// 1. Basic encryption/decryption
val key = "my-secret-encryption-key-32!!"
val encryptor = AesGcmEncryptor.fromStringKey(key)
val original = "super_secret_database_password"
val encrypted = encryptor.encrypt(original)
val decrypted = encryptor.decrypt(encrypted)
println("1. Basic AES-256-GCM Encryption:")
println(" Original: $original")
println(" Encrypted: $encrypted")
println(" Decrypted: $decrypted")
println(" Match: ${original == decrypted}")
println()
// 2. Property delegates with environment variables
println("2. Environment Variable Delegates:")
// These read from actual environment or .env file
val port: Int by envInt("SERVER_PORT", default = 8080)
val debug: Boolean by envBool("DEBUG_MODE", default = false)
val host: String by env("SERVER_HOST", default = "localhost")
println(" Host: $host")
println(" Port: $port")
println(" Debug: $debug")
println()
// 3. DSL-based configuration
println("3. Secure Config DSL:")
val config = secureConfig {
encryptionKey = key
encrypted("database.password", envVar = "DATABASE_PASSWORD", default = "dev-password")
encrypted("api.key", envVar = "API_KEY", default = "dev-api-key")
plain("database.host", envVar = "DATABASE_HOST", default = "localhost")
plain("database.port", envVar = "DATABASE_PORT", default = "5432")
plain("log.level", envVar = "LOG_LEVEL", default = "INFO")
}
println(" database.password: ${config["database.password"]}")
println(" api.key: ${config["api.key"]}")
println(" log.level: ${config["log.level"]}")
}
  1. Run the demo (encryption, delegates, and DSL all exercised):

    Terminal window
    ./gradlew run
  2. Encrypt a value for storage in an env var:

    Terminal window
    ./gradlew run --args="encrypt my-secret-value"

    Copy the Base64 output into your environment, e.g. export DATABASE_PASSWORD="<the-base64-blob>", and the encrypted(...) delegate will decrypt it transparently on read.

  3. Run the tests:

    Terminal window
    ./gradlew test

The test suite is the best documentation for AES-GCM’s guarantees. The round-trip test confirms decrypt(encrypt(x)) == x; two more assert that encrypting the same value twice yields different ciphertext (thanks to the random IV) yet both decrypt back to the same plaintext. Two negative tests show GCM’s authentication: the wrong key and a single tampered byte each make decrypt throw.

src/test/kotlin/com/example/config/EncryptionTest.kt
@Test
fun `different encryptions of same value produce different ciphertexts`() {
val original = "same value"
val encrypted1 = encryptor.encrypt(original)
val encrypted2 = encryptor.encrypt(original)
// Due to random IV, same plaintext produces different ciphertext
assertNotEquals(encrypted1, encrypted2)
}
@Test
fun `wrong key fails to decrypt`() {
val encrypted = encryptor.encrypt("secret")
val wrongKeyEncryptor = AesGcmEncryptor.fromStringKey("wrong-key")
assertFailsWith<Exception> {
wrongKeyEncryptor.decrypt(encrypted)
}
}
@Test
fun `tampered ciphertext fails to decrypt`() {
val encrypted = encryptor.encrypt("secret")
val bytes = java.util.Base64.getDecoder().decode(encrypted)
// Tamper with a byte
bytes[bytes.size / 2] = (bytes[bytes.size / 2].toInt() xor 0xFF).toByte()
val tampered = java.util.Base64.getEncoder().encodeToString(bytes)
assertFailsWith<Exception> {
encryptor.decrypt(tampered)
}
}