Threat model: what you are protecting and from whom
Offline-first apps keep meaningful data on-device for long periods: user-generated content, cached server data, attachments, and metadata that drives sync. “Security for data at rest and secure sync transport” means protecting confidentiality and integrity in two places: (1) local storage on the device (data at rest) and (2) data moving between device and server (data in transit). The right design starts with a simple threat model that is specific to mobile realities.
Common threats for data at rest include: a lost or stolen device; malware or a malicious app reading shared storage; a rooted/jailbroken device where sandbox boundaries are weakened; physical acquisition of a device backup; and developer mistakes such as logging sensitive payloads or leaving debug databases unencrypted. Threats for transport include: man-in-the-middle interception on hostile Wi‑Fi; TLS downgrade or certificate substitution; replay of captured requests; tampering with responses; and leakage through misconfigured proxies or analytics.
From this threat model, define what must be confidential (e.g., message bodies, notes, PII, access tokens, encryption keys), what must be integrity-protected (e.g., operation payloads, file chunks, server responses), and what can be public (e.g., non-sensitive feature flags). Then decide the “security boundary”: typically the device OS keystore plus your app process. Anything outside that boundary (disk, backups, network, logs) should be treated as hostile.
Data at rest: encryption, key management, and storage boundaries
Encrypting local databases and files
Encryption at rest is only as strong as the key management. The typical pattern is: generate a random symmetric key (e.g., 256-bit) for encrypting your local database and file blobs, store that key in the OS-provided secure keystore, and never hardcode it or derive it from weak user input.
For structured data (SQLite-based stores, Realm, etc.), use a database encryption option (e.g., SQLCipher or platform-provided encrypted storage). For files (attachments, cached images, exported documents), use per-file encryption or an encrypted container directory. Prefer authenticated encryption (AEAD) modes such as AES-GCM or ChaCha20-Poly1305 to get confidentiality and integrity together.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
Practical guidance:
- Encrypt the database file itself, not just specific columns, unless you have a strong reason (column-level encryption can leak metadata and is easy to misapply).
- Use separate keys for database and file storage, or at least separate key identifiers, to allow rotation and scoped access.
- Do not store encryption keys in SharedPreferences/UserDefaults/plain files.
- Do not rely on “device encryption” alone; assume an attacker can obtain app files via backups or rooted access.
Key hierarchy and envelope encryption
A robust approach is envelope encryption: a master key is protected by the OS keystore, and that master key encrypts (wraps) one or more data keys. Data keys encrypt the actual content. This lets you rotate data keys without re-encrypting everything with a new master key, and it allows you to invalidate a subset of data by deleting a wrapped key.
Example hierarchy:
- Keystore key: non-exportable key stored in Android Keystore / iOS Keychain + Secure Enclave when available.
- App master key: random bytes, encrypted (wrapped) by the keystore key.
- Database key and File key: random bytes, encrypted by the app master key.
When the app starts, it asks the keystore to decrypt the wrapped master key, then uses it to decrypt the database/file keys. The decrypted keys live only in memory and should be wiped when possible (best-effort in managed runtimes).
Binding keys to device state and user authentication
Mobile OS keystores can enforce access control policies: “only when device is unlocked,” “require biometrics,” or “invalidate on new biometric enrollment.” Use these policies to match your app’s risk profile.
- Low friction: keys accessible when the device is unlocked. Good for most consumer apps where offline access is expected.
- Higher security: require user presence (biometric/PIN) to unlock the encryption key after app launch or after inactivity. This reduces exposure if the device is stolen while unlocked.
- Enterprise: integrate with device management policies; consider “wipe on too many failed attempts,” and disable backups.
Be careful: requiring biometrics for every database access can break background processing and degrade UX. A common compromise is to unlock the data key once per session and re-lock after a timeout or app backgrounding.
Backups, screenshots, and OS-level leakage
Even with encryption, data can leak through secondary channels:
- Device backups: ensure encrypted app data is not exported in plaintext. On Android, consider disabling auto-backup for sensitive apps or ensure keys are not backed up (keystore keys are typically not exportable). On iOS, use appropriate data protection classes and mark highly sensitive files as excluded from backups if necessary.
- App switcher snapshots: prevent sensitive screens from appearing in OS task switcher previews (e.g., secure window flags on Android; blur/cover on iOS when backgrounding).
- Notifications: avoid putting sensitive content in notification bodies on locked screens unless user opts in.
- Logs and crash reports: never log raw payloads, tokens, encryption keys, or full file paths that reveal user data. Redact by default.
Data minimization and compartmentalization
Offline-first does not mean “store everything forever.” Reduce blast radius by minimizing what you store and how long you keep it:
- Store only fields needed for offline functionality; avoid caching full PII if not required.
- Use per-account storage namespaces so logging out can reliably delete that user’s local data.
- Separate highly sensitive datasets into a distinct encrypted store with stricter access controls (e.g., “vault” vs “cache”).
Step-by-step: implementing encrypted local storage with key rotation
The exact APIs differ by platform, but the steps are consistent. The following procedure is a practical blueprint you can adapt.
Step 1: Create a keystore-backed wrapping key
- Generate a non-exportable key in the OS keystore (RSA or EC for wrapping, or an AES key if supported for wrapping).
- Set access controls: “device unlocked” at minimum; optionally “user authentication required” for high sensitivity.
Step 2: Generate an app master key and wrap it
- Generate 32 random bytes using a cryptographically secure RNG.
- Wrap (encrypt) the master key using the keystore key.
- Store the wrapped master key in app-private storage (it is safe to store because it is encrypted).
Step 3: Generate data keys (database and files) and wrap them with the master key
- Generate a database key and a file key (32 bytes each).
- Encrypt each with the master key using AEAD (store nonce/IV alongside ciphertext).
- Store wrapped data keys in app-private storage.
Step 4: Open encrypted database and encrypt files
- On startup, unwrap master key via keystore, then unwrap database key.
- Open the database with the database key (SQLCipher-style) or use the platform’s encrypted database facility.
- When writing a file, generate a random nonce, encrypt with file key using AEAD, and store: header (version, nonce), ciphertext, and authentication tag.
Step 5: Rotate keys safely
Key rotation is not only for compliance; it is a recovery tool if you suspect compromise. A practical rotation plan:
- Rotate data keys periodically or on major app upgrades. Create a new database key, re-encrypt the database (or export/import), then delete the old key.
- Rotate master key less frequently. Generate a new master key, re-wrap data keys, then delete the old wrapped master key.
- Emergency wipe: delete wrapped keys and encrypted files; without keys, ciphertext is useless.
Keep a versioned key metadata record so the app can detect which key version encrypted which data. For files, include a small header:
struct EncryptedFileHeader { uint8 version; uint8 keyId; uint8 nonceLen; bytes nonce; // followed by ciphertext + tag}Secure sync transport: TLS, certificate validation, and pinning
Use TLS correctly (and assume hostile networks)
All sync traffic must use HTTPS with modern TLS. “Using HTTPS” is not enough if certificate validation is bypassed or if your app accepts user-installed CAs without consideration. Ensure:
- TLS 1.2+ (prefer TLS 1.3 where available).
- Strong cipher suites (platform defaults are usually fine if you keep OS support current).
- Strict hostname verification and certificate chain validation.
- No fallback to HTTP, even for “initial bootstrap” or “health checks.”
Certificate pinning: when and how
Pinning reduces the risk of man-in-the-middle attacks by restricting which certificates/keys your app will trust. It is most useful when your threat model includes hostile proxies or compromised certificate authorities. However, pinning increases operational risk: if you rotate certificates incorrectly, you can brick connectivity for older app versions.
Safer pinning strategies:
- Pin to public key (SPKI) rather than leaf certificate, so you can renew certificates without changing the key (or pin multiple keys during migration).
- Pin a set: include current and next pins to allow planned rotation.
- Fail closed for high-security apps; consider a controlled “break glass” mechanism only if you can update pins quickly and securely (e.g., via app update, not via an unauthenticated remote config).
On Android, use Network Security Config for pinning and to disallow cleartext traffic. On iOS, use ATS (App Transport Security) and implement pinning in your networking stack if needed.
Integrity and replay protection beyond TLS
TLS protects the channel, but you often need message-level integrity and replay defenses, especially for queued operations that may be retried, delayed, or sent from background contexts. Common patterns:
- Request signing: compute an HMAC over canonical request components (method, path, body hash, timestamp, nonce) using a secret known to client and server. This detects tampering even if TLS is terminated by an internal proxy.
- Nonce + timestamp: server rejects old timestamps and reused nonces to prevent replay.
- Idempotency keys: include a unique idempotency key per operation so the server can safely deduplicate retries (this also helps reliability, but here it prevents replay from causing duplicate effects).
Be careful not to reuse the same secret for both encryption and HMAC; use separate keys or use an AEAD scheme where appropriate. For request signing, store the signing secret securely (keystore-protected) and rotate it when sessions change.
Step-by-step: adding request signing with replay defense
This is a practical outline; adapt to your backend.
Step 1: Define a canonical string to sign
Canonicalization must be deterministic across platforms. Example components:
- HTTP method (uppercase)
- Path + query (normalized)
- SHA-256 hash of body (hex/base64)
- Timestamp (Unix seconds)
- Nonce (random 16 bytes base64)
stringToSign = METHOD + "\n" + PATH + "\n" + BODY_SHA256 + "\n" + TIMESTAMP + "\n" + NONCEStep 2: Compute signature
Use HMAC-SHA256 with a per-session secret:
signature = base64(HMAC_SHA256(sessionSecret, stringToSign))Step 3: Send headers and enforce server checks
- Client sends:
X-Signature,X-Timestamp,X-Nonce, and a key identifier if needed. - Server verifies signature, checks timestamp window (e.g., ±5 minutes), and checks nonce uniqueness per session (store recent nonces in a cache).
Step 4: Handle clock skew and offline queues
Offline-first queues can hold operations for hours. A strict timestamp window would reject them. Two options:
- Sign at send time: store the operation payload locally, but compute timestamp/nonce/signature only when the request is actually sent.
- Use server-issued one-time tokens: for high-security actions, require a fresh server challenge when online; queued operations that require freshness can be blocked until reauthorized.
End-to-end encryption (E2EE) considerations for offline-first sync
Transport security protects data between client and server, but the server can still read plaintext. If your product requires that even the server cannot read user content (notes, messages, documents), you need end-to-end encryption. E2EE changes your sync design because the server becomes a blind storage and routing layer.
Key ideas:
- Client-side encryption: encrypt content before writing to local storage and before uploading.
- Per-item keys: each document/message can have its own symmetric key; that key is then encrypted for each authorized device/user.
- Key distribution: devices need a way to share keys securely (QR code pairing, encrypted key bundles, or a user passphrase-derived key with strong KDF).
- Search and indexing: server-side search is not possible on plaintext; you may need local indexing or privacy-preserving search schemes (often complex).
Even if you do not implement full E2EE, you can still apply “application-layer encryption” for especially sensitive fields so that only the client can decrypt them, while leaving non-sensitive metadata in plaintext for sync routing.
Secure handling of attachments and large blobs
Attachments are often the largest and most sensitive data at rest. Apply consistent rules:
- Encrypt before upload if you need confidentiality from the server or from intermediate storage (CDNs, object stores).
- Use content hashing to detect corruption: store SHA-256 of plaintext (or ciphertext, depending on your design) and verify after download.
- Use per-file nonces and never reuse a nonce with the same key in AEAD modes.
- Stream encryption for large files: encrypt/decrypt in chunks while maintaining integrity (use chunked AEAD with per-chunk nonces and an overall manifest).
A practical chunked format:
- Manifest JSON (encrypted or signed) containing: fileId, chunkSize, totalChunks, per-chunk hashes, encryption parameters, and keyId.
- Each chunk encrypted with AEAD using nonce = fileNonce || chunkIndex.
Secrets hygiene: tokens, keys, and sensitive metadata
Never store long-lived secrets in plaintext
Even if you covered authentication earlier, it matters here because tokens are “data at rest.” Store refresh tokens and session secrets only in keystore/keychain-protected storage. If you must cache access tokens, keep them in memory and refresh as needed.
Protect against accidental exposure
- Redact sensitive fields in analytics and crash reports (implement a centralized redaction utility).
- Avoid writing decrypted content to temporary files; if unavoidable, store temp files in encrypted app-private directories and delete promptly.
- Be careful with “share” intents and export features: require explicit user action and consider re-encrypting exported files with a user-chosen password when appropriate.
Platform-specific implementation notes (Android and iOS)
Android
- Use Android Keystore for non-exportable keys; prefer hardware-backed keys when available.
- Use Jetpack Security (EncryptedSharedPreferences, EncryptedFile) for simpler cases, but evaluate performance and file size overhead for large datasets.
- Use Network Security Config to disable cleartext and optionally pin certificates.
- Set
FLAG_SECUREon sensitive activities to prevent screenshots and screen recording.
iOS
- Use Keychain with appropriate accessibility (e.g.,
kSecAttrAccessibleWhenUnlockedor stricter). - Use Data Protection classes for files (NSFileProtection) to ensure encryption by the OS when locked, and combine with app-level encryption for stronger guarantees.
- Use ATS to enforce HTTPS and strong TLS; implement pinning if required by threat model.
- Handle backgrounding by obscuring sensitive UI to avoid snapshot leakage.
Operational practices: secure defaults, migrations, and incident response
Secure defaults and configuration hardening
- Disable debug endpoints and verbose logging in release builds.
- Fail closed on TLS errors; do not allow “accept all certificates” even temporarily.
- Use feature flags carefully: never deliver secrets or encryption keys via remote config.
Secure migrations
Offline-first apps evolve their schemas and storage formats. Migrations are a common place to accidentally write plaintext. When migrating:
- Perform migrations inside the encrypted database context.
- If you must export/import, ensure the intermediate export is encrypted and stored in app-private storage.
- After migration, securely delete old files and vacuum/compact databases if your storage engine leaves remnants (note: secure deletion is not always guaranteed on flash storage; encryption is the primary defense).
Incident response hooks
Plan for compromise scenarios:
- Remote logout / key invalidation: on next connectivity, instruct the app to delete wrapped keys and local data for the account.
- Local wipe: provide a user-facing “wipe offline data” option.
- Compromised device detection: treat root/jailbreak detection as a signal, not a guarantee; you may restrict offline storage or require re-authentication for sensitive actions.