Skip to content
All posts

Encrypting a Room database in 2026: SQLCipher, the Keystore, and migrating without losing data

A practical guide to encrypting a Room/SQLite database at rest on Android with SQLCipher — key management via the Keystore, the one-time migration, and the real performance cost.

MFKAPPS 5 min read

A biometric prompt decides who gets to open the app. It doesn’t decide what’s sitting in the database file while the app is closed. Pull app.db off an unlocked, rooted device — or out of an unencrypted backup — and a plain Room database opens in any SQLite viewer, no prompt required. If the data is worth locking behind a fingerprint, it’s worth encrypting at rest too. Those are two different problems, and most guides only solve the first one.

This is the version I actually shipped: SQLCipher wrapping Room, a key held in the Android Keystore instead of a hardcoded string, and a migration that moves existing users from a plaintext database to an encrypted one without losing a row.

Why this is separate from biometric auth

I wrote about locking Granyn with BiometricPrompt and a Keystore-backed CryptoObject in an earlier post. That pattern encrypts specific fields, and only while the OS considers the user “authenticated” for that one operation. It’s the right tool for gating a balance on screen.

It does nothing for the database file itself. Room’s default SupportSQLiteOpenHelper writes plain SQLite pages to disk — readable with sqlite3 app.db the moment someone has the file, authentication or not. Whole-database encryption at rest is a different layer: it protects the file, independent of whether any particular screen is locked. You generally want both, but they solve different threats, and neither substitutes for the other.

Wiring SQLCipher into Room

SQLCipher for Android ships a drop-in replacement for the standard SupportSQLiteOpenHelper.Factory, so the Room setup barely changes:

// build.gradle.kts
implementation("net.zetetic:android-database-sqlcipher:4.6.1")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
fun buildEncryptedDatabase(context: Context, passphrase: ByteArray): AppDatabase {
    val factory = SupportOpenHelperFactory(passphrase)
    return Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
        .openHelperFactory(factory)
        .build()
}

That’s the entire integration surface for Room itself — DAOs, entities, Flow queries, migrations all stay exactly as they were. The only new problem SQLCipher introduces is the one it doesn’t solve for you: where does passphrase come from, and how is it stored so that it isn’t just a plaintext key sitting next to a now-encrypted file, which would be security theater.

Keying it with the Android Keystore, not a hardcoded string

The passphrase has to live somewhere the OS itself protects, not in a resource file or BuildConfig string. The Keystore is built for exactly this: generate an AES key that never leaves hardware-backed storage, use it to encrypt a random passphrase, and store only the encrypted passphrase in SharedPreferences or DataStore.

private const val KEY_ALIAS = "app_db_passphrase_key"

fun getOrCreateWrappingKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }

    val keyGenerator = KeyGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
    )
    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .build()
    keyGenerator.init(spec)
    return keyGenerator.generateKey()
}

fun getOrCreatePassphrase(prefs: SharedPreferences): ByteArray {
    prefs.getString("wrapped_db_key", null)?.let { stored ->
        return decryptStoredPassphrase(stored, prefs)
    }
    val passphrase = ByteArray(32).also { SecureRandom().nextBytes(it) }
    storeWrappedPassphrase(passphrase, prefs)
    return passphrase
}

Note there’s no setUserAuthenticationRequired(true) on this key, unlike the biometric-gated one. That’s deliberate: the database needs to open on app launch, including from a background job, without blocking on a fingerprint prompt every time. This key’s job is narrower — keep the passphrase off disk in plaintext — not gate every read behind biometrics. If a screen needs that stronger guarantee, layer the earlier CryptoObject pattern on top of specific fields; don’t try to make one key do both jobs.

The one-time migration: plaintext to encrypted

Existing users have a plaintext app.db sitting on disk already. SQLCipher can’t just “start” encrypting it — you rewrite the file once, using its own sqlcipher_export() mechanism, which copies every table from a plaintext source database into a freshly created encrypted one:

fun migrateToEncrypted(context: Context, passphrase: ByteArray) {
    val plainDbFile = context.getDatabasePath("app.db")
    if (!plainDbFile.exists()) return // fresh install, nothing to migrate

    val encryptedPath = context.getDatabasePath("app_encrypted.db").absolutePath
    val plainDb = SQLiteDatabase.openDatabase(
        plainDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE
    )

    plainDb.rawExecSQL("ATTACH DATABASE '$encryptedPath' AS encrypted KEY '${passphrase.toHexKey()}'")
    plainDb.rawExecSQL("SELECT sqlcipher_export('encrypted')")
    plainDb.rawExecSQL("DETACH DATABASE encrypted")
    plainDb.close()

    val originalRowCount = countRows(plainDbFile.absolutePath)
    val migratedRowCount = countRows(encryptedPath, passphrase)
    check(originalRowCount == migratedRowCount) { "Row count mismatch after export" }

    plainDbFile.delete()
    File(encryptedPath).renameTo(plainDbFile)
}

The check() on row counts is not paranoia — it’s the difference between a migration you can trust and one you find out failed from a support email. Run this once, gated behind a version flag in DataStore (db_encrypted_v1 = true), before Room.databaseBuilder() ever opens the database. If it throws, don’t delete the original plaintext file — leave the app on the old path and log it, rather than risk a half-migrated state with no fallback.

What it costs

SQLCipher isn’t free. On a mid-range device, expect roughly 5-15% overhead on read/write throughput compared to plain SQLite, mostly from per-page AES-256 encryption and the additional HMAC verification on read. For a budgeting or subscription-tracking app with a few thousand rows, this is not perceptible — queries that took 8ms take 9ms. For anything doing bulk imports of tens of thousands of rows in a single transaction, benchmark it before shipping; that’s where the overhead compounds enough to notice.

The other real cost is recovery. Lose the wrapped passphrase — a Keystore reset from a factory restore, an OS bug clearing app-specific keys — and the encrypted database is unrecoverable by design. There is no backdoor to add; that’s what “encrypted” means. Pair this with an unencrypted, user-initiated export (CSV, plain JSON) as the actual backup story, so “the Keystore key is gone” is an inconvenience, not permanent data loss.

What to actually encrypt

Not every local-first app needs this. The question worth asking before reaching for SQLCipher: what does the data reveal if the raw file leaks? A hydration app’s timestamp log is low-stakes. A budgeting app’s transaction notes, or a medication log’s dose history, are not — they’re the kind of thing a privacy policy makes promises about, and “the file was AES-256 encrypted at rest” is a promise SQLCipher actually lets you keep instead of just write down.