Encrypted by default. Plain (unencrypted) when needed. Persist variables, Compose State, StateFlow, and serializable objects across Android, iOS, macOS, Desktop, and Web Easy to use by design — plus key rotation, cross-platform biometrics, and an encryption
> /plugin marketplace add ioannisa/KSafe> /plugin install ksafe@ksafe
Repo: ioannisa/KSafe
What's inside
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 death
Read 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/KSafe
Forcing 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() // suspend
Reactive 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 it
Hot — 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 // persists
Without 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)
// ...
}
FAQ
ksafe is a Claude Code plugin with 1 hand-picked skill for security work, indexed on Flowy. Install it with the command on its page. It includes ksafe. Its skills do not fire on their own yet. Request auto-invocation to have Flowy route them as you prompt. Free and open source.
Is this plugin yours?
Claim it with GitHubSubmit a pluginPromote it