Commercial risk
A token in a plaintext local database is not “at risk” — it is already disclosed the moment the device is lost, rooted, or backed up. For a financial or health app that is a reportable breach with regulatory notification duties, not an internal fix.
The subtler cost is the silent mass logout: a non-atomic token write that tears when the OS suspends the app can delete the refresh token while leaving the stale access token behind, forcing every affected user to re-authenticate with no error and no explanation — a support and churn event that reads as “the app randomly logs me out.”
Mobile credential storage is deceptively hard because the platform gives you a secure primitive — Keychain on iOS, Keystore/EncryptedSharedPreferences on Android — and then lets you bypass it without warning. The most common failure is not using a broken crypto scheme; it is not using the secure store at all, or using it incorrectly under concurrency and lifecycle pressure.
Three distinct failure modes recur across the mobile codebases we audit. Each one is invisible in a demo, each one is a security or reliability incident in production, and all three trace back to treating the token as ordinary data rather than a secret with a lifecycle.
1. Plaintext credentials in the local database
The pattern: a `Profile` entity persisted via the platform's SQLite ORM, with `token` and `refreshToken` as ordinary string columns. The database file is created with no encryption. On a rooted Android device — or from an unencrypted device backup, or via forensic extraction — that file is a plain read, and every stored credential is exposed. `fallbackToDestructiveMigration()` in the same config is a tell: schema churn was expensive, so security was deferred, and deferred security is absent security.
The fix is to keep secrets out of the general-purpose database entirely. Credentials belong in the hardware-backed keystore: iOS Keychain with an explicit protection class, Android Keystore or `EncryptedSharedPreferences` keyed by a Keystore-held master key. The application database stores a reference, never the secret. If a credential must transit the database, it is encrypted with a key that itself lives only in the keystore — but the correct answer is almost always "it must not."
// Wrong: credentials as plain columns in the app database.
@Entity(tableName = "profile")
data class Profile(
@PrimaryKey val id: Long,
val token: String, // plaintext access token at rest
val refreshToken: String, // plaintext refresh token at rest
)
// Right: secrets in the Keystore-backed store, DB holds no credential.
val secure = EncryptedSharedPreferences.create(
context, "auth",
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
secure.edit().putString("refresh_token", token).apply()2. Non-atomic keystore writes that tear on suspend
A token refresh typically writes two secrets: the new access token and the new refresh token. If those are two independent keystore operations — delete-then-add, twice — there is a window between them where the state is inconsistent. If the OS suspends or kills the app in that window (which it does aggressively to backgrounded apps), you can land with the refresh token deleted and the old access token still present. On next launch the app makes a request, gets a 401, tries to refresh, and finds no refresh token: a hard logout, with no error the user can act on.
iOS adds a second edge: a keychain item stored `kSecAttrAccessibleWhenUnlocked` (or `AfterFirstUnlock`) cannot be written while the device is locked. A background refresh that fires just as the user locks the phone can fail its write silently, leaving the old token in place. The refresh appeared to run; the persistence didn't.
The fix is to make the credential update atomic: write both tokens as a single serialised record under one key, so the keystore performs one operation that either fully lands or doesn't land at all. There is no torn intermediate state because there is no intermediate state.
// One atomic record — both tokens land together or not at all.
struct Session: Codable { let access: String; let refresh: String }
func store(_ session: Session) throws {
let data = try JSONEncoder().encode(session)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "auth.session",
// Require the device to be unlocked, this device only, and
// fail loudly rather than silently if it cannot be written.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
SecItemDelete(query as CFDictionary)
var add = query; add[kSecValueData as String] = data
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.write(status) }
}3. Refresh with no lock: the double flight
The third failure is concurrency. Two requests race, both receive a 401, both trigger a refresh. Now two refresh calls are in flight with the same refresh token. Depending on the backend's rotation policy, one of them invalidates the token the other is still using, and a request that should have succeeded fails — or both persist different new tokens and the last writer wins, discarding a valid session.
The fix is a single-flight refresh: the first 401 acquires a lock and performs the refresh; every concurrent 401 awaits the same in-progress operation and reuses its result. This is a small amount of coordination code and it eliminates an entire class of intermittent, unreproducible auth failures that otherwise show up only under real-world request concurrency.
actor TokenRefresher {
private var inFlight: Task<Session, Error>?
func refresh() async throws -> Session {
// Single-flight: concurrent callers await the same operation.
if let existing = inFlight { return try await existing.value }
let task = Task { try await self.performRefresh() }
inFlight = task
defer { inFlight = nil }
return try await task.value
}
}Credentials are not application data with a security requirement bolted on — they are secrets with a lifecycle, and every shortcut in storing them is a liability you ship to every device. Plaintext at rest is a breach waiting for a lost phone. Non-atomic writes are a mass-logout waiting for the OS to suspend the app. Unlocked refresh is an intermittent failure waiting for two requests to race.
Keep secrets in the hardware-backed store, write them atomically as a single record, and serialise refresh behind a single-flight lock. None of it is expensive. All of it is invisible until the incident, which is exactly why it has to be engineered in before there is one.