
Effortlessly encrypts and persists data using hardware-backed security, offering one-code-path simplicity, seamless integration, and protection for sensitive information like OAuth tokens.
KSafe is a secure-by-default Kotlin Multiplatform key/value persistence library. Persist ordinary Kotlin variables, Compose MutableState, MutableStateFlow, and @Serializable objects across app restarts with one API on Android, iOS, macOS, JVM/Desktop, WASM, and Kotlin/JS. Encrypted (AES-256-GCM) by default; plain per-entry with mode = KSafeWriteMode.Plain.
var counter by ksafe(0)
counter++ // auto-encrypted (AES-256-GCM), auto-persisted, survives process deathRead and write it like any normal Kotlin variable — no suspend, no runBlocking, no DataStore boilerplate, no explicit encrypt/decrypt. Reads hit a hot in-memory cache; writes encrypt and flush in the background — synchronous, but never blocking. Reach for the suspend API (get / put) only when you want to await the disk flush.
Extras when you encrypt: biometrics (Face ID / Touch ID / Fingerprint — optional standalone ksafe-biometrics module) · root/jailbreak detection (WARN/BLOCK + analytics callback) · memory policy (RAM-exposure modes) · a one-line hardware-isolated DB passphrase for SQLCipher / SQLDelight / Room.
KSafe ships an agentskills.io-compatible skill — skills/ksafe/SKILL.md — that teaches any AI agent (Claude Code, Codex, Gemini CLI, Copilot CLI, Junie) KSafe's patterns, anti-patterns and gotchas, so the code it writes for you is the code this README describes.
Claude Code — run both, in this order, once. Restart the session afterwards; skills load at session start:
/plugin marketplace add ioannisa/KSafe # register this repo as a plugin source
/plugin install ksafe@ksafe # install the skill from it
Any other agent — one command, then pick your agents from its prompt:
npx skills add ioannisa/KSafeForcing an update, installing without any tooling, and why you should not also hand-copy SKILL.md: docs/AI_AGENTS.md.
KSafe in action across many scenarios: KSafeDemo — Compose Multiplatform app.
| Author's Video | Philipp Lackner's Video | Jimmy Plazas's Video |
|---|---|---|
| KSafe - Kotlin Multiplatform Encrypted DataStore Persistence Library | How to Encrypt Local Preferences In KMP With KSafe | Encripta datos localmente en Kotlin Multiplatform con KSafe - Ejemplo + Arquitectura |
// commonMain or Android-only build.gradle(.kts)
implementation("eu.anifantakis:ksafe:3.2.0")
implementation("eu.anifantakis:ksafe-compose:3.2.0") // ← Compose state (optional)
implementation("eu.anifantakis:ksafe-biometrics:3.2.0") // ← Biometric auth (optional)Skip
ksafe-composeif you don't use Jetpack Compose ormutableStateOfpersistence.Skip
ksafe-biometricsif you don't need Face ID / Touch ID / Fingerprint verification. The biometrics module is fully independent — it has no dependency on:ksafeand can be used on its own to protect any action in your app.
Note:
kotlinx-serialization-jsoncomes in transitively — don't add it yourself.
Required only if you store @Serializable data classes. Add it to libs.versions.toml:
[versions]
kotlin = "2.2.21"
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }then apply it in build.gradle.kts:
plugins {
//...
alias(libs.plugins.kotlin.serialization)
}// Android
val ksafe = KSafe(context)
// iOS / macOS / JVM / WASM / JS
val ksafe = KSafe()With Koin (recommended for KMP):
// Android
actual val platformModule = module {
single { KSafe(androidApplication()) }
}
// iOS / macOS / JVM / WASM / JS
actual val platformModule = module {
single { KSafe() }
}Multi-instance setups, web awaitCacheReady(), custom storage directories, key namespacing for Desktop/Web (appNamespace), and AES key-size configuration: docs/SETUP.md.
Compose Desktop release builds: add
modules("jdk.unsupported", "java.management")tonativeDistributionsfor OS-backed key custody — why, and what happens without it: docs/JVM_PROTECTION.md.
There are two ways to reach your data, and they share the same store and the same hot cache, so you can mix them freely. Full reference (Compose policy, cross-screen sync, write modes, nullables, deletion, full ViewModel): docs/USAGE.md.
You declare one variable per value. KSafe persists it, encrypts it, and keeps it in the hot cache, so reads and writes are synchronous and never suspend.
Property delegation — the key is the property name:
var counter by ksafe(0)
counter++Direct handle (3.2.0+) — the same call without by. Keep the handle in a val, pass it around, read and write .value. The key must be given explicitly, because there is no property name to infer it from:
val counter = ksafe(0, key = "counter") // KSafeReference<Int>
counter.value++No variable per key. You address any key, at any time, straight on the instance. Each operation comes in two forms: a suspend one that waits for the disk flush, and a Direct one that returns at once, serves reads from the hot cache and flushes writes in the background (about 1000x faster for bulk work).
// Put
ksafe.put("profile", user) // suspend — returns after the disk flush
ksafe.putDirect("counter", 42) // non-suspend — background flush
// Get
val loaded: User = ksafe.get("profile", User())
val n = ksafe.getDirect("counter", 0)
// Delete
ksafe.delete("profile")
ksafe.deleteDirect("counter")
// Wipe the store — every value and the keys that protected them (logout)
ksafe.clearAll() // suspendReactive reads that pick up changes made anywhere: another screen, a background sync, a delegate against the same key. Four shapes, and the same two doors as above — the property names the key, or you do:
| Shape | Type | Hot or cold | Writes | Scope | Same thing, key spelled out |
|---|---|---|---|---|---|
asFlow |
Flow<T> |
cold | — | none | getFlow(key, default) |
asWritableFlow |
WritableKSafeFlow<T> |
cold | set(value) |
none | — |
asStateFlow |
StateFlow<T> |
hot, .value
|
— | needed | getStateFlow(key, default, scope) |
asMutableStateFlow |
MutableStateFlow<T> |
hot, .value
|
.value =, update {}
|
needed | — |
Cold — nothing runs until someone collects, so there is no scope to manage:
val toggleMode: Flow<Boolean> by ksafe.asFlow(defaultValue = false)
toggleMode.collect { on -> render(on) }
ksafe.putDirect("toggleMode", true) // updated from anywhere — the collector above sees true
// Writable: one declaration you both collect and write through
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE)
themeMode.collect { mode -> applyTheme(mode) }
themeMode.set(ThemeMode.DARK) // persists, and every collector sees itHot — a current value is always there, and that is what the scope pays for. Something has to sit on the store, watch for changes made elsewhere and push them in; that watcher is a coroutine, and it must die with your ViewModel:
val isLoggedIn: StateFlow<Boolean> by ksafe.asStateFlow(true, viewModelScope)
isLoggedIn.value // read it any time, with no collector at all
ksafe.putDirect("isLoggedIn", false) // every collector sees false
// Writable: the _state / state pattern, persisted
private val _count by ksafe.asMutableStateFlow(0, viewModelScope)
val count = _count.asStateFlow()
_count.update { it + 1 } // persists
_count.value = 42 // persistsWithout the delegate — the same shapes and the same types, with the key spelled out. Note that a flow is always bound to one key: unlike put or getDirect, there is no flow over any key.
// Cold: call it inline, as often as you like — a cold Flow starts nothing on its own
ksafe.getFlow("isLoggedIn", defaultValue = true).collect { loggedIn -> render(loggedIn) }
// Hot: call it ONCE and keep the result — every call runs stateIn() and starts its own watcher
val isLoggedIn: StateFlow<Boolean> = ksafe.getStateFlow("isLoggedIn", true, viewModelScope)[!WARNING] Call
getStateFlowonce and keep the result. Every call runsstateIn()and launches its own watcher coroutine in the scope you pass, so calling it inline, in a loop, or inside a composable leaks one watcher per call until that scope is cancelled. Theby ksafe.asStateFlow(...)delegate is safe by construction — it builds itsStateFlowon first read and hands back the same instance forever after.getFlowis cold and costs nothing: call it as often as you like.
For the cold shapes the two forms are interchangeable — pick whichever reads better.
There is no writable shape without the delegate, and none is needed: to write, use put or putDirect, and every reader of that key sees it — a delegated flow in a ViewModel, a getFlow on another screen, a Compose state. One store, one cache.
Persistent state inside a @Composable body. The rememberSaveable analogue that also survives app restarts; the key resolves to the property name and no ViewModel is needed. KSafe is @Stable, so it can be passed as a parameter without breaking skipping. The default mode here is Plain, because this is UI state, not a secret. Requires ksafe-compose.
@Composable
fun TabbedScreen(ksafe: KSafe) {
var currentTab by ksafe.rememberKSafeState(Tab.Home)
// ...
}Compose MutableState on a ViewModel or any class field. The UI recomposes on change, the value persists, and two screens holding the same key stay in sync. Requires ksafe-compose.
var username by ksafe.mutableStateOf("Guest")Every write above takes an optional KSafeWriteMode. The default is encrypted; you can step up or step down per entry:
var token by ksafe("") // encrypted, the default
var token by ksafe("", mode = KSafeWriteMode.Encrypted()) // the same, spelled out
var pin by ksafe("", mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)) // step up: StrongBox / Secure Enclave
var theme by ksafe("light", mode = KSafeWriteMode.Plain) // step down: no encryptionThe helper classes freeze that mode at the type level, so no call site can forget it or pick the wrong one. They wrap an existing instance and offer every API shape above — KSafePlain(ksafe), or the accessors ksafe.plain, ksafe.encrypted, ksafe.hardwareIsolated. With Koin:
val appModule = module {
single<KSafe> { KSafe() }
single { KSafePlain(get()) } // every write plain
single { KSafeEncrypted(get()) } // every write encrypted
single { KSafeHardwareIsolated(get()) } // every write requests StrongBox / Secure Enclave
}
class PreferencesViewModel(
private val prefs: KSafePlain,
private val secrets: KSafeEncrypted,
private val vault: KSafeHardwareIsolated,
) : ViewModel() {
var counter by prefs(0) // delegate, plain
var pin by vault("0000") // delegate, hardware-isolated
fun save(token: String) = secrets.putDirect("token", token) // instance API, encrypted
}KSafeWriteMode.Encrypted(requireUnlockedDevice = true) additionally binds the key to the lock screen, so the value cannot be read while the device is locked. On Android 9 to 14 without a secure lock screen the key is minted without that binding, and ksafe.protectionInfo.notes says so: docs/SECURITY_MODEL.md.
Complex objects — mark them @Serializable; JSON and encryption are automatic:
@Serializable
data class AuthInfo(val accessToken: String = "", val refreshToken: String = "")
var authInfo by ksafe(AuthInfo())
authInfo = authInfo.copy(accessToken = "newToken")Nullable values — null is stored as a real value. But a bare null default cannot tell Kotlin the type, so name it, on every read shape:
var token: String? by ksafe(null) // typed declaration
val token = ksafe.get<String?>("token", null) // explicit type parameter
ksafe.getFlow<String?>("token", null).collect { … } // same rule for flowsKey rotation — re-encrypt everything under fresh keys, on every platform:
val result = ksafe.rotateKeys() // on demand: rotated / skipped / failed counts + new generation
// …or make it a policy and forget about it
val ksafe = KSafe(config = KSafeConfig(
keyRotationPolicy = KSafeKeyRotationPolicy.MaxAge(90.days) // rotates in the background at key age 90 days
))Crash-safe and resumable: an interrupted rotation keeps everything readable and finishes on the next KSafe instance. Values never change, only key material does. Details: docs/KEY_ROTATION.md.
Encrypted database passphrase — a stable, hardware-isolated 256-bit secret for SQLCipher / SQLDelight / Room:
val passphrase = ksafe.getOrCreateSecret("main.db") // generated once, same value on every call afterIt refuses to overwrite a secret it cannot read back, so it can never silently orphan your database, and key rotation preserves its value. Sizes, protection tiers, full Room + SQLCipher examples: docs/SECURITY_MODEL.md#cryptographic-utilities.
Biometric gate — one call raises the platform's own prompt (Face ID, Touch ID, fingerprint, Windows Hello, passkey) from shared code. Requires ksafe-biometrics:
if (KSafeBiometrics.verifyBiometric("Unlock your wallet")) showSecrets()Pass authorizationDuration = BiometricAuthorizationDuration(duration = 60_000L, scope = "payments") to keep the next minute prompt-free in that scope. Details: docs/BIOMETRICS.md.
What protection did I actually get — per store and per key, at runtime:
ksafe.protectionInfo.effectiveLevel // the tier the store really runs at
ksafe.protectionInfo.isEncryptionOperational // will encrypted reads and writes succeed right now?
ksafe.getKeyInfo("pin")?.level // where this one key's material landedRead it off the main thread on Android. Details: docs/PROTECTION_INFO.md.
Note: The property delegate and the direct handle work with any KSafe instance —
var x by myKsafe(default)makesmyKsafethe storage backend. The bareksafe(default)form requires an in-scopeksafe(the conventional name, typically your default instance). See docs/SETUP.md for the multi-instance pattern.
Everything else lives in docs/. New here? Start with USAGE and SETUP.
| Topic | What's inside |
|---|---|
| Complete Usage Guide | Every API shape: delegates, flows, Compose state, write modes, mode-typed views, nullables, full ViewModel |
| Setup | Koin per platform, multi-instance, web awaitCacheReady(), custom storage directory, appNamespace
|
| Custom JSON Serialization |
KSerializers for UUID, Instant, and other third-party types |
| Biometric Authentication | Face ID / Touch ID / Fingerprint / Windows Hello / WebAuthn — gate any action, auth caching, scoped sessions |
| Security Model | Runtime security policy (root/debugger/emulator), encryption internals, threat model, crypto utilities (getOrCreateSecret, secureRandomBytes) |
| Key Rotation |
rotateKeys() and the MaxAge policy: crash-safe, resumable, compliance notes |
| Protection Info |
KSafe.protectionInfo diagnostics: effective key custody, isEncryptionOperational, gating patterns |
| JVM Key Protection | Windows DPAPI / macOS Keychain / Linux Secret Service, software fallback, jdk.unsupported
|
| Memory Policy | RAM-exposure trade-offs: LAZY_PLAIN_TEXT, PLAIN_TEXT, ENCRYPTED, ENCRYPTED_WITH_TIMED_CACHE
|
| Encryption Proof | Automated proof tests + commands to inspect the raw stored bytes yourself |
| Performance Benchmarks | Full tables, cold-start numbers, methodology |
| Alternatives & Comparison | KSafe vs SharedPrefs, DataStore, multiplatform-settings, KVault, SQLCipher |
| Architecture | The conceptual model: modules, rings, hot cache + write coalescer |
| Source-tree Tour | File-by-file walkthrough of :ksafe
|
| Testing | Running tests, iOS test app |
| Migration Guide | Upgrading from older KSafe versions |
| Skill for AI Agents | Installing the KSafe skill in Claude Code and other agents, updating it, common pitfalls |
Compatibility: Android API 24+ · iOS 13+ · macOS 11+ (macosArm64/macosX64) · JDK 11+ · WasmGC browsers · Kotlin/JS. Kotlin 2.0+.
Contributions are welcome. Please read CONTRIBUTING.md before opening issues or pull requests, and follow the Code of Conduct. Release notes live in CHANGELOG.md.
Security-sensitive bug reports should follow SECURITY.md, not public GitHub issues.
Licensed under the Apache License 2.0 — see http://www.apache.org/licenses/LICENSE-2.0. Distributed "AS IS", without warranties of any kind.
KSafe is a secure-by-default Kotlin Multiplatform key/value persistence library. Persist ordinary Kotlin variables, Compose MutableState, MutableStateFlow, and @Serializable objects across app restarts with one API on Android, iOS, macOS, JVM/Desktop, WASM, and Kotlin/JS. Encrypted (AES-256-GCM) by default; plain per-entry with mode = KSafeWriteMode.Plain.
var counter by ksafe(0)
counter++ // auto-encrypted (AES-256-GCM), auto-persisted, survives process deathRead and write it like any normal Kotlin variable — no suspend, no runBlocking, no DataStore boilerplate, no explicit encrypt/decrypt. Reads hit a hot in-memory cache; writes encrypt and flush in the background — synchronous, but never blocking. Reach for the suspend API (get / put) only when you want to await the disk flush.
Extras when you encrypt: biometrics (Face ID / Touch ID / Fingerprint — optional standalone ksafe-biometrics module) · root/jailbreak detection (WARN/BLOCK + analytics callback) · memory policy (RAM-exposure modes) · a one-line hardware-isolated DB passphrase for SQLCipher / SQLDelight / Room.
KSafe ships an agentskills.io-compatible skill — skills/ksafe/SKILL.md — that teaches any AI agent (Claude Code, Codex, Gemini CLI, Copilot CLI, Junie) KSafe's patterns, anti-patterns and gotchas, so the code it writes for you is the code this README describes.
Claude Code — run both, in this order, once. Restart the session afterwards; skills load at session start:
/plugin marketplace add ioannisa/KSafe # register this repo as a plugin source
/plugin install ksafe@ksafe # install the skill from it
Any other agent — one command, then pick your agents from its prompt:
npx skills add ioannisa/KSafeForcing an update, installing without any tooling, and why you should not also hand-copy SKILL.md: docs/AI_AGENTS.md.
KSafe in action across many scenarios: KSafeDemo — Compose Multiplatform app.
| Author's Video | Philipp Lackner's Video | Jimmy Plazas's Video |
|---|---|---|
| KSafe - Kotlin Multiplatform Encrypted DataStore Persistence Library | How to Encrypt Local Preferences In KMP With KSafe | Encripta datos localmente en Kotlin Multiplatform con KSafe - Ejemplo + Arquitectura |
// commonMain or Android-only build.gradle(.kts)
implementation("eu.anifantakis:ksafe:3.2.0")
implementation("eu.anifantakis:ksafe-compose:3.2.0") // ← Compose state (optional)
implementation("eu.anifantakis:ksafe-biometrics:3.2.0") // ← Biometric auth (optional)Skip
ksafe-composeif you don't use Jetpack Compose ormutableStateOfpersistence.Skip
ksafe-biometricsif you don't need Face ID / Touch ID / Fingerprint verification. The biometrics module is fully independent — it has no dependency on:ksafeand can be used on its own to protect any action in your app.
Note:
kotlinx-serialization-jsoncomes in transitively — don't add it yourself.
Required only if you store @Serializable data classes. Add it to libs.versions.toml:
[versions]
kotlin = "2.2.21"
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }then apply it in build.gradle.kts:
plugins {
//...
alias(libs.plugins.kotlin.serialization)
}// Android
val ksafe = KSafe(context)
// iOS / macOS / JVM / WASM / JS
val ksafe = KSafe()With Koin (recommended for KMP):
// Android
actual val platformModule = module {
single { KSafe(androidApplication()) }
}
// iOS / macOS / JVM / WASM / JS
actual val platformModule = module {
single { KSafe() }
}Multi-instance setups, web awaitCacheReady(), custom storage directories, key namespacing for Desktop/Web (appNamespace), and AES key-size configuration: docs/SETUP.md.
Compose Desktop release builds: add
modules("jdk.unsupported", "java.management")tonativeDistributionsfor OS-backed key custody — why, and what happens without it: docs/JVM_PROTECTION.md.
There are two ways to reach your data, and they share the same store and the same hot cache, so you can mix them freely. Full reference (Compose policy, cross-screen sync, write modes, nullables, deletion, full ViewModel): docs/USAGE.md.
You declare one variable per value. KSafe persists it, encrypts it, and keeps it in the hot cache, so reads and writes are synchronous and never suspend.
Property delegation — the key is the property name:
var counter by ksafe(0)
counter++Direct handle (3.2.0+) — the same call without by. Keep the handle in a val, pass it around, read and write .value. The key must be given explicitly, because there is no property name to infer it from:
val counter = ksafe(0, key = "counter") // KSafeReference<Int>
counter.value++No variable per key. You address any key, at any time, straight on the instance. Each operation comes in two forms: a suspend one that waits for the disk flush, and a Direct one that returns at once, serves reads from the hot cache and flushes writes in the background (about 1000x faster for bulk work).
// Put
ksafe.put("profile", user) // suspend — returns after the disk flush
ksafe.putDirect("counter", 42) // non-suspend — background flush
// Get
val loaded: User = ksafe.get("profile", User())
val n = ksafe.getDirect("counter", 0)
// Delete
ksafe.delete("profile")
ksafe.deleteDirect("counter")
// Wipe the store — every value and the keys that protected them (logout)
ksafe.clearAll() // suspendReactive reads that pick up changes made anywhere: another screen, a background sync, a delegate against the same key. Four shapes, and the same two doors as above — the property names the key, or you do:
| Shape | Type | Hot or cold | Writes | Scope | Same thing, key spelled out |
|---|---|---|---|---|---|
asFlow |
Flow<T> |
cold | — | none | getFlow(key, default) |
asWritableFlow |
WritableKSafeFlow<T> |
cold | set(value) |
none | — |
asStateFlow |
StateFlow<T> |
hot, .value
|
— | needed | getStateFlow(key, default, scope) |
asMutableStateFlow |
MutableStateFlow<T> |
hot, .value
|
.value =, update {}
|
needed | — |
Cold — nothing runs until someone collects, so there is no scope to manage:
val toggleMode: Flow<Boolean> by ksafe.asFlow(defaultValue = false)
toggleMode.collect { on -> render(on) }
ksafe.putDirect("toggleMode", true) // updated from anywhere — the collector above sees true
// Writable: one declaration you both collect and write through
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE)
themeMode.collect { mode -> applyTheme(mode) }
themeMode.set(ThemeMode.DARK) // persists, and every collector sees itHot — a current value is always there, and that is what the scope pays for. Something has to sit on the store, watch for changes made elsewhere and push them in; that watcher is a coroutine, and it must die with your ViewModel:
val isLoggedIn: StateFlow<Boolean> by ksafe.asStateFlow(true, viewModelScope)
isLoggedIn.value // read it any time, with no collector at all
ksafe.putDirect("isLoggedIn", false) // every collector sees false
// Writable: the _state / state pattern, persisted
private val _count by ksafe.asMutableStateFlow(0, viewModelScope)
val count = _count.asStateFlow()
_count.update { it + 1 } // persists
_count.value = 42 // persistsWithout the delegate — the same shapes and the same types, with the key spelled out. Note that a flow is always bound to one key: unlike put or getDirect, there is no flow over any key.
// Cold: call it inline, as often as you like — a cold Flow starts nothing on its own
ksafe.getFlow("isLoggedIn", defaultValue = true).collect { loggedIn -> render(loggedIn) }
// Hot: call it ONCE and keep the result — every call runs stateIn() and starts its own watcher
val isLoggedIn: StateFlow<Boolean> = ksafe.getStateFlow("isLoggedIn", true, viewModelScope)[!WARNING] Call
getStateFlowonce and keep the result. Every call runsstateIn()and launches its own watcher coroutine in the scope you pass, so calling it inline, in a loop, or inside a composable leaks one watcher per call until that scope is cancelled. Theby ksafe.asStateFlow(...)delegate is safe by construction — it builds itsStateFlowon first read and hands back the same instance forever after.getFlowis cold and costs nothing: call it as often as you like.
For the cold shapes the two forms are interchangeable — pick whichever reads better.
There is no writable shape without the delegate, and none is needed: to write, use put or putDirect, and every reader of that key sees it — a delegated flow in a ViewModel, a getFlow on another screen, a Compose state. One store, one cache.
Persistent state inside a @Composable body. The rememberSaveable analogue that also survives app restarts; the key resolves to the property name and no ViewModel is needed. KSafe is @Stable, so it can be passed as a parameter without breaking skipping. The default mode here is Plain, because this is UI state, not a secret. Requires ksafe-compose.
@Composable
fun TabbedScreen(ksafe: KSafe) {
var currentTab by ksafe.rememberKSafeState(Tab.Home)
// ...
}Compose MutableState on a ViewModel or any class field. The UI recomposes on change, the value persists, and two screens holding the same key stay in sync. Requires ksafe-compose.
var username by ksafe.mutableStateOf("Guest")Every write above takes an optional KSafeWriteMode. The default is encrypted; you can step up or step down per entry:
var token by ksafe("") // encrypted, the default
var token by ksafe("", mode = KSafeWriteMode.Encrypted()) // the same, spelled out
var pin by ksafe("", mode = KSafeWriteMode.Encrypted(KSafeEncryptedProtection.HARDWARE_ISOLATED)) // step up: StrongBox / Secure Enclave
var theme by ksafe("light", mode = KSafeWriteMode.Plain) // step down: no encryptionThe helper classes freeze that mode at the type level, so no call site can forget it or pick the wrong one. They wrap an existing instance and offer every API shape above — KSafePlain(ksafe), or the accessors ksafe.plain, ksafe.encrypted, ksafe.hardwareIsolated. With Koin:
val appModule = module {
single<KSafe> { KSafe() }
single { KSafePlain(get()) } // every write plain
single { KSafeEncrypted(get()) } // every write encrypted
single { KSafeHardwareIsolated(get()) } // every write requests StrongBox / Secure Enclave
}
class PreferencesViewModel(
private val prefs: KSafePlain,
private val secrets: KSafeEncrypted,
private val vault: KSafeHardwareIsolated,
) : ViewModel() {
var counter by prefs(0) // delegate, plain
var pin by vault("0000") // delegate, hardware-isolated
fun save(token: String) = secrets.putDirect("token", token) // instance API, encrypted
}KSafeWriteMode.Encrypted(requireUnlockedDevice = true) additionally binds the key to the lock screen, so the value cannot be read while the device is locked. On Android 9 to 14 without a secure lock screen the key is minted without that binding, and ksafe.protectionInfo.notes says so: docs/SECURITY_MODEL.md.
Complex objects — mark them @Serializable; JSON and encryption are automatic:
@Serializable
data class AuthInfo(val accessToken: String = "", val refreshToken: String = "")
var authInfo by ksafe(AuthInfo())
authInfo = authInfo.copy(accessToken = "newToken")Nullable values — null is stored as a real value. But a bare null default cannot tell Kotlin the type, so name it, on every read shape:
var token: String? by ksafe(null) // typed declaration
val token = ksafe.get<String?>("token", null) // explicit type parameter
ksafe.getFlow<String?>("token", null).collect { … } // same rule for flowsKey rotation — re-encrypt everything under fresh keys, on every platform:
val result = ksafe.rotateKeys() // on demand: rotated / skipped / failed counts + new generation
// …or make it a policy and forget about it
val ksafe = KSafe(config = KSafeConfig(
keyRotationPolicy = KSafeKeyRotationPolicy.MaxAge(90.days) // rotates in the background at key age 90 days
))Crash-safe and resumable: an interrupted rotation keeps everything readable and finishes on the next KSafe instance. Values never change, only key material does. Details: docs/KEY_ROTATION.md.
Encrypted database passphrase — a stable, hardware-isolated 256-bit secret for SQLCipher / SQLDelight / Room:
val passphrase = ksafe.getOrCreateSecret("main.db") // generated once, same value on every call afterIt refuses to overwrite a secret it cannot read back, so it can never silently orphan your database, and key rotation preserves its value. Sizes, protection tiers, full Room + SQLCipher examples: docs/SECURITY_MODEL.md#cryptographic-utilities.
Biometric gate — one call raises the platform's own prompt (Face ID, Touch ID, fingerprint, Windows Hello, passkey) from shared code. Requires ksafe-biometrics:
if (KSafeBiometrics.verifyBiometric("Unlock your wallet")) showSecrets()Pass authorizationDuration = BiometricAuthorizationDuration(duration = 60_000L, scope = "payments") to keep the next minute prompt-free in that scope. Details: docs/BIOMETRICS.md.
What protection did I actually get — per store and per key, at runtime:
ksafe.protectionInfo.effectiveLevel // the tier the store really runs at
ksafe.protectionInfo.isEncryptionOperational // will encrypted reads and writes succeed right now?
ksafe.getKeyInfo("pin")?.level // where this one key's material landedRead it off the main thread on Android. Details: docs/PROTECTION_INFO.md.
Note: The property delegate and the direct handle work with any KSafe instance —
var x by myKsafe(default)makesmyKsafethe storage backend. The bareksafe(default)form requires an in-scopeksafe(the conventional name, typically your default instance). See docs/SETUP.md for the multi-instance pattern.
Everything else lives in docs/. New here? Start with USAGE and SETUP.
| Topic | What's inside |
|---|---|
| Complete Usage Guide | Every API shape: delegates, flows, Compose state, write modes, mode-typed views, nullables, full ViewModel |
| Setup | Koin per platform, multi-instance, web awaitCacheReady(), custom storage directory, appNamespace
|
| Custom JSON Serialization |
KSerializers for UUID, Instant, and other third-party types |
| Biometric Authentication | Face ID / Touch ID / Fingerprint / Windows Hello / WebAuthn — gate any action, auth caching, scoped sessions |
| Security Model | Runtime security policy (root/debugger/emulator), encryption internals, threat model, crypto utilities (getOrCreateSecret, secureRandomBytes) |
| Key Rotation |
rotateKeys() and the MaxAge policy: crash-safe, resumable, compliance notes |
| Protection Info |
KSafe.protectionInfo diagnostics: effective key custody, isEncryptionOperational, gating patterns |
| JVM Key Protection | Windows DPAPI / macOS Keychain / Linux Secret Service, software fallback, jdk.unsupported
|
| Memory Policy | RAM-exposure trade-offs: LAZY_PLAIN_TEXT, PLAIN_TEXT, ENCRYPTED, ENCRYPTED_WITH_TIMED_CACHE
|
| Encryption Proof | Automated proof tests + commands to inspect the raw stored bytes yourself |
| Performance Benchmarks | Full tables, cold-start numbers, methodology |
| Alternatives & Comparison | KSafe vs SharedPrefs, DataStore, multiplatform-settings, KVault, SQLCipher |
| Architecture | The conceptual model: modules, rings, hot cache + write coalescer |
| Source-tree Tour | File-by-file walkthrough of :ksafe
|
| Testing | Running tests, iOS test app |
| Migration Guide | Upgrading from older KSafe versions |
| Skill for AI Agents | Installing the KSafe skill in Claude Code and other agents, updating it, common pitfalls |
Compatibility: Android API 24+ · iOS 13+ · macOS 11+ (macosArm64/macosX64) · JDK 11+ · WasmGC browsers · Kotlin/JS. Kotlin 2.0+.
Contributions are welcome. Please read CONTRIBUTING.md before opening issues or pull requests, and follow the Code of Conduct. Release notes live in CHANGELOG.md.
Security-sensitive bug reports should follow SECURITY.md, not public GitHub issues.
Licensed under the Apache License 2.0 — see http://www.apache.org/licenses/LICENSE-2.0. Distributed "AS IS", without warranties of any kind.