Saltar al contenido
Mis Guidelines
Guías
En esta guía (15 elementos)

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 assumptionMobile reality
The binary is privateThe .ipa/.apk is downloadable and unzippable
Local storage is protectedRooted/jailbroken devices read app storage freely
Client-side checks helpThey are advisory at best; the client can be patched
TLS is enoughThe 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.

ValueWhere it goes
API base URL, public feature flagsEXPO_PUBLIC_* — fine, they are public
Third-party publishable keys (Stripe publishable, Sentry DSN)Bundle — designed to be public
Third-party secret keysServer only. Proxy the call through your backend.
Signing credentials, store API keysEAS Secrets — build-time only, never in the bundle
Per-user tokensObtained 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

DataStoreEncrypted
Access/refresh tokens, credentials, PIIexpo-secure-store (iOS Keychain / Android Keystore)✅ by the OS
Non-sensitive cache, preferences, UI stateMMKV❌
Structured non-sensitive dataexpo-sqlite❌ unless SQLCipher
Anything sensitiveAsyncStorage❌ 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:

application/auth/port/TokenStore.ts
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 userId or role.
  • 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=false on 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:

SurfaceThreat
Deep / universal linksAny app on the device can open them, with any parameters
Push payloadsServer-controlled but still unvalidated by the client
Persisted storageWritten by an older version, or tampered with
API responsesContract drift, or a proxying attacker
Clipboard, QR, file importFully user- or attacker-supplied

Deep links deserve special care because they are an unauthenticated entry point into arbitrary screens:

app/expense/[id].tsx
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.log from production builds — babel-plugin-transform-remove-console.
  • Sentry: enable PII scrubbing, and add beforeSend to 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_SECURE on 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 audit in CI. Fail on high and critical.
  • Pin exact versions. No ^, no latest in 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/MMKVexpo-secure-store
Secret key in EXPO_PUBLIC_*Server-side proxy
Login in a WebViewexpo-web-browser + PKCE
Password grantAuthorization Code + PKCE
Client-side role check as the controlServer authorizes every request
TLS validation disabled “for dev”Never disabled anywhere
console.log(response) in releaseConsole stripped; correlation ids only
Deep link param trusted as identityParsed, then authorized server-side
Sensitive content in a push bodyIdentifier only; fetch after unlock
Permissions requested at launchAt point of use, with rationale
"react-native-x": "^1.2.0" in releaseExact pin + lockfile
Root detection as the only gateServer-side risk signal

14. Pre-merge checklist

  • No new secret in the bundle, app.config.ts or EXPO_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