All writing

Writing

Encrypting Room with SQLCipher in offline-first Android apps

SQLCipher makes encrypting a Room database fairly direct. In an offline-first app, the harder design work is making the passphrase lifecycle explicit, testable, and recoverable enough for real field conditions.

Encrypting a Room database with SQLCipher is not the complicated part.

The library integration is fairly small: create a passphrase, give Room a SQLCipher open-helper factory, and let SQLite pages be encrypted on disk. That is useful protection for sensitive local data, and for field apps it is often the right default.

The design question that deserves more attention is what keeps that passphrase available.

In a connected app, a local database is often a cache. If the encrypted store cannot be opened, the app can usually rebuild local state from the server. That is still a disruption, but it is usually bounded.

In an offline-first app, the local database is also where work happens. A user may create records while the network is absent for hours or days, and the app still has to behave predictably. That changes the encryption discussion. You are not only choosing a cipher. You are choosing a key lifecycle for the local store that keeps the product usable in the conditions it was built for.

This is the part I now design explicitly.

The basic shape

At the Room level, the wiring is straightforward. Resolve a database passphrase, pass it to SQLCipher, and attach the resulting factory to the Room builder:

EncryptedDatabase.kt
fun buildEncryptedDatabase(
context: Context,
passphraseProvider: DatabasePassphraseProvider,
): AppDatabase {
val passphrase = passphraseProvider.getOrCreatePassphrase()
val factory = SupportOpenHelperFactory(passphrase.toByteArray())
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db",
)
.openHelperFactory(factory)
.addMigrations(/* migrations */)
.build()
}

That snippet hides the part that matters most: DatabasePassphraseProvider. Putting passphrase resolution behind a small abstraction is not ceremony. It is where the app decides which failures are temporary, which ones require re-provisioning, and how initialization should proceed.

Keep the passphrase available

SQLCipher needs a stable passphrase. Android Keystore is a reasonable place to protect the key material used to encrypt that passphrase, because the raw database passphrase should not be stored in plaintext. But the Keystore is still part of your app’s state, with its own failure modes.

In some apps, you might be tempted to derive the database passphrase from a user password. That was not a fit here. The app’s sign-in model used a federated identity provider, so there was no user password available to the app. The user’s email address was available, but an email address is an identifier, not a secret. It is easy to know, easy to guess, and not suitable key material for a local encrypted database.

That pushed the design toward an app-managed database passphrase: generated with secure randomness, stored only after being encrypted, and treated as a dependency the data layer has to resolve before Room opens.

Those failure modes are especially important in offline-first apps:

  1. The passphrase exists and decrypts normally.
  2. The Keystore or encrypted preference layer is temporarily unreadable.
  3. The stored passphrase blob is corrupt or intentionally absent.
  4. The database file exists, but the app no longer has the passphrase that opens it.

Those are not the same condition, and they should not all produce the same response.

The mistake I try to avoid now is treating any failure to read the passphrase as permission to generate a new one. A new passphrase does not recover an existing SQLCipher database. It creates a key for a different database. If the old file is still present, opening it with the new passphrase will fail. That means passphrase reads need to be classified before the app decides what to do next.

For a cache, that may be acceptable. For an offline-first store with pending user work, it is an initialization policy that needs to be chosen on purpose.

Separate transient failure from re-provisioning

The provider does not need to know about every table in the app. It does need to make a careful distinction:

DatabasePassphraseProvider.kt
class DatabasePassphraseProvider(
private val passphraseStore: PassphraseStore,
private val passphraseFactory: () -> String,
) {
fun getOrCreatePassphrase(onPassphraseMissingOrCorrupt: () -> Unit): String {
return when (val result = passphraseStore.read()) {
is PassphraseReadResult.Found -> result.value
is PassphraseReadResult.TemporarilyUnavailable -> {
// Preserve the existing database. A later launch may be able
// to read the same passphrase successfully.
throw DatabaseLockedException(result.cause)
}
is PassphraseReadResult.MissingOrCorrupt -> {
// This path is intentionally separate from transient platform
// failures, so reset/re-provision decisions stay explicit.
onPassphraseMissingOrCorrupt()
generatePersistAndVerify()
}
}
}
private fun generatePersistAndVerify(): String {
val passphrase = passphraseFactory()
passphraseStore.write(passphrase)
check(passphraseStore.read() == PassphraseReadResult.Found(passphrase)) {
"Refusing to encrypt the database with a passphrase that was not durably stored"
}
return passphrase
}
}

The exact sealed classes are not important. The boundary is.

A temporarily unavailable key should preserve the encrypted database and fail initialization in a controlled way. The app can show a retryable state, delay database startup, or retry on the next launch. What it should not do is silently rotate keys while the platform store may simply be unavailable.

