In this guide (15 items)
Mobile Security
Mobile Security
MANDATORY FOR ALL AI AGENTS: Tokens, credentials and PII MUST be stored in
expo-secure-store, NEVER in AsyncStorage or MMKV — neither is encrypted. NO secret of any kind may be embedded in the app bundle; everything shipped to a device is readable. Deep link parameters are attacker-controlled and MUST be parsed and never trusted for authorization. Never log tokens, PII or full request bodies in release builds.
Companion command: /mobile-init-project — generates .env.example, the SecureStore token port and the strict app.config.ts variant setup.
This is the mobile counterpart of security.md. STRIDE, threat modelling and the compliance sections there still apply; this guide covers what is different on a device you do not control. Architecture context: mobile-architecture.md.
1. The one thing that changes
On a server, you control the machine. On mobile, the attacker owns the device.
The consequences follow from that single fact:
| Server assumption | Mobile reality |
|---|---|
| The binary is private | The .ipa/.apk is downloadable and unzippable |
| Local storage is protected | Rooted/jailbroken devices read app storage freely |
| Client-side checks help | They are advisory at best; the client can be patched |
| TLS is enough | The user may install a proxy CA to read all traffic |
Every security control that matters is enforced on the server. Client-side controls improve UX and raise the cost of attack; they never provide the guarantee.
2. Secrets: there are none in the client
⚠️ Anything shipped in the bundle is public. EXPO_PUBLIC_* variables, values in app.config.ts, hardcoded strings and API keys are all extractable in minutes with standard tooling. Obfuscation delays this; it does not prevent it.
| Value | Where it goes |
|---|---|
| API base URL, public feature flags | EXPO_PUBLIC_* — fine, they are public |
| Third-party publishable keys (Stripe publishable, Sentry DSN) | Bundle — designed to be public |
| Third-party secret keys | Server only. Proxy the call through your backend. |
| Signing credentials, store API keys | EAS Secrets — build-time only, never in the bundle |
| Per-user tokens | Obtained at runtime, stored in SecureStore |
If a third-party SDK requires a secret key on the client, that SDK is unusable in a mobile app. Put it behind your own endpoint.
.env files are never committed. Commit .env.example with empty values. Scan for sk-, ghp_, AKIA, password=, secret= before every commit, same as security.md.
3. Storage
| Data | Store | Encrypted |
|---|---|---|
| Access/refresh tokens, credentials, PII | expo-secure-store (iOS Keychain / Android Keystore) | ✅ by the OS |
| Non-sensitive cache, preferences, UI state | MMKV | ❌ |
| Structured non-sensitive data | expo-sqlite | ❌ unless SQLCipher |
| Anything sensitive | AsyncStorage | ❌ never use |
⚠️ MMKV and AsyncStorage are plain files. On a rooted or jailbroken device — or from a device backup — they are readable in clear text. MMKV supports an encryption key, but that key must itself live in SecureStore, and at that point the data belongs in SecureStore.
SecureStore has a size limit (a few KB per entry). It is for tokens and keys, not for datasets. Encrypt a larger local database with SQLCipher and keep only its key in SecureStore.
Behind a port, as always:
export interface TokenStore { save(tokens: AuthTokens): Promise<void> read(): Promise<AuthTokens | null> clear(): Promise<void>}Use cases never know whether the implementation is Keychain, Keystore or an in-memory fake in a test.
4. Authentication
- OAuth2 / OIDC with PKCE. Authorization Code + PKCE is the only correct flow for a public client. There is no client secret to protect, which is exactly why PKCE exists.
- Use
expo-auth-session. Do not hand-roll the flow. - ❌ Never the Resource Owner Password flow. The app must never see the user’s password.
- ❌ Never an embedded WebView for login — it defeats SSO and lets the app observe credentials. Use
expo-web-browser, which opens the system browser (ASWebAuthenticationSession / Custom Tabs). - Access tokens short-lived; refresh tokens in SecureStore.
- Logout clears SecureStore, the query cache and any persisted state. A stale cache surviving logout leaks the previous user’s data on a shared device.
- Refresh must be single-flight: concurrent 401s must not fire N refresh calls and invalidate each other.
Keycloak specifics: keycloak.md.
Biometrics
expo-local-authentication gates access to a token already held — it is a local convenience, not an authentication factor for your server. The server still validates the token. Always offer a non-biometric fallback.
5. Authorization
All authorization is server-side. Hiding a button is UX, not a control.
- The server authorizes every request from the token; it never trusts a client-supplied
userIdorrole. - The client may read roles from a token to decide what to render — never to decide what is permitted.
- Never trust an id from a deep link, a push payload or a route param to identify the current user.
6. Transport
- HTTPS only. Never disable certificate validation, not even in development — the flag survives into production more often than anyone expects.
- Certificate pinning when handling financial, health or credential data. It defeats casual proxy interception. Ship at least two pins (current + backup) and a rotation plan; a pinned app with an expired pin is bricked until an app-store update lands.
- ATS on iOS and
cleartextTrafficPermitted=falseon Android stay at their secure defaults. Exceptions require an ADR. - No sensitive values in URLs — query strings land in server logs and proxy caches. Use headers or the body.
7. Untrusted input
Same boundary rule as mobile-architecture.md §7, viewed as a threat surface:
| Surface | Threat |
|---|---|
| Deep / universal links | Any app on the device can open them, with any parameters |
| Push payloads | Server-controlled but still unvalidated by the client |
| Persisted storage | Written by an older version, or tampered with |
| API responses | Contract drift, or a proxying attacker |
| Clipboard, QR, file import | Fully user- or attacker-supplied |
Deep links deserve special care because they are an unauthenticated entry point into arbitrary screens:
const parsed = expenseIdSchema.safeParse(id)if (!parsed.success) return <NotFound />// The screen still fetches through the API, which authorizes server-side.Never let a deep link carry a token, and never let it perform a state-changing action without an explicit user confirmation in-app.
Verify universal links / App Links properly (apple-app-site-association, assetlinks.json) so another app cannot claim your domain.
8. Logging
- ❌ Never log tokens, passwords, PII or full request/response bodies in release.
- Strip
console.logfrom production builds —babel-plugin-transform-remove-console. - Sentry: enable PII scrubbing, and add
beforeSendto drop auth headers and known sensitive fields. - Include correlation ids (traceId, anonymous session id) so support can debug without payloads. Same principle as obs-logging.md.
- Analytics events carry ids and enums, never free-text user content.
9. Platform hardening
- Screenshot/recording protection on sensitive screens (
FLAG_SECUREon Android, native equivalent on iOS) — also blanks the app-switcher preview. - Autofill and keyboard caching disabled on secret fields:
secureTextEntry,autoComplete="off",textContentType="none"where appropriate. - Exclude sensitive files from iCloud/Android backups.
- Root/jailbreak detection is a signal, not a gate. It is bypassable; use it to raise risk scoring server-side, never as the only barrier.
- Session timeout with re-authentication for high-value actions.
- ⚠️ Do not push sensitive content in notification bodies — they render on a locked screen. Push an identifier; fetch the content after unlock.
10. Dependencies
The supply chain is the most common realistic attack path.
npm audit/pnpm auditin CI. Fail on high and critical.- Pin exact versions. No
^, nolatestin a released app. - Committed lockfile, always.
- Review any package that requests native permissions or postinstall scripts.
- Knip to find unused dependencies — code you do not use is attack surface you cannot audit.
- Every permission in the manifest is justified. Reviewers reject unexplained permissions, and users uninstall over them.
11. Privacy and compliance
- Request permissions at the moment of use, with an explanation. Never at launch.
- iOS Privacy Manifest (
PrivacyInfo.xcprivacy) and App Store privacy labels must match what the app actually collects. A mismatch is a rejection and, in some jurisdictions, a regulatory problem. - Android Data Safety form likewise.
- GDPR/CCPA: consent before any tracking; a working data deletion path. See security.md for the compliance detail.
- Collect the minimum. Data you never collected cannot leak.
12. Threat model
Run STRIDE on every new external surface — new screen with an API call, new deep link, new push type, new third-party SDK. Method and template: security.md.
Mobile-specific prompts:
- What happens if this runs on a rooted device?
- What can another app on this device do with our deep links?
- What is readable if the device is lost, unlocked?
- What does an attacker learn by unzipping the binary?
- What does an attacker with a proxy CA see in our traffic?
13. Anti-patterns
| ❌ | ✅ |
|---|---|
| Token in AsyncStorage/MMKV | expo-secure-store |
Secret key in EXPO_PUBLIC_* | Server-side proxy |
| Login in a WebView | expo-web-browser + PKCE |
| Password grant | Authorization Code + PKCE |
| Client-side role check as the control | Server authorizes every request |
| TLS validation disabled “for dev” | Never disabled anywhere |
console.log(response) in release | Console stripped; correlation ids only |
| Deep link param trusted as identity | Parsed, then authorized server-side |
| Sensitive content in a push body | Identifier only; fetch after unlock |
| Permissions requested at launch | At point of use, with rationale |
"react-native-x": "^1.2.0" in release | Exact pin + lockfile |
| Root detection as the only gate | Server-side risk signal |
14. Pre-merge checklist
- No new secret in the bundle,
app.config.tsorEXPO_PUBLIC_* - Tokens and PII only in SecureStore
- Auth flow is PKCE via the system browser
- Logout clears SecureStore, caches and persisted state
- Every new external input parsed with a schema
- New deep links cannot act destructively without in-app confirmation
- Authorization verified server-side, not only hidden in the UI
- No tokens, PII or bodies logged; console stripped in release
- New dependencies audited, pinned, and justified
- New permissions justified and reflected in privacy manifests
- STRIDE run on any new external surface