Security No. 279

Mobile App Security skill for Claude Code

Ship an app binary with nothing worth stealing. Claude reviews iOS, Android, React Native and Flutter apps the way a MASVS assessor does: it scans your own release build for leaked keys, moves tokens into hardware-backed storage, adds SPKI pinning with backup pins and expiry, binds biometrics to keys, and locks down deep links and WebViews. Every finding is mapped to a MASVS control with file evidence.

  • namemobile-app-security
  • size209 lines · ≈2,950 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Reviews and hardens iOS, Android, React Native, Flutter, and Expo apps against OWASP MASVS v2 - Keychain and Keystore storage, no secrets or API keys in the binary, TLS and certificate pinning with backup pins, biometric auth bound to keys, deep link and WebView safety, App Attest and Play Integrity attestation, jailbreak and root signals as defense in depth, and privacy manifests. Use when securing a mobile app, auditing an APK or IPA you own, storing tokens on device, adding pinning, or preparing for a MASVS assessment.

Try asking

  • Audit our Expo app for MASVS L1 issues before the App Store submission
  • Add certificate pinning to our Android app without risking a bricked release
  • Where should our React Native app store refresh tokens, and how do we migrate off AsyncStorage?

Use it when

  • A mobile app stores tokens, payment data, or health data on the device
  • You are preparing for a MASVS or customer security assessment
  • Someone found an API key in your APK or JS bundle
  • You plan to add certificate pinning, biometrics, or attestation

Skip it when

How to check the result

Rescan the release APK or IPA for secrets and manifest flags, confirm tokens appear only in Keychain or Keystore on a test device, and check that an untrusted proxy CA fails while primary and backup pins succeed.

The common mistake it prevents

Pinning the leaf certificate with no backup pin or expiry, so the next certificate renewal breaks every installed copy of the app until users update.

~/.claude/skills/mobile-app-security/SKILL.md

--- name: mobile-app-security description: Reviews and hardens iOS, Android, React Native, Flutter, and Expo apps against OWASP MASVS v2 - Keychain and Keystore storage, no secrets… license: MIT ---

Done well means: the app binary contains nothing that must stay secret, tokens live in hardware-backed storage, all traffic is TLS with a pinning plan that cannot brick the app, sensitive actions are authorized by the server (not by client checks), and every finding maps to a MASVS control with evidence from your own build.

Ground rules

  • Assess only apps the user owns or is authorized to test. Inspect your own release builds; do not reverse-engineer third-party apps.
  • Assume the attacker controls the device. Anything shipped in the APK/IPA (strings, config, JS bundle, Dart snapshot) is public. The server is the only trust anchor.
  • Client-side controls (root detection, obfuscation, pinning) raise cost; they never replace server-side authorization, rate limiting, or attestation checked on the server.
  • Do not provide or apply techniques for bypassing detection, pinning, or attestation. Verification uses your own builds, debug configurations, and documented test tooling.

Workflow

- [ ] 1. Identify stack, data classes, and backend trust boundaries
- [ ] 2. Scan the release artifact for secrets and misconfiguration
- [ ] 3. Storage review (MASVS-STORAGE)
- [ ] 4. Network review (MASVS-NETWORK)
- [ ] 5. Auth and session review (MASVS-AUTH)
- [ ] 6. Platform interaction review (MASVS-PLATFORM)
- [ ] 7. Code, crypto, resilience, privacy (MASVS-CODE/CRYPTO/RESILIENCE/PRIVACY)
- [ ] 8. Report with MASVS IDs; verify fixes on a release build

MASVS v2 map

GroupCore questionWhere it usually fails
MASVS-STORAGEIs sensitive data stored securely and not leaked (logs, backups, screenshots)?Tokens in SharedPreferences/UserDefaults/AsyncStorage; PII in logs
MASVS-CRYPTOIs crypto done with platform APIs and proper key management?Hardcoded keys, home-made encryption
MASVS-AUTHAre auth and authorization enforced server-side; are local auth flows sound?Biometric “success” boolean unlocks everything
MASVS-NETWORKIs all traffic TLS-protected and server identity verified?Cleartext allowed, trust-all TrustManager left from debugging
MASVS-PLATFORMAre IPC, deep links, WebViews, and UI safe?Exported components, unverified deep links, JS bridges
MASVS-CODEAre dependencies current, inputs validated, updates enforced?Old SDKs, no forced-update path
MASVS-RESILIENCEDoes the app resist tampering and analysis where the risk warrants it?No integrity signal reaching the server
MASVS-PRIVACYIs data collection minimized and transparent?Undeclared tracking SDKs, missing privacy manifest