An intentionally absent or corrupt passphrase is different. That path should be explicit, logged, and observable. Whether the app blocks startup, asks the user to retry, restores from a known-good source, or re-provisions local storage is a product decision. The important part is that this branch is not reached by accident because a Keystore read failed once.

Choose Keystore settings for the job

It is tempting to bind every key to the strongest available device state. That can be right for some apps. A banking app that decrypts a session only after the user authenticates has a different shape from an offline-first app that needs background sync to open its database while the user is not present.

For an encrypted Room database, ask whether the database must be available to background work. If the answer is yes, the passphrase protection cannot require fresh user authentication every time the database opens.

The wording matters here: Android Keystore is not changing the SQLCipher passphrase. The passphrase is your app’s value. What can change is whether the Keystore key that protects that value is usable.

Android documents a few important cases for keys that require user authentication. If user authentication is required, using the key may throw UserNotAuthenticatedException until the user authenticates. The same class of auth-bound keys can also throw KeyPermanentlyInvalidatedException if the secure lock screen is disabled, reconfigured to a non-secure mode such as swipe, or forcibly reset by a device administrator. For keys that require biometric authentication for every use, KeyGenParameterSpec.Builder documents that Android invalidates the key by default when a new biometric is enrolled or when all enrolled biometrics are removed, unless the key is configured not to invalidate on biometric enrollment or is authorized with device credentials.

Those are good security features when user presence is part of the operation being protected. They are a poor default for a database that background workers must open without the user in front of the device.

A generic AES key for encrypting the stored passphrase might look like this:

KeystoreKeyFactory.kt
fun createPassphraseWrappingKey(alias: String): SecretKey {
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.setRandomizedEncryptionRequired(true)
.build()
return KeyGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }
.generateKey()
}

This is not a universal recommendation. It is a reminder to match the key policy to the app’s operating model. If the database is part of background sync, then “requires the user to be in front of the device” is not just a security option. It is an availability constraint.

Backups and restores are part of the threat model

Encrypted local state also changes how backup and restore should be reviewed. Android Keystore keys are not simply portable files. If an encrypted passphrase blob is restored without the key that can decrypt it, the app may have a stored value that looks present but is unusable.

That does not mean backup should always be disabled. It means backup rules should be deliberate. For each encrypted local file, decide whether restoring it onto another install can ever work. If the answer is no, exclude it or provide a migration path that handles the mismatch cleanly.

The important thing is to test it as a product flow, not just as a crypto unit test:

  1. Install the app fresh and create the encrypted database.
  2. Restart and confirm the same passphrase opens it.
  3. Upgrade the app and confirm migrations run through SQLCipher.
  4. Simulate an unreadable passphrase store and confirm the app preserves the existing encrypted store.
  5. Simulate an intentionally absent passphrase and confirm the re-provisioning path is controlled.
  6. Exercise device backup and restore behavior for encrypted stores.

Those tests are where the tradeoff becomes concrete.

What changed in my own approach

The lesson for me was not “avoid SQLCipher.” I would still encrypt sensitive offline data.

The change is that I no longer treat database encryption as a one-line Room configuration. In an offline-first app, I treat passphrase availability as a first-class dependency of the data layer.

That means:

  1. The SQLCipher passphrase is resolved in one testable place.
  2. Keystore read failures are classified before any re-provisioning path runs.
  3. New passphrases are written and read back before the database uses them.
  4. Startup paths are explicit about when the app should wait, retry, restore, or initialize fresh local storage.
  5. Backup and restore rules are reviewed alongside the encryption design.

None of this makes the code dramatic. That is the point. The best version of this architecture is quiet. The app opens the encrypted database, syncs when it can, preserves local work when the network is absent, and handles key problems as states the system already understands.

For offline-first Android, that is the practical bar: not just encrypted bytes on disk, but an encrypted store whose key lifecycle matches the conditions the app actually lives in.


This piece comes from work on an offline-first Android field app where local data had to remain useful across long stretches of weak or absent connectivity. The broader architecture story is in the InfoRange case study.

Strictly necessary storage is always on; everything else is your call.

Strictly necessary

Needed for the site to work and remember basic choices. These never track you across sites.

Always on
  • theme (This site) — Remembers your dark/light theme preference. localStorage — kept until you clear your browser data.
  • cookie-consent (This site) — Remembers the cookie choices you make here. localStorage — kept until you clear your browser data.

Analytics

Anonymous, aggregate page-view and interaction statistics via self-hosted Plausible Analytics. Plausible sets no cookies or persistent identifiers, and analytics only runs when you allow it.