AsyncStorage
.expo-secure-store
, never AsyncStorage
. Period.semgrep
-
eslint-plugin-security -
npm audit -
MobSF on every release artifact.Mobile attacks are up. Regulators are watching. AI is writing more of your code than ever β and the patterns it reproduces aren't always the secure ones. Here's the practical checklist I run through for every React Native / Expo app, organized around the OWASP Mobile Top 10 (2024).
This is the working version of a longer guide β focused on what to actually change in your codebase this week.
// β Don't
await AsyncStorage.setItem('access_token', token);
// β
Do
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('access_token', token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
Anything in the bundle can be extracted with apktool
in minutes. Anything in AsyncStorage
is plaintext on disk. Tokens go in the OS keychain via expo-secure-store
or react-native-keychain
. Period.
Refresh tokens rotate on every use. Access tokens live 15 minutes. The backend is the trust boundary, not the client.
npm ci
npm audit --audit-level=high
A clean package.json
doesn't mean a clean app. Post-install scripts run with your dev-machine privileges. Native modules run with full app privileges.
npm ci
in CI, never npm install
.
import * as AuthSession from 'expo-auth-session';
// PKCE is the default in expo-auth-session β don't disable it.
const request = new AuthSession.AuthRequest({
clientId,
scopes: ['openid', 'profile'],
usePKCE: true,
redirectUri,
});
OAuth 2.0 with PKCE for third-party identity. JWTs with 15-minute access tokens and rotated refresh tokens. Every endpoint validates the caller server-side. Hiding UI is not authorization.
Add MFA via expo-local-authentication
for anything touching payments, identity, or health data.
// Treat the URL params as hostile
const handleDeepLink = (url: string) => {
const parsed = new URL(url);
const action = parsed.searchParams.get('action');
if (action && /^[a-z_]{1,32}$/.test(action) && KNOWN_ACTIONS.has(action)) {
routeTo(action);
}
};
Deeplinks, push payloads, clipboard, QR codes, WebView messages β all untrusted. Validate type, length, format. Parameterized queries for local SQLite. originWhitelist
on every WebView
.
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.yourapp.com</domain>
<pin-set>
<pin digest="SHA-256">{base64-spki-hash}</pin>
<pin digest="SHA-256">{backup-spki-hash}</pin>
</pin-set>
</domain-config>
</network-security-config>
Pin to the SPKI hash, not the leaf cert. Ship a backup pin. Have a rotation plan. Reject TLS 1.0/1.1 server-side.
In Expo, usesCleartextTraffic: false
. Verify no allowsArbitraryLoads
snuck into production.
Maintain a data inventory. Apply data minimization. Request permissions just-in-time with context. Build account-delete-and-export flows that actually delete and export. Audit analytics/ad SDKs quarterly β they change practices on their schedule.
babel-plugin-transform-remove-console
in release builds.jail-monkey
for rooted/jailbroken detection (signal, not block).__DEV__
guards on every debug code path.android:exported="true"
only when truly needed.| Sensitivity | Storage |
|---|---|
| Credentials, keys | iOS Keychain / Android Keystore via expo-secure-store
|
| Structured PII | SQLCipher or encrypted Realm |
| Non-sensitive | Regular filesystem or AsyncStorage
|
Disable backup for sensitive paths. Mask app-switcher screenshots on sensitive screens via expo-screen-capture
.
import { randomBytes } from 'react-native-quick-crypto';
// AES-256-GCM. Never CBC without auth. Never ECB. Ever.
Argon2id for password hashing. HKDF for key derivation. SHA-1 and MD5 are dead. Start tracking your post-quantum migration β NIST's ML-KEM and ML-DSA are finalized.
This is the one most security frameworks haven't caught up to. LLMs reproduce the most common pattern in their training data β often the most common flawed pattern.
.cursorrules
or .github/copilot-instructions.md
with your secure defaults.semgrep
and eslint-plugin-security
on AI output before merge.Every PR:
semgrep
, eslint-plugin-security
, Android Lintnpm audit
- Socket/SnykAnnually: pen test. Quarterly: SDK audit. Always: an incident response plan that includes key rotation, token revocation, and an OTA push.
Most mobile breaches aren't sophisticated. They're a hardcoded key, a forgotten debug flag, a plaintext token. The OWASP Top 10 is your checklist β work it every release.
For the longer breakdown β including the AI-safe-code generation patterns and the full OWASP-mapped CI workflow β see the RapidNative blog.
What's the security gotcha you've shipped to production and then quietly patched? Drop it in the comments β I'm collecting the failure modes that don't make it into the OWASP examples for a follow-up.