Offline login in an iOS or Android app: design it as offline unlock
A product request for “offline login” in a mobile app with a cache of sensitive data (customer invoices, HR records, patient notes, credentials) sounds like one feature. On a phone with no network, nothing can check the password against the server, apply MFA, or learn that the device was revoked yesterday. What the app can do is prove a local factor to a device that was enrolled earlier and release keys for ciphertext already on it:
offline login: authenticate the user, apply policy, authorize data (impossible offline)
offline unlock: prove a local factor, release keys for cached data (what you can build)
I researched how to build the second one on iOS and Android. This post collects the parts that are easy to get wrong: where the password path needs a hardware key, why a biometric prompt without a CryptoObject is only a boolean check, which clock keeps counting while the phone sleeps, and what changes for users who have no password at all. It is longer than a usual post here, because the traps sit in different layers.
What an offline unlock can and cannot prove #
A connected sign-in lets the server authenticate the user and the device, require MFA, evaluate current policy and revocation, and send newly authorized data. An offline unlock does none of that. So it must never renew a server session, refresh authorization, or make cached data look current. Label the action “Unlock” in the UI and keep “Sign in” for the server. Show “Last checked” (policy) and “Last synchronized” (data) separately, because a recent local unlock is neither.
The trade-off between availability and revocation is unavoidable. While the device is disconnected, a revocation event cannot reach it, and plaintext it already showed cannot be recalled. A long offline window helps users during outages and travel. It also lets a lost or deauthorized device keep unlocking its cache until the window expires or the device reconnects. Pick a maximum offline age per threat model, make it visible, and apply revocation before any sync or key release at the next connection. Managed users should get an administrator-set finite limit. If you offer an unlimited consumer mode, tell the user that remote revocation and policy changes wait for the next reconnect.
First use of a device is always a connected sign-in. It enrolls the device, downloads the encrypted state, and creates the local unlock material. A device that was never provisioned online must never create offline capability, and a network error must never let you skip a required connected step.
A state machine where a stale device never prompts #
Write the states down, so a missing transition shows up in review:
UNENROLLED
└─ connected sign-in + MFA + policy + initial sync ─▶ LOCKED_CURRENT
LOCKED_CURRENT
├─ freshness valid, then valid local factor ────────▶ UNLOCKED
├─ successful check-in ─────────────────────────────▶ LOCKED_CURRENT (freshness renewed)
├─ freshness expires ───────────────────────────────▶ LOCKED_STALE
├─ check-in reports device/user revocation ─────────▶ UNENROLLED
└─ logout/user removal ─────────────────────────────▶ UNENROLLED
UNLOCKED
├─ timeout/background/device lock/manual lock ──────▶ LOCKED_CURRENT
├─ successful check-in ─────────────────────────────▶ UNLOCKED (freshness renewed)
├─ freshness expires ───────────────────────────────▶ LOCKED_STALE
├─ check-in reports device/user revocation ─────────▶ UNENROLLED
└─ logout/user removal ─────────────────────────────▶ UNENROLLED
LOCKED_STALE
├─ successful connected reauthentication/check-in ──▶ LOCKED_CURRENT
├─ check-in reports device/user revocation ─────────▶ UNENROLLED
└─ logout/user removal ─────────────────────────────▶ UNENROLLED
A check-in is any authenticated server call that returns current policy, revocation state, and server time. A background call with a refresh token is enough. If policy also wants full reauthentication with MFA after some maximum age, put that in the policy record, not in app logic.
Evaluate freshness before asking for a factor. A stale device shows the reconnect screen and never runs the password KDF, a PIN check, or a biometric prompt. When the window has expired, a biometric prompt that is bound to fail, followed by a generic “wrong password” error, tells the user nothing about the fix.
Losing one local factor does not change state. When a biometric wrapper is invalidated, or the PIN hits its threshold, delete that wrapper and continue with the others.
Wrap keys; never encrypt data with the password #
Use envelope encryption:
- Generate a random 256-bit account data key (ADK).
- Generate a random key per record and per file and wrap each under the ADK. Do not derive them from the ADK. A derived key can always be derived again, so you cannot destroy or rotate it for one object without rotating the ADK.
- Encrypt record fields, search indexes, file metadata, thumbnails, and file content with an AEAD: AES-256-GCM or ChaCha20-Poly1305.
- Wrap the ADK once per unlock method: password, biometric, optional PIN, recovery key.
- Encrypt the password and PIN wrappers a second time under a non-exportable hardware key that needs no user authentication: Secure Enclave on iOS, Android Keystore on Android.
- Put version, algorithm, KDF parameters, key ID, account, object ID, field ID, and purpose into the authenticated data.
The field ID in step 6 looks like overkill until a field moves. If an attacker can swap the bank-account field of supplier record A with that of record B and the AEAD still verifies, the app shows a payment destination nobody entered for that supplier. In an autofill provider, the same swap between URL fields offers entry A’s password to site B.
A whole-database encryption layer can hide metadata and WAL contents. It does not replace per-field AEAD, because it usually protects only while the database is closed.
Why the password path needs the device too #
The threat model that changes the design is the attacker with a copied sandbox or backup. Assume they hold the database, salts, KDF parameters, wrapped keys, and preferences. If the password wrapper is only KDF(password), they can run the KDF on GPUs, away from the device, for as long as they like.
Step 5 fixes that. With the wrapper sealed by a device key that never leaves the Secure Enclave or Keystore, every guess has to run on the original phone, at its speed. The KDF becomes the second barrier, not the only one. A secret stored in plaintext next to the copied files is not a second factor and does not count as one.
For the KDF itself:
- Prefer Argon2id with a random salt and versioned parameters, benchmarked on the weakest supported device. It is not part of CryptoKit, CommonCrypto, or the standard Android crypto APIs, so it is a third-party dependency with its own review, pinning, and update process.
- If you have to use PBKDF2, both platforms ship it:
CCKeyDerivationPBKDFandSecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"). Calibrate the iteration count on real devices instead of copying one from another project. - Store the parameters with the envelope and migrate them after a successful unlock.
- Derive separate keys for local wrapping and for any protocol authentication, with domain separation.
- On iOS, hold keys in CryptoKit
SymmetricKey, which zeroes its storage on release, not inDatathat the runtime may copy. A password captured as a SwiftStringcannot be wiped; turn it into bytes for the KDF right away.
KDF cost does not stop a patient person typing guesses into the unmodified app, so add an increasing delay and a persisted failure counter, and require a connected sign-in after a threshold.
A six-digit PIN is a million candidates #
An app PIN is a convenience secret in front of the password. It unlocks its own envelope, and that envelope has to be bound to a non-exportable platform key. Six digits are 10^6 candidates. An unbound PIN envelope falls to offline search, whatever the KDF costs.
The rest of the PIN rules:
- At least six digits; off by default for high-assurance or managed profiles.
- Never store a PIN verifier that is cheaper to attack than the envelope.
- A failure counter scoped to the user that survives process death and reboot, with increasing delay and at most 10 consecutive failures, the upper bound NIST SP 800-63B sets for activation secrets.
- At the threshold, delete the PIN wrapper and ask for the password. Wiping the local data would destroy the only copy of anything not yet synced.
The catch: neither iOS nor Android enforces a retry limit in hardware for an app-defined PIN. NIST’s limit assumes the authenticator enforces it, and here the app does. The counter stops the user and a thief using the unmodified app. On a rooted or jailbroken device, an attacker can query the platform key directly at hardware speed, and only PIN length helps against that.
The biometric prompt must unlock a key #
Biometric success has to authorize the cryptographic operation that unwraps the ADK. If it only flips an isAuthenticated flag, a hooked callback on a rooted device skips it.
Android: pass a CryptoObject #
Generate the key inside Keystore and require authentication for every use:
val spec = KeyGenParameterSpec.Builder(
"offline-data-wrap",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) // API 30+: every use prompts
.setInvalidatedByBiometricEnrollment(true)
.setUnlockedDeviceRequired(true) // API 28+
.setIsStrongBoxBacked(true) // API 28+; on StrongBoxUnavailableException, retry in the TEE
.build()
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }
.generateKey()
Then hand the initialized cipher to BiometricPrompt, and decrypt only with the cipher you get back:
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
try {
cipher.init(Cipher.DECRYPT_MODE, wrappingKey, GCMParameterSpec(128, envelope.iv))
} catch (e: KeyPermanentlyInvalidatedException) {
deleteBiometricWrapper() // enrolled biometrics changed: fall back to the password
return
}
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val unlocked = result.cryptoObject!!.cipher!!
unlocked.updateAAD(envelope.aad)
val adk = unlocked.doFinal(envelope.ciphertext)
// ...
}
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock offline data")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText("Use password")
.build(),
BiometricPrompt.CryptoObject(cipher),
)
Set the accepted authenticators explicitly. A default that includes DEVICE_CREDENTIAL means the device PIN opens the offline data, and so does anyone who watched the owner type it. If you want that fallback, make it a written product decision. Since this is a symmetric key with authentication required, creating the wrapper at enrollment also needs a prompt; ask for the password first, as with any new factor.
iOS: a Secure Enclave key and ECIES #
Generate the wrapping key in the Secure Enclave with biometric access control, and wrap the ADK with ECIES:
var error: Unmanaged<CFError>?
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, // the OS deletes it if the passcode is removed
[.privateKeyUsage, .biometryCurrentSet], // no passcode fallback
&error
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: Data("offline-data-wrap".utf8),
kSecAttrAccessControl as String: access,
],
]
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)!
let wrapped = SecKeyCreateEncryptedData(
SecKeyCopyPublicKey(privateKey)!,
.eciesEncryptionCofactorX963SHA256AESGCM,
adk as CFData,
&error
)
To unwrap, fetch the key with a fresh LAContext passed as kSecUseAuthenticationContext, with touchIDAuthenticationAllowableReuseDuration left at 0, and call SecKeyCreateDecryptedData. The prompt the user sees then authorizes this key use. That is the iOS counterpart of Android’s per-use authentication.
Two details to get right:
- The accessibility class goes into
SecAccessControlCreateWithFlags. It cannot be combined with a separatekSecAttrAccessibleattribute on the same item. .biometryCurrentSetinvalidates the key when Face ID or Touch ID enrollment changes. Handle that by deleting the wrapper and requiring the password. A.devicePasscodeor.userPresencefallback lets anyone who knows the device passcode into the offline data.
Data Protection classes that fail after lock #
Set a Data Protection class per store instead of relying on container defaults. The complete class key is discarded about 10 seconds after the device locks, which bites in three places:
- Plaintext temporaries should use
complete. - Background downloads of offline files need
completeUnlessOpen. - A SQLite database in WAL mode under
completefails when anything touches it after lock. Either stop database access onprotectedDataWillBecomeUnavailable, or usecompleteUntilFirstUserAuthenticationfor the database and rely on field-level AEAD.
Files in tmp/ and elsewhere in the container default to completeUntilFirstUserAuthentication unless you set otherwise.
Offline expiry needs a clock that counts sleep #
Enforcing maximum offline age with wall-clock time alone fails the moment someone sets the clock back. I covered that half in An offline cache TTL needs a clock-rollback policy . The research added three things.
The freshness record has two parts. A server-signed part (check-in time, policy version, expiry duration), verified with the server’s public key. A local part (wall time at check-in, monotonic time at check-in, boot reference, accumulated elapsed time), authenticated with a keystore device key. Do not protect it with the ADK: freshness is evaluated before accepting any factor, so the record must verify while the app is locked.
The monotonic clock must keep counting while the phone sleeps, because a phone is asleep most of the time:
| Platform | Use | Stops during sleep |
|---|---|---|
| iOS | mach_continuous_time(), clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) | mach_absolute_time(), ProcessInfo.systemUptime |
| Android | SystemClock.elapsedRealtime() | SystemClock.uptimeMillis() |
Verify the chosen clock on a device by letting it sleep.
Reboots reset the monotonic clock. Android has Settings.Global.BOOT_COUNT. iOS has no documented boot identifier: kern.boottime moves when the user changes the wall clock, so it detects clock changes, not boots. On iOS, detect a reboot by a monotonic reading lower than the stored one. Then:
update accumulated elapsed time on every foreground, unlock, and lock
same boot: elapsed from the monotonic clock, cross-checked against wall time
after reboot: elapsed = max(accumulated + monotonic_since_boot,
wall_now - wall_at_checkin)
wall_now < last stored wall time → rollback → stale
A rollback across a reboot can then hide at most the time between the last update and the reboot.
Fail closed on a missing, rolled-back, or inconsistent record, and exclude it from backup. A restore, to another device or to the same one, then leaves the record missing and forces a check-in. Otherwise, a same-device restore brings back an older valid record together with the ThisDeviceOnly Keychain items it belongs to.
These controls resist clock rollback, not state rollback. Nothing stops someone from replacing the current record with an older valid copy, and the expiry is application logic. Like the PIN counter, it binds the unmodified app, not an attacker running their own code against the device keys.
Documents: chunked AEAD and plaintext that outlives the app #
Offline documents, such as invoice PDFs or scanned contracts, need more than per-record encryption. CryptoKit’s AES.GCM and ChaChaPoly seal a whole message in memory. Streaming playback and seeking viewers need chunks, and naive chunking lets an attacker truncate, reorder, or drop chunks without failing authentication. Use a format in the style of the STREAM construction:
- fixed-size chunks (say 64 KiB), each sealed separately;
- a nonce built from a random per-file prefix and the chunk index;
- file ID, format version, chunk index, and a final-chunk flag in each chunk’s authenticated data;
- an authenticated header with the prefix, chunk size and version;
- a new per-file key or nonce prefix whenever the file changes, so no key and nonce pair is ever reused.
Prefer decrypting into memory or a pipe for an in-app viewer. QLPreviewController wants a file URL, so some formats force a plaintext file. When one is unavoidable, put it in a dedicated private directory under complete, with an unpredictable name, excluded from backup and indexing, shared only through a narrow, non-exported FileProvider grant on Android.
The trap is cleanup. A suspended iOS app runs no code, so no timer deletes the file on schedule, and cancellation, render errors, crashes, process death, and reboot all skip view-level cleanup. Make the launch path enumerate and delete expired temporaries before it allows an unlock.
Lock, logout, and Keychain items that survive uninstall #
Lock keeps the encrypted state and destroys the usable keys: release the ADK and derived keys, close plaintext streams, blank the app-switcher snapshot, invalidate decrypted caches, and stop background jobs from quietly recreating an unlocked session. A suspended app cannot clear the clipboard later, so set the expiry when you copy:
UIPasteboard.general.setItems(
[[UTType.utf8PlainText.identifier: secret]],
options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(60)]
)
.localOnly keeps it off Universal Clipboard. On Android, mark the clip with ClipDescription.EXTRA_IS_SENSITIVE (API 33+) and clear it with ClipboardManager.clearPrimaryClip().
Logout makes the state permanently unusable. Physical overwrite on flash is unreliable, so the primary boundary is cryptographic erasure: destroy every usable wrapping key, then delete tokens, databases with their WAL and journal files, indexes, cached files, temporaries, pending operations, and shared-container state.
On iOS, uninstalling the app deletes its container but leaves its Keychain items, and no app code runs during uninstall. Keep a marker file in the container. When it is missing at launch, delete every Keychain item the app owns, shared access groups included, before doing anything else. Otherwise, a reinstall can find the previous install’s wrappers.
Autofill is offline unlock under another name #
If the cached data includes credentials and the app offers an autofill provider, that provider releases secrets from the local cache with no server call, so every rule above applies to it. The platforms constrain it differently.
On iOS, the Credential Provider extension is a separate process with its own unlock session. It sees the app’s state only through a shared Keychain access group and an App Group container, so every key and wrapper it needs, including the Secure Enclave device key and the freshness record, must live in the shared group. It runs under a memory limit Apple describes as only slightly lower than an app’s. That limit caps the Argon2id memory parameter, so benchmark inside the extension on the weakest device. Do not give the extension a cheaper copy of the password envelope: the weakest wrapper sets the strength of all the data it protects. If parameters that fit are too weak, let the extension offer biometrics only and send password users to the app.
An LAContext evaluated in the app does not carry over to the extension. Within one request, the extension can pass one evaluated context to several Keychain reads, so a single prompt covers a list. The system may reuse the extension process for the next request, so nothing decrypted may stay in a static.
On Android, the AutofillService runs in the app’s own process unless the manifest says otherwise. Its authentication activity still needs a CryptoObject, and it should accept the same authenticators as the app.
Rules for both:
- Give the provider its own key that wraps only what a fill needs (username, password, URL), so a faulty extension cannot decrypt documents, notes, or indexes.
- Share the failure counter between app and provider. Otherwise, an attacker doubles the allowed attempts by alternating between them.
- The provider reads freshness and never checks it itself. When stale, it fills nothing and says which app to open.
- On iOS, the identity store shows registered usernames next to their domains in the QuickType bar to anyone holding the unlocked phone, with the app locked. Replace the whole identity set after each sync instead of removing and re-adding the current ones, so deleted entries disappear, and consider a managed policy that registers none.
Verify who is asking #
The requester check is where autofill phishing happens (Aonzo et al., Phishing Attacks on Modern Android, CCS 2018 ). iOS identifies websites and apps through associated domains. On Android, the provider has to do it:
- Verify the requesting app’s signing certificate against the domain’s Digital Asset Links
, not its package name. An app named
com.examplebank.evilis not Example Bank. - Trust browsers by package name and certificate digest together. On a device without a listed browser, a sideloaded app can take that browser’s package name and report any web domain.
- Derive registrable domains from the Public Suffix List
, not a hand-written list of suffixes. Otherwise, an entry saved for
victim.github.iocollapses togithub.ioand fills inattacker.github.io.
Users without a password #
Everything above assumes every user has a password. Users who sign in through enterprise SSO or a partner’s authenticator app do not. In this design, the password has five jobs:
- the baseline wrapper of the ADK;
- the fallback when a biometric wrapper is invalidated;
- the authorization for a new biometric wrapper;
- the replacement when the PIN hits its threshold;
- the recent password entry that sensitive actions require.
Each job needs a new owner. The options I found:
- Connected sign-in takes every fallback role. This one always applies. A user who loses their last local factor while offline has no offline access until they reconnect.
- A mandatory app passcode becomes the baseline factor. Set it during connected provisioning and wrap the ADK with it like the password (KDF, then device key). Three PIN rules change. Six digits stop being enough, because nothing stands behind the passcode: at 0.5 seconds per guess on a compromised device, 10^6 candidates take under six days. Allow alphanumeric passcodes with a real minimum length. The threshold sends the user to connected sign-in, and choosing a new passcode needs one too. The user gets a second secret to remember; that is the price of an offline knowledge factor.
- The device passcode can back up the biometric wrapper. It avoids losing offline access at the first biometric change and reopens the “watched the owner type it” risk. Make it explicit, let administrators forbid it, and check on a device whether iOS still releases a
biometryCurrentSetordevicePasscodeitem through the passcode after enrollment changes. - A recovery key can have enough entropy that a copied wrapper is safe even without device binding. Users lose these, and nobody can reset one, so offer it only where a lost factor can cost data.
What does not work is anything the identity provider issued. A cached refresh token or ID token proves the device holds a token, not that the user is holding the device. Don’t wrap the ADK under it, and delete the identity provider’s token cache after the server exchange.
Check one thing in your own protocol. If the server opens a passwordless user’s data without any secret from the user, it can provision the ADK again at the next sign-in. A lost factor then costs offline access, not data, as long as offline records stay read-only. Unsynced offline edits are the exception, because they exist only on the device.
Decide the factor set at provisioning from the login method, and store it with the policy. If the same user later provisions through a password login, re-enroll; don’t bolt a password wrapper onto a passwordless enrollment.
Caveats #
- The ADK’s scope is a protocol decision. If the ADK only protects this device’s cache, logout and revocation destroy it. If it is the user’s end-to-end key shared with the server and other devices, a device revoked while offline keeps a copy that decrypts future data, and revoking it means rotating the key.
- Offline edits are a separate project. Ship read-only offline records first. Editable mode needs an encrypted operation log separate from server-confirmed state, conflict rules that keep both versions when a merge could lose data, and an authorization recheck for each operation on reconnect.
- On rooted and jailbroken devices, hardware-backed keys and integrity signals raise the cost. Retry counters and expiry are app logic and don’t bind an attacker who runs their own code against the keys. Don’t claim otherwise.
Test the failure paths #
Happy-path testing says nothing about whether keys and plaintext stay contained. The release gates I would not skip:
| Area | Test |
|---|---|
| Provisioning | A never-enrolled device fails offline; restoring a backup to another device, or an older backup to the same one, forces a check-in |
| Crypto | Copied password and PIN wrappers cannot be opened off the device; ciphertext swapped between records or between fields of one record fails authentication |
| Biometrics | Add and remove enrolled biometrics, change and remove the passcode, reboot, process death |
| Retries | Counters survive relaunch and reboot; the PIN threshold removes only the PIN wrapper |
| Freshness | Clock forward and back, time zone change, reboot, a long sleep counted toward expiry, a replayed older record, the exact boundary; a stale device never prompts |
| Documents | Truncated, reordered, and dropped chunks fail; kill the app mid-preview and confirm the temporary is gone before the next unlock |
| Deletion | Uninstall and reinstall leave no usable Keychain state; removing one user leaves another intact |
| Autofill origin | A look-alike package name gets no match; victim.github.io is not offered on attacker.github.io |
OWASP MASVS and MASTG , NIST SP 800-63B , NIST SP 800-132 for PBKDF2, and RFC 9106 for Argon2id set the baseline. They don’t decide where your keys live or what happens when a preview crashes.
Takeaway #
Offline, the app can only answer “is this the enrolled user, on the enrolled device, within the allowed window?” Every unlock path, including the password, should require the device’s hardware key; every biometric prompt should gate a key operation; and the window should be measured by a clock that keeps counting while the phone sleeps.
If your app already has an offline mode, copy its sandbox to another machine and try to open the password wrapper there. If that attempt gets as far as a KDF, the password path is missing its device key.