Use the MAS testing profiles to set depth: L1 baseline for every app, L2 for apps handling money, health, or sensitive identity data, R (resilience) only when there is a business threat from tampering or cloning, P for privacy.

2. Scan the release artifact

# Android: inspect your own release APK/AAB
apkanalyzer manifest print app-release.apk | grep -E 'exported|debuggable|allowBackup|usesCleartextTraffic|networkSecurityConfig'
unzip -o -q app-release.apk -d apk_out && grep -rIEo '(AIza[0-9A-Za-z_-]{35}|sk_live_[0-9a-zA-Z]{24,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY)' apk_out | sort -u
# React Native / Expo: the JS bundle ships in plain text
grep -oE '(EXPO_PUBLIC_[A-Z_]+|api[_-]?key[^,]{0,60})' apk_out/assets/index.android.bundle | sort -u | head
# iOS: unzip the IPA and check Info.plist and strings
unzip -o -q App.ipa -d ipa_out && plutil -p ipa_out/Payload/*.app/Info.plist | grep -A5 NSAppTransportSecurity
# Automated static scan of your own build (MobSF)
docker run -it --rm -p 8000:8000 opensecurity/mobile-security-framework-mobsf:latest

Any secret found here is compromised: rotate it, then move the call behind your backend. Keys that must ship (Maps, Firebase config) are fine only if restricted on the provider side by package name plus signing certificate SHA, or iOS bundle ID, and by API.

3. Storage

DataiOSAndroidCross-platform
Refresh/session tokensKeychain, kSecAttrAccessibleWhenUnlockedThisDeviceOnly (or AfterFirstUnlockThisDeviceOnly if background refresh is needed)Android Keystore key wrapping the token; data in app-private storageexpo-secure-store, react-native-keychain, flutter_secure_storage
Files with PIIData Protection NSFileProtectionCompleteApp-private dir, encrypted with a Keystore keyAvoid caching; purge on logout
Non-sensitive prefsUserDefaultsDataStore/SharedPreferencesAsyncStorage / shared_preferences
  • Never store tokens in AsyncStorage, SharedPreferences, UserDefaults, or SQLite in plain text.
  • Jetpack security-crypto (EncryptedSharedPreferences) is deprecated; for new code use Keystore-backed keys directly or Tink, and keep existing data readable during migration.
  • Exclude secrets from backups: Android android:allowBackup="false" or dataExtractionRules excluding token files; ThisDeviceOnly Keychain classes do not migrate to new devices.
  • Hide sensitive screens: WindowManager.LayoutParams.FLAG_SECURE on Android; overlay a privacy view on iOS when the scene resigns active (the app switcher snapshot).
  • Logs: no tokens or PII in Log.d, print, console.log; strip debug logging in release (R8 rules or a logging wrapper). On iOS use os_log with private redaction.
  • Clear tokens and caches on logout and on refresh-token revocation from the server.

4. Network

  • Android: usesCleartextTraffic false (default since API 28); no custom X509TrustManager or HostnameVerifier that accepts all. iOS: no NSAllowsArbitraryLoads; per-domain exceptions only with a written reason.
  • Pin when the threat model includes hostile networks or corporate MITM for high-value apps (banking, health). Otherwise, rely on platform TLS and Certificate Transparency.
  • Pin the SPKI (public key) of your intermediate or a key you control, always with at least one backup pin from a different key, and an expiry so old builds fail open to normal TLS validation instead of bricking.
<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <base-config cleartextTrafficPermitted="false" />
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-06-30">
      <pin digest="SHA-256">PRIMARY_SPKI_BASE64=</pin>
      <pin digest="SHA-256">BACKUP_SPKI_BASE64=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

On iOS 14+, declare pins in Info.plist under NSAppTransportSecurity > NSPinnedDomains (NSPinnedLeafIdentities or NSPinnedCAIdentities with SPKI-SHA256-BASE64). Compute an SPKI hash from your own endpoint:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary | base64

Pinning rollout: ship pins with backup and expiry, monitor TLS failure telemetry, and keep a remote kill switch or forced-update path before rotating server keys.

5. Auth and sessions

  • The server authorizes every request. Hidden buttons, client-side role flags, and local “isPremium” booleans are UX.
  • Biometric login must gate a cryptographic key, not a boolean: Android BiometricPrompt with a CryptoObject over a Keystore key created with setUserAuthenticationRequired(true); iOS Keychain item with SecAccessControl .biometryCurrentSet, read through the Keychain (not LAContext.evaluatePolicy alone).
  • Use OAuth with PKCE through the system browser (ASWebAuthenticationSession, Custom Tabs) for third-party login; never embedded WebViews for credentials. Prefer passkeys for first-party login (see passkeys-webauthn).
  • Short-lived access tokens, rotating refresh tokens, server-side revocation on logout and device loss.

6. Platform interaction

  • Android components: every activity, service, receiver, provider with an intent filter declares android:exported explicitly; exported ones validate callers and inputs. Content providers default to not exported; use signature permissions for internal IPC.
  • Deep links: use verified App Links (assetlinks.json, autoVerify="true") and Universal Links (apple-app-site-association). Treat every parameter as untrusted; never let a link trigger a state-changing action without confirmation, and never route a link URL straight into a WebView.
  • WebViews: disable JavaScript unless needed; no addJavascriptInterface exposed to remote content; setAllowFileAccess(false); load only allowlisted origins; on iOS use WKWebView with message handlers that validate origin.
  • Pasteboard: mark OTP and password fields so they are excluded from suggestions and universal clipboard where the platform supports it.

7. Code, crypto, resilience, privacy

  • Crypto: platform APIs or libsodium/Tink only; keys generated in Keystore/Secure Enclave; no keys in code (see cryptography-basics).
  • Updates: enforce a minimum supported version from the server so you can retire builds with known flaws; keep SDKs and the RN/Flutter runtime current.
  • Resilience (only where the threat model justifies it): R8 minification and obfuscation; server-verified attestation via Play Integrity API and App Attest (or Firebase App Check wrapping both). Verify tokens on your server and use the verdict as a risk signal: step up, rate limit, or block high-risk actions.
  • Jailbreak/root detection is a heuristic a device owner can defeat. Use it to inform server-side risk scoring and to warn users, not to hard-block (you will lock out legitimate users on custom ROMs and gain little).
  • Privacy: ship PrivacyInfo.xcprivacy with required-reason API declarations and third-party SDK manifests; keep the Play Data safety form consistent with actual SDK collection; request permissions at the point of use.

Report template

# Mobile security review: <app> <version> (<platforms>), build <sha>
**Profile:** MAS L1 / L2 / +R / +P   **Method:** static review of release build + source; no third-party apps tested

| ID | MASVS | Severity | Finding | Evidence | Fix |
|---|---|---|---|---|---|
| M-1 | MASVS-STORAGE-1 | High | Refresh token in AsyncStorage | src/auth/store.ts:22 | Move to expo-secure-store; migrate on next launch |
| M-2 | MASVS-NETWORK-2 | Medium | Pin set has no backup pin | network_security_config.xml:6 | Add backup SPKI, set expiration |

**Rotated secrets:** <list>   **Not covered:** <dynamic testing, backend APIs>

Verification

  • Re-run the artifact scan on the new release build: no secrets, manifest flags correct.
  • On a test device, confirm tokens are absent from app backups and plain-text storage (inspect your debug build’s app container) and present only in Keychain/Keystore.
  • Confirm a proxy with an untrusted CA fails the connection on release builds, and that pins plus backup pin succeed against production and staging.
  • Confirm server rejects requests with missing or failed attestation for the protected actions only, and that the forced-update path works.

Gotchas

  • EXPO_PUBLIC_* and react-native-config values are compiled into the bundle; they are configuration, not secrets.
  • Debug network configs (<debug-overrides>, trust user CAs) must stay in debug build types; check the merged manifest of the release variant.
  • Pinning a leaf certificate breaks every app install at the next certificate renewal.
  • Firebase/Google API keys in the app are expected; unrestricted keys are the finding.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc