Expo streamlines React Native development, but its managed workflow can obscure security-critical configurations. This guide provides a practitioner-focused approach to hardening Expo applications with concrete steps, verification commands, and recovery procedures. Whether you are a developer, DevOps consultant, or technical startup team, you will learn how to inventory your environment, apply safe configurations, verify changes locally, handle common failures, and maintain ongoing security operations. The goal is to reduce rework by implementing security in a structured, incremental way that is easy to inspect and validate.
Version and Environment Inventory
Before making any changes, establish a baseline of your current setup. This includes the Expo SDK version, React Native version, Node.js version, and package manager. Run the following commands in your project root:
npx expo --version
npm list react-native
node --version
npm --version
Inspect your app.json or app.config.js for current settings. Record everything in a simple inventory table:
| Component | Version | Source |
|---|---|---|
| Expo SDK | 51.0.0 | package.json |
| React Native | 0.74.1 | package.json |
| Node.js | 20.11.0 | .nvmrc |
| npm | 10.2.4 | npm -v |
Update this table monthly. An accurate inventory identifies outdated dependencies, unsupported SDKs, and configuration drift before they become vulnerabilities.
Safe Configuration Path
Apply hardening in small, verifiable increments. Each change should be testable locally before committing.
Secrets Management
Never hardcode API keys, tokens, or secrets in source code. Use environment variables with Expo's extra configuration field.
Create a .env file at the project root (add it to .gitignore):
API_KEY=your_production_api_key
ANALYTICS_TOKEN=your_analytics_token
Configure app.config.js to load these values:
require('dotenv').config();
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
extra: {
apiKey: process.env.API_KEY,
analyticsToken: process.env.ANALYTICS_TOKEN,
},
},
};
Access secrets in your application code:
import Constants from 'expo-constants';
const apiKey = Constants.expoConfig?.extra?.apiKey;
For EAS Build, store secrets in the Expo dashboard under Project > Secrets rather than committing them. This separates build-time configuration from source control.
Permissions Minimization
Request only the permissions your app actively uses. Each unnecessary permission expands the attack surface and triggers additional app store scrutiny.
In app.json, explicitly declare the minimal Android permission set:
{
"expo": {
"android": {
"permissions": [
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE"
]
}
}
}
For iOS, configure permission strings through the relevant Expo config plugins. Example for camera access:
{
"expo": {
"plugins": [
["expo-camera", {
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera for document scanning"
}]
]
}
}
Audit permissions quarterly. Remove any permission not tied to a shipped feature.
Network Security Configuration
Enforce HTTPS and certificate validation on both platforms.
Android: Create resources/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config>
<domain includeSubdomains="true">api.yourdomain.com</domain>
<pin-set expiration="2026-01-01">
<pin algorithm="SHA-256">base64_encoded_spki_pin_here</pin>
<pin algorithm="SHA-256">base64_encoded_backup_pin_here</pin>
</pin-set>
</domain-config>
</network-security-config>
Reference it in app.json:
{
"expo": {
"android": {
"networkSecurityConfig": "./resources/xml/network_security_config.xml"
}
}
}
This configuration blocks cleartext HTTP, pins certificates for your API domain, and includes a backup pin for rotation safety.
iOS: App Transport Security (ATS) is enabled by default. Explicitly configure it in app.json to prevent accidental weakening:
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": false,
"NSExceptionDomains": {
"api.yourdomain.com": {
"NSExceptionMinimumTLSVersion": "TLSv1.2",
"NSRequiresCertificateTransparency": true
}
}
}
}
}
}
}
Secure Storage
Use expo-secure-store for tokens, encryption keys, and other small sensitive values. Never use AsyncStorage for secrets.
npx expo install expo-secure-store
Implementation pattern:
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'auth_token';
export async function saveToken(token: string) {
await SecureStore.setItemAsync(TOKEN_KEY, token, {
keychainService: 'com.yourcompany.myapp',
keychainAccessible: SecureStore.WHEN_UNLOCKED,
});
}
export async function getToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY, {
keychainService: 'com.yourcompany.myapp',
});
}
export async function clearToken() {
await SecureStore.deleteItemAsync(TOKEN_KEY, {
keychainService: 'com.yourcompany.myapp',
});
}
The keychainService parameter isolates your app's keychain items. WHEN_UNLOCKED ensures data is inaccessible when the device is locked.
Verification and Diagnostics
After each configuration change, verify the behavior locally before merging.
Verify Secrets Loading
Add a temporary development-only check:
if (__DEV__) {
console.log('API Key loaded:', Constants.expoConfig?.extra?.apiKey ? 'PRESENT' : 'MISSING');
}
Run npx expo start and confirm the key loads without printing its value.
Verify Permissions
Android: Build a development client and inspect granted permissions:
adb shell dumpsys package com.yourcompany.myapp | grep -E "permission.*granted"
Output should show only android.permission.INTERNET and android.permission.ACCESS_NETWORK_STATE (plus any runtime permissions the user granted).
iOS: Use the Settings app on the device or simulator to review the app's permission list. Confirm no unused permissions appear.
Verify Network Security
Test cleartext blocking with a local HTTP endpoint:
# Start a simple HTTP server in another terminal
python3 -m http.server 8080
In the app:
fetch('http://localhost:8080')
.then(() => console.log('UNEXPECTED: Cleartext succeeded'))
.catch(err => console.log('EXPECTED: Cleartext blocked', err.message));
On Android with the network security config, the request fails with "Cleartext HTTP traffic not permitted." On iOS, ATS blocks it with "App Transport Security has blocked a cleartext HTTP resource load."
Test certificate pinning by temporarily changing the pin in network_security_config.xml and confirming requests fail.
Verify Secure Storage
// Test persistence across app restarts
await saveToken('test-token-123');
const retrieved = await getToken();
console.log('Token retrieved:', retrieved === 'test-token-123' ? 'PASS' : 'FAIL');
// Restart app, then:
const afterRestart = await getToken();
console.log('Token after restart:', afterRestart === 'test-token-123' ? 'PASS' : 'FAIL');
Both checks should pass.
Failure Modes and Recovery
Plan for common failure scenarios to minimize downtime.
Network Failures from HTTPS Enforcement
Symptom: API calls fail after enabling network security config or ATS.
Immediate recovery: Add a temporary domain exception for the failing endpoint.
Android (network_security_config.xml):
<domain-config cleartextTrafficPermitted="true">
<domain>legacy-api.example.com</domain>
</domain-config>
iOS (app.json):
"NSExceptionDomains": {
"legacy-api.example.com": {
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
Permanent fix: Migrate the endpoint to HTTPS with a valid certificate. Remove the exception once migrated.
Permission-Related Crashes
Symptom: App crashes when accessing a feature after permission removal.
Recovery: Re-add the permission in app.json, rebuild the development client, and investigate whether the feature requires the permission or can be refactored to work without it.
For local testing only, grant permissions via ADB:
adb shell pm grant com.yourcompany.myapp android.permission.CAMERA
This does not affect production builds.
Secrets Not Loading
Symptom: Constants.expoConfig.extra.apiKey is undefined.
Diagnosis checklist:
- Confirm
.envexists and containsAPI_KEY=value - Verify
dotenvis installed:npm list dotenv - Check
app.config.jsimports dotenv before accessingprocess.env - Run
npx expo config --type publicand inspect the resolvedextraobject - Ensure the development client was rebuilt after config changes
Recovery: Fix the configuration, rebuild the development client (npx expo run:android or npx expo run:ios), and restart the Metro bundler with --reset-cache.
Configuration Rollback
If a change breaks the app:
git checkout -- app.json app.config.js resources/xml/network_security_config.xml
npx expo run:android # or run:ios
Keep commits atomic (one security change per commit) to make rollback precise.
Operations Checklist
Integrate these checks into your recurring workflow.
| Check | Frequency | Tool/Command | Expected Result |
|---|---|---|---|
| Verify Expo SDK version | Monthly | npx expo --version | Current version is supported (not EOL) |
| Check outdated dependencies | Weekly | npm outdated | No critical vulnerabilities; only minor/patch updates pending |
| Scan for committed secrets | Weekly | git secrets --scan or trufflehog git file://. --since-commit HEAD~10 | Zero findings |
| Review app permissions | Monthly | Inspect app.json and app.config.js | Only necessary permissions declared |
| Test HTTPS enforcement | Monthly | HTTP fetch in dev build | Request fails with network error |
| Verify secure storage usage | Monthly | Code review of storage calls | All tokens/keys use expo-secure-store |
| Run dependency audit | Weekly | npm audit --audit-level=high | Zero high/critical vulnerabilities |
| Validate EAS Build secrets | Monthly | Expo dashboard > Project > Secrets | No stale or unused secrets |
Post-Change Validation Routine
After every security-related change:
- Run the app in development mode and exercise core user flows
- Verify secret loading with the temporary debug log
- Test network calls to confirm HTTPS enforcement and certificate validation
- Review permission changes from a user perspective (prompt clarity, necessity)
- Document the change in
CHANGELOG.mdwith date, rationale, and verification steps
Conclusion
Securing an Expo application is a continuous discipline, not a one-time configuration. By maintaining an accurate environment inventory, applying hardening measures incrementally, verifying each change with concrete tests, and preparing recovery procedures for common failures, you significantly reduce your app's attack surface. The patterns covered here—secrets management through environment variables and EAS secrets, permission minimization, network security configuration with certificate pinning, and secure storage for sensitive data—form a practical baseline for any production Expo app. Start with the inventory step today, identify your highest-risk gap, implement the fix, and verify it works. Continue this cycle each sprint to build a sustainable security posture that protects your users and your reputation.