E-NO
Expo security 8 Min Read

Expo Security Hardening: A Practical Implementation Guide

calendar_today Published: 2026-08-19
update Last Updated: 2026-08-20
analytics SEO Efficiency: 100%
Technical guide illustration for Expo Security Hardening: A Practical Implementation Guide.

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:

ComponentVersionSource
Expo SDK51.0.0package.json
React Native0.74.1package.json
Node.js20.11.0.nvmrc
npm10.2.4npm -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.

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:

  1. Confirm .env exists and contains API_KEY=value
  2. Verify dotenv is installed: npm list dotenv
  3. Check app.config.js imports dotenv before accessing process.env
  4. Run npx expo config --type public and inspect the resolved extra object
  5. 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.

CheckFrequencyTool/CommandExpected Result
Verify Expo SDK versionMonthlynpx expo --versionCurrent version is supported (not EOL)
Check outdated dependenciesWeeklynpm outdatedNo critical vulnerabilities; only minor/patch updates pending
Scan for committed secretsWeeklygit secrets --scan or trufflehog git file://. --since-commit HEAD~10Zero findings
Review app permissionsMonthlyInspect app.json and app.config.jsOnly necessary permissions declared
Test HTTPS enforcementMonthlyHTTP fetch in dev buildRequest fails with network error
Verify secure storage usageMonthlyCode review of storage callsAll tokens/keys use expo-secure-store
Run dependency auditWeeklynpm audit --audit-level=highZero high/critical vulnerabilities
Validate EAS Build secretsMonthlyExpo dashboard > Project > SecretsNo stale or unused secrets

Post-Change Validation Routine

After every security-related change:

  1. Run the app in development mode and exercise core user flows
  2. Verify secret loading with the temporary debug log
  3. Test network calls to confirm HTTPS enforcement and certificate validation
  4. Review permission changes from a user perspective (prompt clarity, necessity)
  5. Document the change in CHANGELOG.md with 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.

Related Research

Article Quality Score

Reader usefulness 100%
  • check_circle Reader-ready guide
  • check_circle Practical examples included
  • check_circle Clean SEO article URL